9 Commits
Author SHA1 Message Date
maeneko 1278ad527d Add /sub/v1/devices and /sub/v1/peek, count the slots on register
release / release (push) Successful in 3m14s
- GET /sub/v1/devices: a device lists the devices of its own master key
  (name, platform, version, last seen, and which one is asking). Read-only:
  unbinding is still only DELETE /sub/v1/device for oneself.
- POST /sub/v1/peek: how many slots of a key are taken, asked with the
  link's secret before any device exists, so the client can show it while
  the link is only pasted. Wrong secrets count against the failure limit.
- register also answers with devices / device_limit, so the client needs no
  second request to show the slot it just took.
2026-09-25 17:24:52 +03:00
maeneko 6e010a0141 Bump to Beta 0.3.0
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

First release with the sen:// subscription and master keys.
2026-09-25 17:24:52 +03:00
maeneko 923bd87fe9 Add sen:// subscription with master keys
A master key is a sen:// link with a device limit. Each device
generates its own WireGuard keypair and registers only the public key,
then polls a signed /sub/v1/config, so params and generation changes
reach users without re-importing a key.

- awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and
  rebuildConf take peers into account (output unchanged without them)
- awg-ui: link codec and signatures (sen.ts), registry, panel routes
  and the public listener on SUB_PORT (sub.ts)
- responses are always Ed25519-signed; TLS with a pinned self-signed
  certificate is optional
- devices report a client version, shown as platform-version
- "Мастер-ключи" tab: a table with the devices nested under each key,
  a device-limit meter, per-key settings and a placeholder for the
  servers a key serves; cards on mobile
- install.sh/CLI: subscription signing key, optional certificate, port;
  kept across reinstalls with KEEP_DATA
- add the missing mock-server.ts used by npm run dev:mock
2026-09-25 17:24:52 +03:00
maeneko d5e00ddaf8 Bump to Beta 0.2.2
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.
2026-09-10 17:51:19 +03:00
maeneko 2b46d04af9 Support Debian in the installer
install.sh only knew Ubuntu. On Debian 13 the dependency step died on
software-properties-common, which trixie no longer ships, and on
linux-headers-generic, which Debian never had — so add-apt-repository and the
Ubuntu headers meta package are both off the table there.

The distro family now comes from /etc/os-release (derivatives via ID_LIKE) and
shows up as its own line in the compatibility checklist. On Debian the amnezia
PPA is wired by hand: the key is fetched from keyserver.ubuntu.com by its full
fingerprint and checked after download, then a signed-by source is written on
the Ubuntu series whose glibc is not newer than the Debian release's
(bullseye→focal, bookworm→jammy, trixie→noble; AMNEZIA_PPA_SUITE overrides).

Headers are installed as a separate step so a missing package gets precise
advice instead of a generic failure. Debian's archive keeps only the latest
kernel build, so a server that hasn't rebooted in a while has no headers for
its running kernel; the hint names linux-image/linux-headers for the kernel's
own flavour (cloud-amd64 and so on) plus a reboot.

iptables joins the dependencies — PostUp calls it, and a minimal Debian 13 on
nftables doesn't have it. IP forwarding moves to its own file in
/etc/sysctl.d: Debian 13 no longer reads /etc/sysctl.conf at boot, so the old
line would have held only until the first reboot.
2026-09-10 17:51:19 +03:00
maeneko a358504e2a Bump to Beta 0.2.1
release / release (push) Successful in 3m31s
ci / check (push) Successful in 6m8s
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.
2026-09-10 00:24:18 +03:00
maeneko 66ae59e1d0 Add the admin panel scaffold
A self-contained template: an Express backend with login, JWT, rate limiting
and SPA serving, and a React 18 + Vite shell with the same tab registry and
warm Material 3 design system the panel uses. No domain logic — routes and tabs
are meant to be hung off it.

node_modules and the frontend build stay out through the existing ignores.
2026-09-10 00:24:18 +03:00
maeneko a11cfc88de Bring back the external API keys
Commit 0dbb13f removed the whole feature: the apikeys tab, the /ui/apikeys
routes, requireApiKey, the /api/v1 router, the ApiKey type, the better-sqlite3
dependency and UI_DB_FILE in the CLI and the installer. Only the untracked
awg-ui/API.md survived, so the README went on to claim there was no key-based
API at all.

Restores all of it: ui.db with the api_keys table, awgk_ key generation storing
only the SHA-256 and a display prefix, the JWT-guarded CRUD, and the /api/v1
router mounted before the JWT proxy on /api so the key auth is not swallowed.
The tab comes back as its own folder and the registry picks it up on its own.
UI_DB_FILE points at /etc/amnezia/amneziawg/ui.db, outside the install tree, so
reinstalling keeps the keys; a fresh install moves the old one aside with its
WAL sidecars.

better-sqlite3 takes the slot of jsonwebtoken, which arrived with the MA7
integration and has been unused since 068cab4 removed it.

One defect is not reproduced: the /api/v1 handlers called ctrl() without a
try/catch, and Express 4 does not catch async rejections, so a single request
with awg-ctrl down killed the panel process. They now answer 502 like proxy()
already did.

The API contract is documented in the README rather than linked: .gitignore
excludes every markdown but README.md, so awg-ui/API.md is not in the repo and
the link would break on a clone.

Checked against a running server: login, key create/list/delete, 400 on a bad
label, 401 without a JWT, "API key required" rather than "Unauthorized" on
/api/v1, "Invalid API key" for a deleted key, last_used updating, and all three
/api/v1 routes returning 502 with the process still alive.
2026-09-10 00:24:18 +03:00
maeneko 44c032b376 Document the panel API in the README
The public /api/v1 surface and its docs went away with the API keys, leaving
nothing written down about what the panel exposes — even though every route it
uses is reachable over HTTP with a login token.

Replaces the install-source section with an API chapter: the JWT flow and its
401/429 cases, the unauthenticated /ui/brand, health and interface status, the
user routes including why fetching a key is a POST, reissue, and the 2.0 to 3.1
upgrade with its 409 and 500 cases. What is left of the install-source notes is
one line pointing at the comments in install.sh and .env.

Shapes and status codes are taken from the handlers rather than from memory,
and the auth behaviour was checked against a running server.
2026-08-29 03:45:27 +03:00
40 changed files with 7242 additions and 69 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.2.0 VERSION=0.3.0
+103 -29
View File
@@ -9,13 +9,13 @@
| Компонент | Роль | | Компонент | Роль |
|---|---| |---|---|
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. | | **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к 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 проверяет подпись публичным.
## Требования ## Требования
- **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) - **ОС**: Ubuntu 24.04+ (Тесты проходили на Ubuntu 24.04.4 LTS) или Debian 11–13 (поддержка экспериментальная: PPA amnezia подключается вручную, серия подбирается по релизу Debian и переопределяется через `AMNEZIA_PPA_SUITE`)
- **Права**: root. - **Права**: root.
- **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG). - **Ядро**: kvm (не OpenVZ/LXC/Docker/WSL — нужен загружаемый kernel-модуль AmneziaWG).
- **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля). - **Заголовки ядра** под текущее ядро (для сборки DKMS-модуля).
@@ -43,45 +43,119 @@ AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branc
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически. автоматически.
### Свой источник и своё имя Источник архива и имя панели не зашиты в код — задаются переменными
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
(подробности — в комментариях `install.sh` и в `.env`).
В коде не зашиты ни репозиторий, ни бренд — всё задаётся окружением до запуска: ## API
| Переменная | По умолчанию | Зачем | Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
|---|---|---| стабильный контракт для сторонних программ, авторизация по API-ключу.
| `REPO_BASE` | `https://git.ma7neko.ru/maeneko/forgetting` | основной источник архива релиза | **Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
| `REPO_FALLBACK` | `https://github.com/maeneko/forgetting` | запасной, если в основном релиза ещё нет | URL в обоих случаях — адрес сервера и порт, выбранный при установке.
| `ARCHIVE_URL` | — | полный URL архива, перекрывает оба варианта |
| `BRAND` | из `.env` | имя продукта в баннерах, systemd-юните и панели |
Путь релиза у Gitea и GitHub одинаковый (`<repo>/releases/download/v<версия>/awgcontrol-<версия>.tar.gz`), > [!WARNING]
так что подходит любой из них. Если архив не найден в основном источнике, > Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
установщик предупреждает и берёт его из запасного. > публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
> без TLS и ограничения по адресам.
Архив собирается по тегу `v*`: на GitHub — `.github/workflows/release.yml`, в ### Публичный API (`/api/v1`)
Gitea — `.gitea/workflows/release.yml` (сборка та же, публикация через API
Gitea). Для запуска в Gitea репозиторий не должен быть pull-зеркалом, Actions Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`.
включены, а runner зарегистрирован с меткой `ubuntu-latest`. Открытое значение показывается один раз при создании — на сервере лежит только
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
`psk_key` и `pub_key` не покидают сервер.
```bash ```bash
REPO_BASE=https://git.example.org/me/vpn BRAND="My VPN" \ curl -s -X POST http://HOST:PORT/api/v1/users \
bash <(curl -Ls https://git.example.org/me/vpn/raw/branch/main/install.sh) -H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
-d '{"name":"alice"}' | jq -r .vpn_key
``` ```
Имя панели, канал и версия лежат в `.env` в корне проекта (едет в архиве | Метод | Что делает |
релиза, на сервере — `/opt/awg-control/.env`): |---|---|
| `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" }` |
```env `gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или
BRAND=Forgetting `"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
CHANNEL=Beta ключ = удалить и создать заново.
VERSION=0.2.0
| Код | Когда |
|---|---|
| `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)
``` ```
Оттуда их читают CLI (баннер меню) и панель (вордмарк, `GET /ui/brand`). | Метод | Ответ и ошибки |
`BRAND=` при установке перекрывает значение из файла. |---|---|
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
### Состояние
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс:
`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
### Пользователи
| Метод | Что делает |
|---|---|
| `POST /api/users` | Создаёт пира. Тело `{ "name": "alice" }`, имя — `a–z A–Z 0–9 _ -`, до 32 символов. `201` с `{ name, ip, pub_key, psk_key, vpn_key, key_gen, vpn_key_prev }`; `400` — имя не прошло валидацию, `409` — уже есть |
| `GET /api/users` | Список: `{ users: [{ name, ip, pub_key, vpn_key, key_gen, online, lastHandshake }] }` |
| `GET /api/users/stats` | То же без ключей, но со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` |
| `POST /api/users/:name` | Возвращает пользователя целиком, включая `vpn_key`. Метод именно `POST`, чтобы ключ не оседал в логах и истории как параметр `GET`. `404` — не найден |
| `DELETE /api/users/:name` | `{ "success": true, "name": "alice" }` |
| `POST /api/users/reissue` | Пересобирает `vpn://` всем на текущих параметрах: `{ total, reissued, regenerated: [], backup }`. IP и ключевая пара сохраняются, клиентам нужно заново импортировать ключ; `backup` — путь к снимку базы, снятому перед проходом |
```bash
curl -s -X POST http://HOST:PORT/api/users \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"alice"}' | jq -r .vpn_key
```
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
base64url-декодировать, отбросить первые 4 байта (длина), распаковать
`zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
### Интерфейс AWG
| Метод | Что делает |
|---|---|
| `POST /awg/start` | Поднимает интерфейс, если он лежит; возвращает его статус |
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
| `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не
3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0.
Сама панель и её API к домену не привязаны: работают по адресу того сервера,
куда установлены, внешних сервисов не требуют.
## Управление ## Управление
```bash ```bash
+67
View File
@@ -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).
+1491
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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"
}
}
+131
View File
@@ -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}`);
});
+14
View File
@@ -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"]
}
+12
View File
@@ -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>
+2096
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+554
View File
@@ -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;
}
}
+211
View File
@@ -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>
);
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Иконки интерфейса (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,
};
+7
View File
@@ -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';
+62
View File
@@ -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) + ' ч';
}
+12
View File
@@ -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>
);
+34
View File
@@ -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`.
+81
View File
@@ -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 вкладок должны быть уникальны`);
}
+4
View File
@@ -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" />
+15
View File
@@ -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"]
}
+18
View File
@@ -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',
},
},
});
+175 -5
View File
@@ -87,6 +87,20 @@ db.exec(`
) )
`); `);
// Пиры устройств sen://-подписки. Отдельная таблица, users не трогаем: у этих
// пиров нет ни vpn_key, ни имени — приватный ключ живёт только на устройстве,
// сюда приходит лишь публичный. owner — непрозрачная метка от awg-ui
// («m<masterId>/d<deviceId>»), нужна только для комментария в conf и логов.
db.exec(`
CREATE TABLE IF NOT EXISTS peers (
pub_key TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
psk_key TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
`);
const cfgStmts = { const cfgStmts = {
get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"), get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"),
set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"), set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"),
@@ -276,12 +290,27 @@ interface UserRow {
vpn_key_prev: string; vpn_key_prev: string;
} }
interface PeerRow {
pub_key: string;
ip: string;
psk_key: string;
owner: string;
created_at: number;
}
const stmts = { const stmts = {
peersAll: db.prepare<[], PeerRow>("SELECT * FROM peers ORDER BY created_at, rowid"),
peerGet: db.prepare<[string], PeerRow>("SELECT * FROM peers WHERE pub_key = ?"),
peerInsert: db.prepare<[string, string, string, string, number]>("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)"),
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
all: db.prepare<[], UserRow>("SELECT * FROM users"), 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 (?, ?, ?, ?, ?, ?)"), 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 UNION SELECT ip FROM peers"),
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут // Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
// смениться, если исходный ключ не удалось разобрать. // смениться, если исходный ключ не удалось разобрать.
reissue: db.prepare<[string, string, string, string, string]>( reissue: db.prepare<[string, string, string, string, string]>(
@@ -547,17 +576,22 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
function rebuildConf() { function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[]; const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const devices = stmts.peersAll.all();
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`); const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
if (!existsSync(confFile)) return; if (!existsSync(confFile)) return;
const conf = readFileSync(confFile, "utf8"); const conf = readFileSync(confFile, "utf8");
const iface = conf.split(/^\[Peer\]/m)[0].trimEnd(); const iface = conf.split(/^\[Peer\]/m)[0].trimEnd();
const peers = (users as any[]).map(u => // Без записей в peers вывод байт-в-байт прежний.
`\n# ${u.name}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}` const peers = [
...users.map(u => ({ label: u.name, ...u })),
...devices.map(d => ({ label: d.owner || "device", ...d })),
].map(u =>
`\n# ${u.label}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
).join("\n"); ).join("\n");
writeFileSync(confFile, iface + "\n" + peers + "\n"); writeFileSync(confFile, iface + "\n" + peers + "\n");
logger.info("conf rebuilt", { peers: users.length }); logger.info("conf rebuilt", { peers: users.length + devices.length });
} }
const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key"; const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key";
@@ -829,6 +863,89 @@ function removeUser(username: string) {
logger.info("user removed", { name: username }); logger.info("user removed", { name: username });
} }
// ── Пиры устройств (sen://) ────────────────────────────────────────────────
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим
// публичным ключом». Приватного ключа устройства здесь нет и быть не может.
class HttpError extends Error {
constructor(public status: number, message: string) { super(message); }
}
// Публичный ключ WireGuard: base64 от 32 байт (44 символа, последний «=»).
function checkPubKey(k: unknown): string {
if (typeof k !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(k) || Buffer.from(k, "base64").length !== 32)
throw new HttpError(400, "Неверный публичный ключ");
return k;
}
// В пути base64url: «/» и «+» обычного base64 в URL ломают маршрут.
function pubKeyFromParam(p: string): string {
return checkPubKey(Buffer.from(p, "base64url").toString("base64"));
}
function assertPubKeyFree(pub: string) {
if (stmts.peerGet.get(pub) || stmts.userPubTaken.get(pub)!.n > 0 || pub === getServerPublicKey())
throw new HttpError(409, "Публичный ключ уже используется");
}
function addPeer(pubKey: string, owner: string): { ip: string; psk_key: string } {
checkPubKey(pubKey);
const psk = run("awg genpsk");
const ip = db.transaction(() => {
assertPubKeyFree(pubKey);
const ip = nextIp();
stmts.peerInsert.run(pubKey, ip, psk, owner, Math.floor(Date.now() / 1000));
return ip;
})();
const r = setPeer(pubKey, psk, ip);
if (r.status !== 0) {
stmts.peerDelete.run(pubKey);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
rebuildConf();
logger.info("peer added", { owner, ip });
return { ip, psk_key: psk };
}
// Смена ключа устройства: IP и PSK остаются, меняется только pub_key.
function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: string } {
checkPubKey(newPub);
const old = stmts.peerGet.get(oldPub);
if (!old) throw new HttpError(404, "Пир не найден");
db.transaction(() => { assertPubKeyFree(newPub); stmts.peerRekey.run(newPub, oldPub); })();
const r = setPeer(newPub, old.psk_key, old.ip);
if (r.status !== 0) {
stmts.peerRekey.run(oldPub, newPub);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]);
rebuildConf();
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
return { ip: old.ip, psk_key: old.psk_key };
}
function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
const psk = run("awg genpsk");
const r = setPeer(pub, psk, peer.ip);
if (r.status !== 0) throw new Error(`awg set failed: ${r.stderr?.toString()}`);
stmts.peerPsk.run(psk, pub);
rebuildConf();
logger.info("peer psk rotated", { owner: peer.owner });
return { ip: peer.ip, psk_key: psk };
}
function removePeer(pub: string) {
const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден");
stmts.peerDelete.run(pub);
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
rebuildConf();
logger.info("peer removed", { owner: peer.owner });
}
const app = express(); const app = express();
app.use(express.json({ limit: "1kb" })); app.use(express.json({ limit: "1kb" }));
@@ -863,7 +980,10 @@ function validateName(req: Request, res: Response, next: NextFunction) {
function handler(fn: (req: Request, res: Response) => void | Promise<void>) { function handler(fn: (req: Request, res: Response) => void | Promise<void>) {
return async (req: Request, res: Response) => { return async (req: Request, res: Response) => {
try { await fn(req, res); } try { await fn(req, res); }
catch (e) { logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); } catch (e) {
if (e instanceof HttpError) { res.status(e.status).json({ error: e.message }); return; }
logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" });
}
}; };
} }
@@ -931,6 +1051,56 @@ app.delete("/api/users/:name", auth, validateName, handler((req, res) => {
res.json({ success: true, name: req.params.name }); res.json({ success: true, name: req.params.name });
})); }));
app.post("/api/peers", auth, handler((req, res) => {
const { pub_key, owner } = (req.body ?? {}) as { pub_key?: string; owner?: string };
if (typeof owner !== "string" || !/^[\w/.-]{0,64}$/.test(owner)) throw new HttpError(400, "Неверный owner");
res.status(201).json(addPeer(checkPubKey(pub_key), owner));
}));
app.get("/api/peers", auth, handler((_req, res) => {
const stats = getPeersData();
res.json({
peers: stmts.peersAll.all().map(p => ({
pub_key: p.pub_key, ip: p.ip, psk_key: p.psk_key, owner: p.owner,
online: stats[p.pub_key]?.online ?? false,
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
rx: stats[p.pub_key]?.rx ?? 0,
tx: stats[p.pub_key]?.tx ?? 0,
})),
});
}));
app.put("/api/peers/:pub", auth, handler((req, res) => {
const { pub_key } = (req.body ?? {}) as { pub_key?: string };
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
}));
app.post("/api/peers/:pub/psk", auth, handler((req, res) => {
res.json(rotatePeerPsk(pubKeyFromParam(req.params.pub)));
}));
app.delete("/api/peers/:pub", auth, handler((req, res) => {
removePeer(pubKeyFromParam(req.params.pub));
res.json({ success: true });
}));
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
app.get("/api/profile", auth, handler((_req, res) => {
const awg: Record<string, string> = {};
for (const [k, v] of Object.entries(CONFIG.awgParams)) if (String(v) !== "") awg[k] = String(v);
res.json({
name: CONFIG.serverName,
endpoint: `${CONFIG.serverIp}:${CONFIG.serverPort}`,
server_pub: getServerPublicKey(),
gen: CONFIG.gen,
dns: [CONFIG.dns1, CONFIG.dns2],
keepalive: CONFIG.keepalive,
mtu: CONFIG.mtu,
awg,
});
}));
app.post("/awg/restart", auth, handler((_req, res) => { app.post("/awg/restart", auth, handler((_req, res) => {
restartAwg(); restartAwg();
res.json({ success: true }); res.json({ success: true });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.2.0", "version": "0.3.0",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// Локальный мок для разработки фронтенда: эмулирует awg-ui + awg-ctrl целиком в
// памяти, AWG не нужен. Логин admin / admin. Запуск: npm run dev:mock.
import express from "express";
import crypto from "crypto";
import { encodeSenLink } from "./sen";
const app = express();
app.use(express.json());
const now = () => Math.floor(Date.now() / 1000);
const rnd = (n: number) => Math.floor(Math.random() * n);
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
const GEN = "3.1";
// ── пользователи (vpn://) ──
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
}));
// ── API-ключи ──
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
let apiId = 1;
// ── мастер-ключи sen:// ──
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, created_at: m.created_at });
const find = (id: string) => masters.find(m => m.id === Number(id));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } }));
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
app.get("/api/users", (_q, r) => r.json({ users }));
app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => {
const name = String(q.body.name ?? "");
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
users.push(u); r.status(201).json(u);
});
app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); });
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
app.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); });
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,14 +12,14 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"jsonwebtoken": "^9.0.0", "better-sqlite3": "^12.10.0",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"@types/jsonwebtoken": "^9.0.0", "@types/better-sqlite3": "^7.6.13",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+234
View File
@@ -0,0 +1,234 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// sen:// — ссылка-подписка для SenAWG и подписи протокола /sub/v1.
// Байтовая раскладка и схема подписей описаны в docs/sen-link.md; клиент
// реализуется по ней, поэтому любое изменение здесь — изменение протокола.
import crypto from "crypto";
export const SEN_PREFIX = "sen://";
export const SEN_VERSION = 1;
export const FLAG_TLS = 0x01;
const ADDR_V4 = 0x04;
const ADDR_V6 = 0x06;
const ADDR_DOMAIN = 0x44;
export class SenLinkError extends Error {}
export interface SenAddr { host: string; port: number }
export interface SenLink {
tls: boolean;
addrs: SenAddr[];
secret: Buffer; // 16 байт
signPub: Buffer; // 32 байта, Ed25519
tlsPin?: Buffer; // 32 байта, только при tls
name: string;
}
// ── CRC32 (IEEE) ───────────────────────────────────────────────────────────
// zlib.crc32 появился только в Node 20.15, а целимся в 20.x целиком.
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
export function crc32(buf: Uint8Array): number {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// ── Адреса ─────────────────────────────────────────────────────────────────
function ipv6ToBytes(host: string): Buffer | null {
const parts = host.split("::");
if (parts.length > 2) return null;
const head = parts[0] ? parts[0].split(":") : [];
const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
const missing = 8 - head.length - tail.length;
if (parts.length === 1 ? head.length !== 8 : missing < 1) return null;
const groups = [...head, ...Array(parts.length === 2 ? missing : 0).fill("0"), ...tail];
if (groups.length !== 8) return null;
const out = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
if (!/^[0-9a-fA-F]{1,4}$/.test(groups[i])) return null;
out.writeUInt16BE(parseInt(groups[i], 16), i * 2);
}
return out;
}
function bytesToIpv6(b: Buffer): string {
const g: string[] = [];
for (let i = 0; i < 16; i += 2) g.push(b.readUInt16BE(i).toString(16));
// Самый длинный run нулей сворачиваем в «::» — как принято в текстовой записи.
let bestStart = -1, bestLen = 0;
for (let i = 0; i < 8;) {
if (g[i] !== "0") { i++; continue; }
let j = i;
while (j < 8 && g[j] === "0") j++;
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
i = j;
}
if (bestLen < 2) return g.join(":");
return g.slice(0, bestStart).join(":") + "::" + g.slice(bestStart + bestLen).join(":");
}
function encodeAddr({ host, port }: SenAddr): Buffer {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new SenLinkError(`Неверный порт: ${port}`);
const p = Buffer.alloc(2);
p.writeUInt16BE(port);
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
const o = host.split(".").map(Number);
if (o.some(n => n > 255)) throw new SenLinkError(`Неверный IPv4: ${host}`);
return Buffer.concat([Buffer.from([ADDR_V4, ...o]), p]);
}
if (host.includes(":")) {
const b = ipv6ToBytes(host);
if (!b) throw new SenLinkError(`Неверный IPv6: ${host}`);
return Buffer.concat([Buffer.from([ADDR_V6]), b, p]);
}
const h = Buffer.from(host, "utf8");
if (h.length < 1 || h.length > 253 || !/^[A-Za-z0-9.-]+$/.test(host))
throw new SenLinkError(`Неверный домен: ${host}`);
return Buffer.concat([Buffer.from([ADDR_DOMAIN, h.length]), h, p]);
}
// ── Ссылка ─────────────────────────────────────────────────────────────────
export function encodeSenLink(l: SenLink): string {
if (l.addrs.length < 1 || l.addrs.length > 3) throw new SenLinkError("Адресов должно быть от 1 до 3");
if (l.secret.length !== 16) throw new SenLinkError("secret: 16 байт");
if (l.signPub.length !== 32) throw new SenLinkError("signPub: 32 байта");
if (l.tls && l.tlsPin?.length !== 32) throw new SenLinkError("tlsPin: 32 байта при TLS");
const name = Buffer.from(l.name, "utf8");
if (name.length > 64) throw new SenLinkError("Имя: до 64 байт");
const body = Buffer.concat([
Buffer.from([SEN_VERSION, l.tls ? FLAG_TLS : 0, l.addrs.length]),
...l.addrs.map(encodeAddr),
l.secret,
l.signPub,
...(l.tls ? [l.tlsPin as Buffer] : []),
Buffer.from([name.length]),
name,
]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return SEN_PREFIX + Buffer.concat([body, crc]).toString("base64url");
}
export function decodeSenLink(link: string): SenLink {
const t = link.trim();
if (!t.toLowerCase().startsWith(SEN_PREFIX)) throw new SenLinkError("Ссылка должна начинаться с sen://");
const payload = t.slice(SEN_PREFIX.length).replace(/\s+/g, "");
if (!/^[A-Za-z0-9_-]+$/.test(payload)) throw new SenLinkError("Ссылка повреждена: недопустимые символы");
const raw = Buffer.from(payload, "base64url");
if (raw.length < 3 + 4) throw new SenLinkError("Ссылка слишком короткая");
const body = raw.subarray(0, raw.length - 4);
if (raw.readUInt32BE(raw.length - 4) !== crc32(body)) throw new SenLinkError("Ссылка повреждена: не сошлась контрольная сумма");
let off = 0;
const need = (n: number) => { if (off + n > body.length) throw new SenLinkError("Ссылка обрезана"); };
const take = (n: number) => { need(n); const s = body.subarray(off, off + n); off += n; return s; };
const version = take(1)[0];
if (version !== SEN_VERSION) throw new SenLinkError(`Неизвестная версия ссылки: ${version}`);
const flags = take(1)[0];
if (flags & ~FLAG_TLS) throw new SenLinkError("Неизвестные флаги ссылки");
const n = take(1)[0];
if (n < 1 || n > 3) throw new SenLinkError("Неверное число адресов");
const addrs: SenAddr[] = [];
for (let i = 0; i < n; i++) {
const type = take(1)[0];
let host: string;
if (type === ADDR_V4) host = [...take(4)].join(".");
else if (type === ADDR_V6) host = bytesToIpv6(Buffer.from(take(16)));
else if (type === ADDR_DOMAIN) {
const len = take(1)[0];
if (len < 1) throw new SenLinkError("Пустой домен");
host = Buffer.from(take(len)).toString("utf8");
if (!/^[A-Za-z0-9.-]+$/.test(host)) throw new SenLinkError("Неверный домен");
} else throw new SenLinkError(`Неизвестный тип адреса: ${type}`);
const port = Buffer.from(take(2)).readUInt16BE(0);
if (port === 0) throw new SenLinkError("Порт 0");
addrs.push({ host, port });
}
const secret = Buffer.from(take(16));
const signPub = Buffer.from(take(32));
const tls = (flags & FLAG_TLS) !== 0;
const tlsPin = tls ? Buffer.from(take(32)) : undefined;
const nameLen = take(1)[0];
if (nameLen > 64) throw new SenLinkError("Имя длиннее 64 байт");
const name = Buffer.from(take(nameLen)).toString("utf8");
if (off !== body.length) throw new SenLinkError("Лишние данные в конце ссылки");
return { tls, addrs, secret, signPub, tlsPin, name };
}
// ── Ключи Ed25519 в «сыром» виде (32 байта) ────────────────────────────────
const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
export function rawPublicKey(key: crypto.KeyObject): Buffer {
const der = key.export({ type: "spki", format: "der" }) as Buffer;
return der.subarray(der.length - 32);
}
export function publicKeyFromRaw(raw: Buffer): crypto.KeyObject {
if (raw.length !== 32) throw new SenLinkError("Публичный ключ Ed25519: 32 байта");
return crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), format: "der", type: "spki" });
}
/** SHA-256 от SPKI DER сертификата — то, что кладётся в ссылку как pin. */
export function spkiPin(certPem: string): Buffer {
const spki = new crypto.X509Certificate(certPem).publicKey.export({ type: "spki", format: "der" }) as Buffer;
return crypto.createHash("sha256").update(spki).digest();
}
// ── Подписи протокола ──────────────────────────────────────────────────────
// Ответ: {"body": "<JSON-строка>", "sig": "<base64url>"}; sig — Ed25519 над
// UTF-8 байтами строки body. body — именно строка, а не вложенный объект,
// чтобы клиенту не нужна была каноническая сериализация JSON.
export function signResponse(payload: unknown, signKey: crypto.KeyObject): { body: string; sig: string } {
const body = JSON.stringify(payload);
return { body, sig: crypto.sign(null, Buffer.from(body, "utf8"), signKey).toString("base64url") };
}
export function verifyResponse(res: { body: string; sig: string }, signPub: Buffer): boolean {
try {
return crypto.verify(null, Buffer.from(res.body, "utf8"), publicKeyFromRaw(signPub), Buffer.from(res.sig, "base64url"));
} catch { return false; }
}
// Запрос: подпись ключом устройства (auth_pub) над строкой
// METHOD \n /path \n ts \n hex(sha256(тело запроса))
// Тело пустое → sha256 пустой строки. path — без query.
export function requestSigString(method: string, urlPath: string, ts: number, body: Buffer | string): string {
const h = crypto.createHash("sha256").update(body).digest("hex");
return `${method.toUpperCase()}\n${urlPath}\n${ts}\n${h}`;
}
export function signRequest(
method: string, urlPath: string, ts: number, body: Buffer | string, authKey: crypto.KeyObject,
): string {
return crypto.sign(null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"), authKey).toString("base64url");
}
export function verifyRequest(
method: string, urlPath: string, ts: number, body: Buffer | string, sig: string, authPub: Buffer,
): boolean {
try {
return crypto.verify(
null, Buffer.from(requestSigString(method, urlPath, ts, body), "utf8"),
publicKeyFromRaw(authPub), Buffer.from(sig, "base64url"),
);
} catch { return false; }
}
+135 -1
View File
@@ -6,6 +6,8 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import crypto from "crypto"; import crypto from "crypto";
import axios from "axios"; import axios from "axios";
import Database from "better-sqlite3";
import { createSub } from "./sub";
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 8080; const PORT = Number(process.env.PORT) || 8080;
@@ -50,6 +52,46 @@ function mintInternalToken(): string {
const revoked = new Set<string>(); const revoked = new Set<string>();
// ── Ключи внешнего API (/api/v1) ───────────────────────────────────────────
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое
// значение ключа не хранится — только SHA-256 и префикс для показа в панели.
interface ApiKeyRow {
id: number; label: string; key_hash: string; prefix: string;
server_id: number; created_at: number; last_used: number | null;
}
const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db");
const uidb = new Database(UI_DB_FILE);
uidb.pragma("journal_mode = WAL");
uidb.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
function genApiKey(): { key: string; hash: string; prefix: string } {
const key = "awgk_" + crypto.randomBytes(24).toString("base64url");
return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" };
}
function tokenSig(token: string): string { return token.split(".")[2] ?? token; } function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
function sign(): string { function sign(): string {
@@ -128,6 +170,31 @@ app.get("/ui/brand", (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION }); res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
}); });
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() });
});
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id);
res.json({ success: true, id });
});
async function proxy(req: Request, res: Response) { async function proxy(req: Request, res: Response) {
try { try {
const r = await axios({ const r = await axios({
@@ -143,6 +210,22 @@ async function proxy(req: Request, res: Response) {
} }
} }
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? "";
if (!key.startsWith("awgk_")) { res.status(401).json({ error: "API key required" }); return; }
const row = keyStmts.byHash.get(hashKey(key)) as ApiKeyRow | undefined;
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row;
next();
}
async function ctrl(method: string, urlPath: string, body?: unknown) { async function ctrl(method: string, urlPath: string, body?: unknown) {
return axios({ return axios({
method, method,
@@ -153,6 +236,56 @@ 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();
ext.use(requireApiKey);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
// тот же, что у proxy() в такой ситуации.
type Handler = (req: Request, res: Response) => Promise<void>;
const wrap = (fn: Handler) => (req: Request, res: Response) => {
fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); });
};
ext.post("/users", wrap(async (req, res) => {
const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.get("/users", wrap(async (_req, res) => {
const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data);
}));
ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.delete("/users/:name", wrap(async (req, res) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
}));
// ВАЖНО: /api/v1 монтируется ДО JWT-прокси на /api — иначе прокси перехватит
// его и потребует JWT вместо ключа.
app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist"))); app.use(express.static(path.join(__dirname, "dist")));
@@ -161,4 +294,5 @@ app.get("*", (_req: Request, res: Response) => {
res.sendFile(path.join(__dirname, "dist", "index.html")); res.sendFile(path.join(__dirname, "dist", "index.html"));
}); });
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
sub.start();
+2
View File
@@ -24,6 +24,7 @@ html, body, #root { height: 100%; }
--success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922; --success-bg: #c0dd97; --success-text: #2c4d0a; --success-dot: #639922;
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85; --neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a; --error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
--warn-dot: #e08a2c;
--danger-border: #e6c4bc; --danger-border: #e6c4bc;
--danger-text: #b34a3a; --danger-text: #b34a3a;
@@ -65,6 +66,7 @@ html, body, #root { height: 100%; }
--success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f; --success-bg: #2d4413; --success-text: #c8e3a0; --success-dot: #8fc24f;
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085; --neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449; --error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
--warn-dot: #ffb76b;
--danger-border: #6e4540; --danger-border: #6e4540;
--danger-text: #ffb4ab; --danger-text: #ffb4ab;
+7
View File
@@ -35,6 +35,13 @@ export const IcoRefresh = () => (
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg> </svg>
); );
export const IcoSettings = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="17" x2="20" y2="17"/>
<circle cx="9" cy="7" r="2.5" fill="var(--surface-bright, transparent)"/>
<circle cx="15" cy="17" r="2.5" fill="var(--surface-bright, transparent)"/>
</svg>
);
export const IcoQR = () => ( export const IcoQR = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/> <rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
+10
View File
@@ -26,6 +26,16 @@ export interface ServerInfo {
} }
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'. // На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0'); export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
// Ключ внешнего API в списке вкладки: открытого значения тут нет — только
// префикс, сервер хранит лишь хэш.
export interface ApiKey {
id: number;
label: string;
prefix: string;
server_id: number;
created_at: number;
last_used: number | null;
}
export interface PageProps { export interface PageProps {
token: string; token: string;
+77
View File
@@ -0,0 +1,77 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
+343
View File
@@ -0,0 +1,343 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number }
interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
}
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
const platformLabel = (d: Device): string =>
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false);
const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null);
const [label, setLabel] = useState('');
const [limit, setLimit] = useState('3');
const [devs, setDevs] = useState<Record<number, Device[]>>({});
const [folded, setFolded] = useState<Set<number>>(new Set()); // свёрнутые узлы
const [tools, setTools] = useState<number | null>(null); // узел с открытыми настройками
const [limitEdit, setLimitEdit] = useState('');
const [modal, setModal] = useState<{ label: string; link: string; tls: boolean; dataUrl: string } | null>(null);
const load = useCallback(async () => {
try {
const d = await apiFetch('GET', '/ui/masterkeys', token);
const list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
const all = await Promise.all(list.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
}, [token, showMsg]);
// Действие над ключом/устройством: выполнить, показать сообщение, обновить данные.
const act = useCallback(async (fn: () => Promise<unknown>, ok: string) => {
try { await fn(); showMsg(ok); await load(); }
catch (e) { showMsg(errText(e)); }
}, [load, showMsg]);
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, token, act]);
const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
}, [token, act]);
const fetchLink = useCallback(async (k: MasterKey) => {
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
return r as { link: string; tls: boolean };
}, [token]);
const showQR = useCallback(async (k: MasterKey) => {
try {
const { link, tls: t } = await fetchLink(k);
const dataUrl = await QRCode.toDataURL(link, { width: 560, margin: 2, errorCorrectionLevel: 'H' });
setModal({ label: k.label, link, tls: t, dataUrl });
} catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
const copyLink = useCallback(async (k: MasterKey) => {
try { await copyText((await fetchLink(k)).link); showMsg('Ссылка скопирована'); }
catch (e) { showMsg(errText(e)); }
}, [fetchLink, showMsg]);
const fold = (id: number) => setFolded(prev => {
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
});
const openTools = (k: MasterKey) => { setLimitEdit(String(k.device_limit)); setTools(tools === k.id ? null : k.id); };
useEffect(() => { if (token) load(); }, [token, load]);
const actions = (k: MasterKey) => (
<div className="actions">
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
<button className="btn-icon" title="Скопировать ссылку" aria-label="Скопировать ссылку" onClick={() => copyLink(k)}><IcoCopy /></button>
<button className="btn-icon" title="Настройки мастер-ключа" aria-label="Настройки мастер-ключа" aria-expanded={tools === k.id} onClick={() => openTools(k)}><IcoSettings /></button>
<button className="btn-icon btn-icon--danger" title="Удалить мастер-ключ" aria-label="Удалить мастер-ключ" onClick={() => remove(k)}><IcoTrash /></button>
</div>
);
const toolbox = (k: MasterKey) => (
<div className="mk-tools">
{/* Слева — настройки самого ключа */}
<section className="mk-box">
<h4 className="mk-box-title">Настройки ключа</h4>
<div className="mk-setting">
<div className="mk-setting-text">
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
<span className="mk-setting-hint">Сколько устройств может подключить ключ</span>
</div>
<div className="mk-setting-ctl">
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
Сохранить
</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">Ссылка</span>
<span className="mk-setting-hint">Старая перестанет принимать новые устройства</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() => {
if (confirm('Выпустить новую ссылку? Старая перестанет принимать новые устройства, уже добавленные продолжат работать.'))
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rotate`, token), 'Ссылка перевыпущена');
}}>Перевыпустить</button>
</div>
</div>
<div className="mk-setting">
<div className="mk-setting-text">
<span className="mk-setting-name">Ключи устройств</span>
<span className="mk-setting-hint">Клиенты сменят их при следующем опросе</span>
</div>
<div className="mk-setting-ctl">
<button className="btn btn--tonal" onClick={() =>
act(() => apiFetch('POST', `/ui/masterkeys/${k.id}/rekey`, token), 'Смена ключей запрошена')}>
Запросить смену
</button>
</div>
</div>
</section>
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
<section className="mk-box">
<h4 className="mk-box-title">Включённые серверы</h4>
<label className="mk-server">
<input type="checkbox" checked disabled />
<span className="mk-server-name">{server?.name ?? 'Текущий сервер'}</span>
{server?.ip && <code className="mk-server-ip">{server.ip}</code>}
{server?.gen && <span className="mk-server-gen">{genLabel(server.gen as AwgGen)}</span>}
</label>
<span className="tip-wrap" data-tip="Появится вместе с поддержкой нескольких серверов">
<button className="btn mk-server-add" disabled><IcoPlus /> Добавить сервер</button>
</span>
</section>
</div>
);
// Полоска занятых слотов: числа по краям внутри; цвет по заполнению.
const meter = (k: MasterKey, n: number) => (
<div className={`mk-meter${n >= k.device_limit ? ' mk-meter--full' : n * 3 >= k.device_limit * 2 ? ' mk-meter--warn' : ''}`}
role="progressbar" aria-label="Устройства" aria-valuemin={0}
aria-valuemax={k.device_limit} aria-valuenow={n}
title={`Устройств: ${n} из ${k.device_limit}`}>
<span className="mk-meter-text"><span>{n}</span><span>{k.device_limit}</span></span>
<div className="mk-meter-fill"
style={{ width: `${Math.min(100, (n / Math.max(1, k.device_limit)) * 100)}%` }}>
{/* тот же текст поверх заливки — светлый, чтобы читался на ней */}
<span className="mk-meter-text mk-meter-text--on" aria-hidden="true"><span>{n}</span><span>{k.device_limit}</span></span>
</div>
</div>
);
const deviceActions = (d: Device) => (
<div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><IcoRefresh /></button>
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
}}><IcoTrash /></button>
</div>
);
const caret = (k: MasterKey) => (
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
onClick={() => fold(k.id)}>▾</button>
);
const deviceNode = (k: MasterKey, d: Device) => (
<li className="mk-node mk-node--device" key={d.id}>
<div className="mk-device">
<div className="mk-device-main">
<span className="mk-device-title">
<span className="mk-device-name">{d.device_name || 'Устройство'}</span>
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
</span>
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested ? ' · ждёт смены ключа' : ''}</span>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
<div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
</div>
{deviceActions(d)}
</div>
</li>
);
return (
<>
{!enabled && (
<p className="mk-notice">Подписка не настроена на сервере: нет ключа подписи или SUB_PORT. Ссылки выдавать нельзя.</p>
)}
{enabled && !tls && (
<p className="mk-notice">Подписка работает без TLS: ответы подписаны, но secret ссылки и конфиг видны в сети.</p>
)}
<div className="toolbar">
<input className="field" placeholder="Метка (напр. Семья)" value={label}
onChange={e => setLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && create()} />
<input className="field mk-limit-input" type="number" min={1} max={100} title="Лимит устройств"
aria-label="Лимит устройств" value={limit} onChange={e => setLimit(e.target.value)} />
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={create}><IcoPlus /> Создать ключ</button>
</div>
</div>
{/* Десктоп: таблица, иерархия — строка мастер-ключа и устройства под ней */}
<div className="table-card mk-table-card">
<table className="mk-table">
<thead>
<tr>
<th>Имя</th><th>ID устройства</th><th>Платформа</th><th>Статус</th>
<th>↓ rx</th><th>↑ tx</th><th>Был</th><th>Устройства</th><th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
) : keys.map(k => {
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const open = !folded.has(k.id);
return (
<Fragment key={k.id}>
<tr className="mk-tr-master">
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
<td colSpan={6}></td>
<td>{meter(k, list.length)}</td>
<td className="td-actions mk-td-actions">{actions(k)}</td>
</tr>
{tools === k.id && <tr className="mk-tr-tools"><td colSpan={9}>{toolbox(k)}</td></tr>}
{open && list.map((d, i) => (
<tr key={d.id} className={`mk-tr-device${i === list.length - 1 && free === 0 ? ' mk-last' : ''}`}>
<td><span className="mk-indent">{d.device_name || 'Устройство'}</span></td>
<td className="td-mono"><code className="mk-device-id" title={d.device_id}>{d.device_id}</code></td>
<td>{platformLabel(d)}</td>
<td>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span>
</td>
<td className="td-mono">{bytes(d.rx)}</td>
<td className="td-mono">{bytes(d.tx)}</td>
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
))}
{open && free > 0 && (
<tr className="mk-tr-free mk-last">
<td colSpan={9}><span className="mk-indent">Свободно: {free}</span></td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{/* Мобильный: таблица скрыта (.table-card), то же — деревом карточек */}
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
<ul className="mk-tree">
{keys.map(k => {
const list = devs[k.id] ?? [];
const free = Math.max(0, k.device_limit - list.length);
const isFolded = folded.has(k.id);
return (
<li className="mk-root" key={k.id}>
<div className="mk-master">
{caret(k)}
<IcoKey />
<span className="mk-master-label">{k.label}</span>
{meter(k, list.length)}
{actions(k)}
</div>
{tools === k.id && toolbox(k)}
{!isFolded && (
<ul className="mk-children">
{list.map(d => deviceNode(k, d))}
{free > 0 && (
<li className="mk-node mk-node--free"><div className="mk-free">Свободно: {free}</div></li>
)}
</ul>
)}
</li>
);
})}
</ul>
)}
{modal && (
<div className="qr-backdrop" onClick={() => setModal(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">{modal.label}</p>
<img className="qr-img" src={modal.dataUrl} alt="QR" title="Нажми чтобы скопировать sen:// ссылку"
onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }} />
<p className="qr-hint">Отсканируй в SenAWG · нажми на QR чтобы скопировать{modal.tls ? '' : ' · без TLS'}</p>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => { await copyText(modal.link); showMsg('Ссылка скопирована'); }}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setModal(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
+281
View File
@@ -0,0 +1,281 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Мастер-ключи»: дерево «мастер-ключ → устройства».
Общая дизайн-система — в App.css. */
.mk-notice {
font-size: 13px;
color: var(--error-text);
background: var(--error-bg);
border-radius: 16px;
padding: 10px 16px;
}
.mk-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
.mk-limit-input { width: 88px !important; }
/* ── таблица (десктоп) ── */
.mk-table td, .mk-table th { white-space: nowrap; }
.mk-table .td-actions { width: 150px; }
.mk-table .mk-device-id { display: inline-block; max-width: 170px; vertical-align: middle; }
.mk-table th, .mk-table td { padding-left: 10px; padding-right: 10px; }
.mk-table th:first-child, .mk-table td:first-child { padding-left: 20px; }
.mk-table th:last-child, .mk-table td:last-child { padding-right: 16px; }
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
.mk-tr-master:hover td { background: var(--surface-container); }
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
.mk-name .mk-caret { margin-left: -8px; }
.mk-tr-master .mk-name { color: var(--on-surface); }
.mk-tr-master .mk-name svg { color: var(--primary); }
.mk-tr-tools td { background: var(--surface-container); padding-top: 4px; padding-bottom: 12px; } /* слева/справа — общие отступы колонок таблицы: блок стоит под заголовком «Имя» */
.mk-tr-tools:hover { background: transparent; }
.mk-tr-device td, .mk-tr-free td { padding-top: 10px; padding-bottom: 10px; }
.mk-flag { color: var(--neutral-text); font-size: 12px; }
/* иерархия: линия слева от имени и «ножка» к каждой строке; у последней линия обрывается */
.mk-tr-device td:first-child, .mk-tr-free td:first-child {
background: linear-gradient(var(--outline), var(--outline)) 30px 0 / 2px 100% no-repeat;
}
.mk-tr-device.mk-last td:first-child, .mk-tr-free.mk-last td:first-child { background-size: 2px 50%; }
.mk-indent { position: relative; display: inline-block; padding-left: 36px; }
.mk-indent::before {
content: ''; position: absolute; left: 10px; top: 50%; width: 16px;
border-top: 2px solid var(--outline);
}
.mk-tr-free td { color: var(--neutral-dot); font-size: 12px; }
.mk-tr-free:hover { background: transparent; }
/* ── дерево (мобильный) ── */
.mk-tree, .mk-children { list-style: none; margin: 0; padding: 0; }
.mk-tree { display: none; flex-direction: column; gap: 12px; } /* только мобильный */
.mk-root {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 12px 18px 14px;
}
/* родитель */
.mk-master { display: flex; align-items: center; gap: 10px; color: var(--primary); min-height: 42px; }
.mk-master-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mk-caret {
width: 28px; height: 28px;
border: 0; border-radius: 50%;
background: transparent;
color: var(--neutral-text);
font-size: 16px;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.mk-caret:hover { background: var(--state-neutral); }
.mk-caret--folded { transform: rotate(-90deg); }
.mk-tools {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
align-items: stretch;
margin: 6px 0 8px;
}
/* два одинаковых блока: слева настройки ключа, справа серверы */
.mk-box {
display: flex; flex-direction: column; gap: 10px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: 16px;
padding: 12px 16px 14px;
}
.mk-box-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); }
.mk-box .mk-box-title { margin-bottom: 2px; }
/* строка настройки: слева название и пояснение, справа контрол */
.mk-setting {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px 0;
border-top: 1px solid var(--outline-variant);
}
.mk-box-title + .mk-setting { border-top: 0; padding-top: 4px; }
.mk-setting:last-child { padding-bottom: 0; }
.mk-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
/* прогресс занятых слотов с числом внутри; полный — цветом «ошибка».
Текст дублируется: тёмный на дорожке, светлый поверх заливки (обрезается ею). */
.mk-meter {
--mk-w: 136px;
--meter-fill: var(--success-dot); /* < 2/3 — зелёный */
--meter-on: #2a2118; /* цвет числа поверх заливки */
position: relative;
width: var(--mk-w); height: 20px;
border-radius: var(--r-pill);
background: var(--outline-variant);
overflow: hidden;
flex-shrink: 0;
}
.mk-meter-fill {
position: absolute; left: 0; top: 0; bottom: 0;
background: var(--meter-fill);
overflow: hidden;
transition: width 0.25s, background 0.25s;
}
/* числа по краям внутри полоски: текущее слева, лимит справа */
.mk-meter-text {
position: absolute; left: 0; top: 0;
width: var(--mk-w); height: 100%;
box-sizing: border-box;
padding: 0 11px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--on-surface);
}
.mk-meter-text--on { color: var(--meter-on); }
.mk-meter--warn { --meter-fill: var(--warn-dot); } /* от 2/3 — оранжевый */
.mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */
:root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; }
/* серверы, отдаваемые по ключу (заглушка) */
.mk-server {
display: flex; align-items: center; gap: 10px;
background: var(--surface-container);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: var(--on-surface);
}
.mk-server input { accent-color: var(--primary); margin: 0; }
.mk-server-name { font-weight: 500; }
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
.mk-server-add {
width: 100%; justify-content: center;
background: transparent;
border: 1px dashed var(--outline);
color: var(--neutral-dot);
cursor: not-allowed;
}
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
.mk-children { margin: 8px 0 0 18px; padding-left: 28px; border-left: 2px solid var(--outline); }
.mk-node { position: relative; padding: 4px 0; }
.mk-node::before {
content: '';
position: absolute;
left: -28px; top: 50%;
width: 28px;
border-top: 2px solid var(--outline);
}
/* последний узел: линия родителя обрывается на его «ножке» */
.mk-node:last-child::after {
content: '';
position: absolute;
left: -30px; top: calc(50% + 1px); bottom: 0;
width: 2px;
background: var(--surface-bright);
}
.mk-device {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface-container);
border-radius: 14px;
padding: 8px 14px;
}
.mk-device-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 160px; }
.mk-device-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.mk-device-id {
font-family: var(--mono);
font-size: 11px;
color: var(--neutral-text);
background: var(--surface-bright);
border-radius: var(--r-pill);
padding: 1px 8px;
min-width: 0;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mk-device-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
.mk-device-sub { font-size: 12px; color: var(--neutral-text); }
.mk-device-traffic { display: flex; gap: 12px; font-family: var(--mono); font-size: 12px; color: var(--neutral-text); }
.mk-metric { display: inline-flex; gap: 4px; }
.mk-metric i, .mk-metric b { font-style: normal; font-weight: 400; }
.mk-free {
border: 1px dashed var(--outline);
border-radius: 14px;
padding: 10px 14px;
font-size: 13px;
color: var(--neutral-dot);
}
/* ── мобильный (design.md, часть IV): одна колонка, тач-цели ≥ 44px, rx/tx блоками
50/50, действия рядом с большим пальцем ── */
@media (max-width: 640px) {
.mk-tree { display: flex; }
.mk-limit-input { width: 100% !important; }
.mk-root { padding: 12px; }
/* родитель: шапка, затем ряд действий на всю ширину */
.mk-master { flex-wrap: wrap; row-gap: 10px; }
.mk-master .actions { width: 100%; gap: 8px; }
.mk-caret { width: 44px; height: 44px; margin: -6px 0 -6px -8px; }
.mk-meter { --mk-w: 112px; height: 24px; }
/* настройки узла: поля и кнопки на всю ширину */
.mk-tools { grid-template-columns: minmax(0, 1fr); margin: 8px 0 4px; }
.mk-setting { flex-direction: column; align-items: stretch; gap: 8px; }
.mk-setting-ctl { width: 100%; }
.mk-setting-ctl .btn { flex: 1; height: 44px; }
.mk-setting-ctl .mk-limit-input { width: 100% !important; flex: 1; height: 44px; }
.mk-tools .btn { width: 100%; height: 44px; justify-content: center; }
/* дерево уже: отступы поменьше, «ножки» короче */
.mk-children { margin-left: 8px; padding-left: 14px; }
.mk-node { padding: 4px 0; }
.mk-node::before { left: -14px; width: 14px; }
.mk-node:last-child::after { left: -16px; }
/* устройство — карточка: заголовок + чип, метрики 50/50, ряд действий */
.mk-device { border-radius: 16px; padding: 12px; gap: 10px; }
.mk-device-main { flex: 1 1 0; }
.mk-device-title { flex-wrap: wrap; row-gap: 2px; }
.mk-device-id { max-width: 100%; }
.mk-device-traffic { width: 100%; gap: 8px; }
.mk-metric {
flex: 1; flex-direction: column; gap: 2px;
background: var(--surface-bright);
border-radius: 16px; padding: 8px 12px;
}
.mk-metric i { font-family: var(--font, inherit); font-size: 12px; color: var(--neutral-text); }
.mk-metric-name::after { content: attr(data-sfx); }
.mk-metric b { font-size: 16px; font-weight: 500; color: var(--on-surface); }
/* кнопки действий: контурные, на всю ширину ряда, 44px */
.mk-device .actions { width: 100%; gap: 8px; }
.mk-master .btn-icon, .mk-device .btn-icon {
flex: 1; width: auto; height: 44px;
border: 1px solid var(--outline);
border-radius: var(--r-pill);
}
.mk-master .btn-icon--danger, .mk-device .btn-icon--danger { border-color: var(--error-dot); }
.mk-free { padding: 12px; font-size: 13px; }
}
+6
View File
@@ -0,0 +1,6 @@
{
"id": "masterkeys",
"label": "Мастер-ключи",
"icon": "key",
"order": 1.5
}
+470
View File
@@ -0,0 +1,470 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// sen://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели
// (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на
// отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md.
//
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
import crypto from "crypto";
import fs from "fs";
import http from "http";
import https from "https";
import path from "path";
import express, { Request, Response, NextFunction, Router } from "express";
import type Database from "better-sqlite3";
import {
encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin,
signResponse, verifyRequest,
} from "./sen";
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
interface MasterRow {
id: number; label: string; secret: Buffer; device_limit: number;
server_id: number; created_at: number; revoked_at: number | null;
}
interface DeviceRow {
id: number; master_id: number; device_id: string; device_name: string; platform: string;
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
rekey_requested: number; version: string;
}
const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3;
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
// pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»).
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
// ── Простой лимитер по IP ──────────────────────────────────────────────────
function makeLimiter(max: number, windowMs: number) {
const hits = new Map<string, { n: number; reset: number }>();
return (ip: string): boolean => {
const now = Date.now();
const e = hits.get(ip);
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
if (e.n >= max) return false;
e.n++;
return true;
};
}
export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) {
const { uidb, ctrl } = deps;
// ── Настройка из окружения ─────────────────────────────────────────────
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
let signKey: crypto.KeyObject | null = null;
let signPub: Buffer | null = null;
try {
signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key")));
signPub = rawPublicKey(crypto.createPublicKey(signKey));
} catch {
console.warn("sub: ключ подписи не найден — sen://-подписка отключена");
}
let tlsMaterial: { key: Buffer; cert: Buffer } | null = null;
let tlsPin: Buffer | null = null;
if (SUB_TLS) {
try {
tlsMaterial = {
key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")),
cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")),
};
tlsPin = spkiPin(tlsMaterial.cert.toString());
} catch {
console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена");
signKey = null;
}
}
// ── Схема ──────────────────────────────────────────────────────────────
uidb.exec(`
CREATE TABLE IF NOT EXISTS master_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
secret BLOB NOT NULL UNIQUE,
device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT},
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
revoked_at INTEGER
);
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL DEFAULT '',
pub_key TEXT NOT NULL UNIQUE,
auth_pub TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_seen INTEGER,
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
`);
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
// таблицы колонки может не быть.
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
uidb.pragma("foreign_keys = ON");
const q = {
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"),
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"),
deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>(
"INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"),
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 WHERE id = ?"),
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
// ── Общие куски ────────────────────────────────────────────────────────
async function profile() {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status)) throw new Error("profile");
return r.data as {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
};
}
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
async function buildConfig(dev: DeviceRow) {
const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]);
if (!hasCtrlOk(peers.status)) throw new Error("peers");
const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[])
.find(p => p.pub_key === dev.pub_key);
if (!peer) throw new Error("peer missing");
const cfg = {
rekey: dev.rekey_requested === 1,
endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`],
servers: [{
id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
}],
};
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
return { rev, ...cfg };
}
async function dropPeer(pub: string) {
await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно
}
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
const sub = express();
sub.disable("x-powered-by");
sub.use(express.json({
limit: "4kb",
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
}));
const registerLimit = makeLimiter(30, 15 * 60 * 1000);
const failLimit = makeLimiter(30, 15 * 60 * 1000);
const seenSigs = new Map<string, number>(); // подпись → когда забыть
function send(res: Response, status: number, payload: unknown) {
res.status(status).json(signResponse(payload, signKey as crypto.KeyObject));
}
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
function checkSigned(req: Request, authPub: Buffer): boolean {
const ts = Number(req.headers["x-sen-ts"]);
const sig = String(req.headers["x-sen-sig"] ?? "");
if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false;
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false;
const t = Date.now();
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
if (seenSigs.has(sig)) return false;
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
return true;
}
interface DevReq extends Request { device?: DeviceRow }
function deviceAuth(req: Request, res: Response, next: NextFunction) {
const id = Number(req.headers["x-sen-device"]);
const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) {
(req as DevReq).device = dev;
next();
return;
}
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; }
send(res, 401, { error: "unauthorized" });
}
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); };
sub.post("/sub/v1/register", wrap(async (req, res) => {
const ip = req.socket.remoteAddress ?? "unknown";
if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
const b = (req.body ?? {}) as Record<string, unknown>;
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/);
const deviceId = str(b.device_id, /^[\w.-]{1,64}$/);
const pubKey = str(b.pub_key, B64_PUB);
const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/);
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
const version = str(b.version, CLIENT_VERSION) ?? "";
if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; }
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
const authPub = Buffer.from(authPubS, "base64url");
if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; }
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; }
let deviceRowId = 0;
let oldPub: string | null = null;
try {
uidb.transaction(() => {
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); }
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
throw Object.assign(new Error("limit"), { code: "limit" });
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
})();
} catch (e: any) {
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
}
if (oldPub) await dropPeer(oldPub);
const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` });
if (!hasCtrlOk(r.status)) {
q.deviceDelete.run(deviceRowId);
send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" });
return;
}
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(res, 201, {
device: dev.id, config: await buildConfig(dev),
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
// Сколько мест у ключа занято — клиент показывает это, пока ссылку только вставили и ещё ничего не
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
sub.post("/sub/v1/peek", wrap(async (req, res) => {
const ip = req.socket.remoteAddress ?? "unknown";
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
if (!master) {
if (!failLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
return;
}
send(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
}));
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
q.deviceSeen.run(now(), dev.id);
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(res, 200, { config: await buildConfig(dev) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
// можно лишь себя (DELETE /sub/v1/device); чужие устройства убирает панель.
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; }
send(res, 200, {
name: master.label,
device_limit: master.device_limit,
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
created_at: d.created_at, last_seen: d.last_seen, current: d.id === dev.id,
})),
});
}));
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub });
if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; }
q.deviceRekeyed.run(pub, dev.id);
send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) });
}));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
await dropPeer(dev.pub_key);
q.deviceDelete.run(dev.id);
send(res, 200, { ok: true });
}));
sub.use((_req, res) => { send(res, 404, { error: "not_found" }); });
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); });
// ── Админ-роуты панели (монтируются в server.ts за JWT) ─────────────────
const aw = (fn: (req: Request, res: Response) => Promise<void>) =>
(req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); };
const idOf = (req: Request) => Number(req.params.id);
const LABEL = /^[^\r\n<>]{1,40}$/;
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
const masterKeys = Router();
masterKeys.get("/", (_req, res) => {
res.json({
enabled: signKey !== null,
tls: tlsPin !== null,
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
id: m.id, label: m.label, device_limit: m.device_limit,
devices: m.devices, server_id: m.server_id, created_at: m.created_at,
})),
});
});
masterKeys.post("/", (req, res) => {
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now());
res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 });
});
masterKeys.patch("/:id", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
const nl = label ?? m.label, nd = device_limit ?? m.device_limit;
if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
q.masterUpdate.run(nl, nd, m.id);
res.json({ id: m.id, label: nl, device_limit: nd });
});
masterKeys.post("/:id/rotate", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
q.masterSecret.run(crypto.randomBytes(16), m.id); // устройства продолжают работать: у них auth_pub
res.json({ id: m.id });
});
masterKeys.post("/:id/rekey", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
q.deviceFlag.run(-1, m.id);
res.json({ id: m.id });
});
masterKeys.delete("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
q.masterDelete.run(m.id); // devices уйдут каскадом
res.json({ success: true, id: m.id });
}));
masterKeys.get("/:id/link", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; }
const prof = await profile();
const link = encodeSenLink({
tls: tlsPin !== null,
addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }],
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
});
res.json({ link, tls: tlsPin !== null });
}));
masterKeys.get("/:id/devices", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
const r = await ctrl("GET", "/api/peers");
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
res.json({
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
...pick(stats.get(d.pub_key)),
})),
});
}));
function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) {
return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 };
}
const devices = Router();
devices.delete("/:id", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
await dropPeer(d.pub_key);
q.deviceDelete.run(d.id);
res.json({ success: true, id: d.id });
}));
devices.post("/:id/rekey", (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
q.deviceFlag.run(d.id, -1);
res.json({ id: d.id });
});
devices.post("/:id/psk", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`);
res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" });
}));
// ── Листенер ───────────────────────────────────────────────────────────
function start() {
if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
server.listen(SUB_PORT, () => {
console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`);
});
}
return { masterKeys, devices, start };
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+10
View File
@@ -90,6 +90,16 @@ const SERVICES = {
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 (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
// переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
// Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и
// сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
SUB_PORT: process.env.SUB_PORT ?? "",
SUB_TLS: process.env.SUB_TLS ?? "off",
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
}, },
pidFile: "/tmp/awg-ui.pid", pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"), logFile: path.join(LOGS, "awg-ui.log"),
+203 -28
View File
@@ -22,7 +22,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может. # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места. # При бампе версии правь оба места.
VERSION="0.2.0" VERSION="0.3.0"
CHANNEL="${CHANNEL:-Beta}" CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде # Откуда брать архив и как называется продукт — задаётся окружением, в коде
@@ -48,9 +48,16 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key"
PUB_KEY_FILE="$AWG_DIR/server_public.key" PUB_KEY_FILE="$AWG_DIR/server_public.key"
AWG_CONF="$AWG_DIR/awg1.conf" AWG_CONF="$AWG_DIR/awg1.conf"
DB_FILE="$AWG_DIR/users.db" DB_FILE="$AWG_DIR/users.db"
UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl. # Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key" INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
# Подписка sen:// (SenAWG): ключ подписи ответов (Ed25519) и, при SUB_TLS=on, самоподписанный
# сертификат. В отличие от internal_auth они НЕ эфемерны — публичный ключ подписи и отпечаток
# сертификата зашиты во все выданные sen:// ссылки.
SUB_SIGN_PRIV="$AWG_DIR/sub_sign.key"
SUB_TLS_KEY="$AWG_DIR/sub_tls.key"
SUB_TLS_CRT="$AWG_DIR/sub_tls.crt"
IFACE="awg1" IFACE="awg1"
AWG_PORT="47619" AWG_PORT="47619"
SUBNET="10.9" SUBNET="10.9"
@@ -71,13 +78,51 @@ echo -e "${BLD}Проверка совместимости:${NC}"
KERNEL=$(uname -r) KERNEL=$(uname -r)
VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown") VIRT=$(systemd-detect-virt 2>/dev/null || echo "unknown")
echo " Ядро: $KERNEL · виртуализация: $VIRT · $(lsb_release -ds 2>/dev/null || echo unknown)"
# Считаем все три пункта, НЕ падая на первом, — чтобы показать полный чеклист # Семейство дистрибутива решает, как ставить AmneziaWG:
# ubuntu — add-apt-repository ppa:amnezia/ppa, заголовки linux-headers-generic;
# debian — ни software-properties-common (в Debian 13 его нет), ни
# linux-headers-generic там не существует: PPA подключаем вручную
# (ключ + signed-by), заголовки — только под текущее ядро.
# Производные (Mint, Pop!_OS, Kali…) определяются через ID_LIKE.
OS_ID=""; OS_LIKE=""; OS_CODENAME=""; OS_NAME=""
if [[ -r /etc/os-release ]]; then
OS_ID=$(. /etc/os-release; echo "${ID:-}")
OS_LIKE=$(. /etc/os-release; echo "${ID_LIKE:-}")
OS_CODENAME=$(. /etc/os-release; echo "${VERSION_CODENAME:-}")
OS_NAME=$(. /etc/os-release; echo "${PRETTY_NAME:-}")
fi
if [[ "$OS_ID" == "ubuntu" || " $OS_LIKE " == *" ubuntu "* ]]; then
OS_FAMILY="ubuntu"
elif [[ "$OS_ID" == "debian" || " $OS_LIKE " == *" debian "* ]]; then
OS_FAMILY="debian"
else
OS_FAMILY=""
fi
# Вариант ядра без версии: 6.12.85+deb13-cloud-amd64 → cloud-amd64,
# 6.1.0-18-cloud-amd64 → cloud-amd64. Нужен для мета-пакетов Debian
# (linux-image-<вариант> / linux-headers-<вариант>).
KFLAVOUR=$(sed -E 's/^[0-9][^-]*-([0-9]+-)?//' <<< "$KERNEL")
# Что советовать, если заголовков под текущее ядро нет.
if [[ "$OS_FAMILY" == "debian" ]]; then
HDR_FIX="apt-get update && apt-get install -y linux-image-$KFLAVOUR linux-headers-$KFLAVOUR && reboot"
else
HDR_FIX="apt-get install -y linux-generic && reboot"
fi
echo " Ядро: $KERNEL · виртуализация: $VIRT · ${OS_NAME:-$(lsb_release -ds 2>/dev/null || echo unknown)}"
# Считаем все пункты, НЕ падая на первом, — чтобы показать полный чеклист
# со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим # со статусом по каждому. Если хоть один не прошёл — печатаем причины и выходим
# с кодом 0 (чистый выход, без вида «упало с ошибкой»). # с кодом 0 (чистый выход, без вида «упало с ошибкой»).
VIRT_OK=1; HDR_OK=1; NET_OK=1 OS_OK=1; VIRT_OK=1; HDR_OK=1; NET_OK=1
VIRT_WHY=""; HDR_WHY=""; NET_WHY="" OS_WHY=""; VIRT_WHY=""; HDR_WHY=""; NET_WHY=""
# 0) Дистрибутив: установщик умеет только apt-семейство Ubuntu/Debian.
if [[ -z "$OS_FAMILY" ]]; then
OS_OK=0
OS_WHY="дистрибутив '${OS_NAME:-${OS_ID:-неизвестно}}' не поддерживается — нужен Ubuntu или Debian"
fi
# 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом. # 1) Виртуализация: kernel-модуль нельзя загрузить там, где ядро общее с хостом.
case "$VIRT" in case "$VIRT" in
@@ -97,7 +142,7 @@ elif echo "$HDR_POLICY" | grep -q 'Candidate: [^(]'; then
: :
elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then elif echo "$HDR_POLICY" | grep -q 'Candidate: (none)'; then
HDR_OK=0 HDR_OK=0
HDR_WHY="нет заголовков под ядро $KERNEL в apt (кастомное ядро провайдера). Решение: apt-get install -y linux-generic && reboot, затем запусти install.sh заново в generic-ядре" HDR_WHY="нет заголовков под ядро $KERNEL в apt. Решение: $HDR_FIX, затем запусти install.sh заново"
fi fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой # 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
@@ -116,13 +161,15 @@ done
# Чеклист: ✓ — пройдено, ✗ — нет. # Чеклист: ✓ — пройдено, ✗ — нет.
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"; }
mark "$OS_OK" "Дистрибутив"
mark "$VIRT_OK" "Виртуализация" mark "$VIRT_OK" "Виртуализация"
mark "$HDR_OK" "Заголовки ядра" mark "$HDR_OK" "Заголовки ядра"
mark "$NET_OK" "Доступ в интернет" mark "$NET_OK" "Доступ в интернет"
if [[ "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then if [[ "$OS_OK" == 0 || "$VIRT_OK" == 0 || "$HDR_OK" == 0 || "$NET_OK" == 0 ]]; then
echo echo
warn "Установка невозможна — не пройдены проверки:" warn "Установка невозможна — не пройдены проверки:"
[[ "$OS_OK" == 0 ]] && echo -e " ${RED}•${NC} $OS_WHY"
[[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY" [[ "$VIRT_OK" == 0 ]] && echo -e " ${RED}•${NC} $VIRT_WHY"
[[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY" [[ "$HDR_OK" == 0 ]] && echo -e " ${RED}•${NC} $HDR_WHY"
[[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY" [[ "$NET_OK" == 0 ]] && echo -e " ${RED}•${NC} $NET_WHY"
@@ -426,6 +473,28 @@ if [[ -f "$DB_FILE" || -f "$PRIV_KEY_FILE" ]]; then
fi fi
fi fi
# sen://-подписка (мастер-ключи для SenAWG). Порт и режим TLS зашиты в уже выданные ссылки,
# поэтому при KEEP_DATA=y берём прежние значения из существующего cli.env.
PREV_ENV="$PROJECT/cli/cli.env"
prev_val() { grep -s "^$1=" "$PREV_ENV" | tail -1 | cut -d= -f2- || true; }
SUB_PORT="${SUB_PORT:-}"
SUB_TLS="${SUB_TLS:-}"
if [[ "$KEEP_DATA" == "y" ]]; then
[[ -z "$SUB_PORT" ]] && SUB_PORT="$(prev_val SUB_PORT)"
[[ -z "$SUB_TLS" ]] && SUB_TLS="$(prev_val SUB_TLS)"
fi
if [[ -z "$SUB_PORT" ]]; then
read -rp " Sub port для sen:// (Enter — случайный): " SUB_PORT
while [[ -z "$SUB_PORT" || "$SUB_PORT" == "$UI_PORT" || "$SUB_PORT" == "$AWGCTRL_PORT" ]]; do
SUB_PORT=$(( (RANDOM % 22768) + 32768 ))
done
fi
if [[ -z "$SUB_TLS" ]]; then
read -rp " Подписка sen:// по HTTPS (самоподписанный сертификат, домен не нужен)? [Y/n]: " ST
[[ "${ST:-y}" =~ ^[Nn]$ ]] && SUB_TLS="off" || SUB_TLS="on"
fi
[[ "$SUB_TLS" == "on" ]] || SUB_TLS="off"
# Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в # Имя сервера: если база уже существует (DB_EXISTS, проверено заранее в
# preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем. # preflight) — имя берётся из неё, не спрашиваем. Если базы нет — спрашиваем.
if [[ "$DB_EXISTS" == "y" ]]; then if [[ "$DB_EXISTS" == "y" ]]; then
@@ -442,6 +511,7 @@ echo " Server name: $SERVER_NAME"
echo " AWG port: $AWG_PORT (udp)" echo " AWG port: $AWG_PORT (udp)"
echo " awgctrl port: $AWGCTRL_PORT" echo " awgctrl port: $AWGCTRL_PORT"
echo " UI port: $UI_PORT" echo " UI port: $UI_PORT"
echo " Sub port: $SUB_PORT (tcp, sen://, TLS: $SUB_TLS)"
echo " UI логин: $UI_USER" echo " UI логин: $UI_USER"
echo " Net interface: $NET_IFACE" echo " Net interface: $NET_IFACE"
if [[ "$KEEP_DATA" == "y" ]]; then if [[ "$KEEP_DATA" == "y" ]]; then
@@ -460,30 +530,89 @@ start_logging
# секции «Проверка совместимости»; $KERNEL задан там же. # секции «Проверка совместимости»; $KERNEL задан там же.
step "1/7 AmneziaWG" step "1/7 AmneziaWG"
# Debian: add-apt-repository нет (software-properties-common из Debian 13 убран),
# а PPA под Debian-релизы не собирается — подключаем его вручную на Ubuntu-серию
# с glibc не новее дебиановской. Модуль приезжает DKMS-исходниками и от серии
# не зависит; серия важна только для бинарника amneziawg-tools.
# AMNEZIA_PPA_SUITE — задать серию вручную (focal/jammy/noble).
AMNEZIA_PPA_URL="https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu"
AMNEZIA_PPA_FPR="75C9DD72C799870E310542E24166F2C257290828" # Launchpad PPA for Iurii Egorov
AMNEZIA_KEYRING="/etc/apt/keyrings/amnezia-ppa.gpg"
add_amnezia_ppa_debian() {
local suite="${AMNEZIA_PPA_SUITE:-}"
if [[ -z "$suite" ]]; then
case "$OS_CODENAME" in
bullseye) suite="focal" ;;
bookworm) suite="jammy" ;;
trixie) suite="noble" ;;
*) suite="noble"
warn "Debian '${OS_CODENAME:-неизвестно}' не сопоставлен с серией PPA — берём noble (задать вручную: AMNEZIA_PPA_SUITE=…)" ;;
esac
fi
echo " → подключение PPA amnezia (Debian ${OS_CODENAME:-?} → серия $suite)"
# Ключ берём по полному отпечатку и сверяем отпечаток после загрузки —
# apt-key в Debian 12+ уже нет, а доверять ответу keyserver вслепую нельзя.
local tmp gh fprs=""
tmp=$(mktemp); gh=$(mktemp -d)
if curl -fsSL --connect-timeout 15 \
"https://keyserver.ubuntu.com/pks/lookup?op=get&options=mr&search=0x${AMNEZIA_PPA_FPR}" \
| gpg --dearmor > "$tmp" 2>/dev/null; then
fprs=$(GNUPGHOME="$gh" gpg --batch --show-keys --with-colons "$tmp" 2>/dev/null || true)
fi
if ! grep -q "^fpr:::::::::${AMNEZIA_PPA_FPR}:" <<< "$fprs"; then
rm -rf "$tmp" "$gh"
fail "Не удалось получить ключ PPA amnezia ($AMNEZIA_PPA_FPR) с keyserver.ubuntu.com"
fi
install -d -m 0755 /etc/apt/keyrings
install -m 0644 "$tmp" "$AMNEZIA_KEYRING"
rm -rf "$tmp" "$gh"
echo "deb [signed-by=$AMNEZIA_KEYRING] $AMNEZIA_PPA_URL $suite main" \
> /etc/apt/sources.list.d/amnezia-ppa.list
ok "PPA amnezia подключён: $suite, ключ $AMNEZIA_KEYRING"
}
if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then if command -v awg &>/dev/null && command -v awg-quick &>/dev/null && modinfo amneziawg &>/dev/null; then
ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))" ok "AWG уже установлен (модуль amneziawg: $(modinfo -F version amneziawg 2>/dev/null || echo present))"
else else
echo " → apt-get update" echo " → apt-get update"
apt-get update || fail "apt-get update упал — проверь /etc/apt/sources.list*" apt-get update || fail "apt-get update упал — проверь /etc/apt/sources.list*"
echo " → установка зависимостей сборки + заголовков ядра" # iptables — в обоих списках: его зовёт PostUp в awg1.conf, а в Debian 13
# (nftables по умолчанию) он в минимальной системе не установлен.
if [[ "$OS_FAMILY" == "debian" ]]; then
DEPS=(gnupg ca-certificates curl dkms build-essential iptables)
else
DEPS=(software-properties-common python3-launchpadlib gnupg2 dkms build-essential iptables)
fi
echo " → установка зависимостей сборки"
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y \ apt-get install -y "${DEPS[@]}" \
software-properties-common \ || fail "Не удалось установить зависимости сборки: ${DEPS[*]}"
python3-launchpadlib \
gnupg2 \ # Заголовки — отдельным шагом: их нехватка — самая частая причина сбоя, и на
dkms \ # неё нужен точный совет, а не общее «не удалось установить». Ошибку apt
build-essential \ # здесь не валим: решает наличие /lib/modules/$KERNEL/build ниже.
"linux-headers-$KERNEL" \ HDR_PKGS=("linux-headers-$KERNEL")
linux-headers-generic \ [[ "$OS_FAMILY" == "ubuntu" ]] && HDR_PKGS+=(linux-headers-generic)
|| fail "Не удалось установить зависимости или заголовки ядра" echo " → установка заголовков ядра: ${HDR_PKGS[*]}"
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y "${HDR_PKGS[@]}" \
|| warn "apt не смог поставить ${HDR_PKGS[*]}"
if [[ ! -d "/lib/modules/$KERNEL/build" ]]; then if [[ ! -d "/lib/modules/$KERNEL/build" ]]; then
warn "Нет /lib/modules/$KERNEL/build — заголовки под текущее ядро отсутствуют." warn "Нет /lib/modules/$KERNEL/build — заголовки под текущее ядро отсутствуют."
if [[ "$OS_FAMILY" == "debian" ]]; then
warn "Часто бывает на кастомном ядре провайдера. Решение:" # Debian держит в архиве только последнюю сборку ядра: если сервер
warn " apt-get install -y linux-generic && reboot" # давно не перезагружали, пакета под запущенное ядро там уже нет.
warn "и после загрузки в generic-ядро запусти install.sh заново." warn "Debian хранит в архиве только свежую сборку ядра — запущенное $KERNEL"
warn "уже устарело. Поставь актуальное ядро вместе с заголовками:"
else
warn "Часто бывает на кастомном ядре провайдера. Решение:"
fi
warn " $HDR_FIX"
warn "и после перезагрузки запусти install.sh заново."
fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль" fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль"
fi fi
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
@@ -495,6 +624,8 @@ else
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту. # apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен" ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
elif [[ "$OS_FAMILY" == "debian" ]]; then
add_amnezia_ppa_debian
else else
echo " → add-apt-repository ppa:amnezia/ppa" echo " → add-apt-repository ppa:amnezia/ppa"
add-apt-repository -y ppa:amnezia/ppa \ add-apt-repository -y ppa:amnezia/ppa \
@@ -658,6 +789,15 @@ else
mv "$DB_FILE" "$DB_BAK" mv "$DB_FILE" "$DB_BAK"
warn "Старая база пользователей сохранена: $DB_BAK" warn "Старая база пользователей сохранена: $DB_BAK"
fi fi
# ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД.
if [[ -f "$UI_DB_FILE" ]]; then
UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
mv "$UI_DB_FILE" "$UI_DB_BAK"
# WAL-сайдкары удаляем — к новой БД они неприменимы.
rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm"
warn "Старая база API-ключей сохранена: $UI_DB_BAK"
fi
PRIV_KEY=$(umask 077 && awg genkey) PRIV_KEY=$(umask 077 && awg genkey)
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey) PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
@@ -678,12 +818,38 @@ fi
chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB" chmod 600 "$INTERNAL_AUTH_PRIV" "$INTERNAL_AUTH_PUB"
ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl" ok "Ключи внутренней авторизации awg-ui ↔ awg-ctrl"
grep -qxF 'net.ipv4.ip_forward=1' /etc/sysctl.conf \ # Подписка sen://. ⚠️ Существующие файлы при KEEP_DATA=y сохраняем: публичный ключ подписи и
|| echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf # отпечаток сертификата зашиты во все выданные ссылки, новый ключ сломал бы каждую. При чистой
grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \ # установке ui.db уходит в бэкап (старые мастер-ключи всё равно недействительны) — ключи новые.
|| echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.conf if [[ "$KEEP_DATA" == "y" && -f "$SUB_SIGN_PRIV" ]]; then
sysctl -qp ok "Ключ подписи sen:// сохранён (существующие ссылки продолжат работать)"
ok "IP forwarding включён" else
( umask 077; openssl genpkey -algorithm ed25519 -out "$SUB_SIGN_PRIV" )
chmod 600 "$SUB_SIGN_PRIV"
ok "Ключ подписи sen:// создан"
fi
if [[ "$SUB_TLS" == "on" ]]; then
if [[ "$KEEP_DATA" == "y" && -f "$SUB_TLS_CRT" && -f "$SUB_TLS_KEY" ]]; then
ok "Сертификат подписки sen:// сохранён"
else
( umask 077
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout "$SUB_TLS_KEY" -out "$SUB_TLS_CRT" -days 3650 -subj "/CN=sen" 2>/dev/null )
chmod 600 "$SUB_TLS_KEY" "$SUB_TLS_CRT"
ok "Самоподписанный сертификат подписки sen:// создан (10 лет)"
fi
fi
# Свой файл в /etc/sysctl.d, а не /etc/sysctl.conf: в Debian 13 systemd-sysctl
# /etc/sysctl.conf при загрузке больше не читает — forwarding включился бы
# только до первой перезагрузки, и клиенты остались бы без интернета.
SYSCTL_FILE="/etc/sysctl.d/99-awg-control.conf"
cat > "$SYSCTL_FILE" <<'SYSCTL'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
SYSCTL
sysctl -q -p "$SYSCTL_FILE"
ok "IP forwarding включён ($SYSCTL_FILE)"
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента # Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились. # AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
@@ -838,8 +1004,9 @@ if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: act
|| warn "ufw route allow не сработал — трафик клиентов может резаться" || warn "ufw route allow не сработал — трафик клиентов может резаться"
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp" 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 allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
ufw allow "${SUB_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${SUB_PORT}/tcp"
ufw reload >/dev/null 2>&1 || true ufw reload >/dev/null 2>&1 || true
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp" ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp, ${UI_PORT}/tcp и ${SUB_PORT}/tcp"
else else
echo " → UFW не активен, правила фаервола не трогаем" echo " → UFW не активен, правила фаервола не трогаем"
fi fi
@@ -878,6 +1045,13 @@ UI_PORT=${UI_PORT}
UI_USER=${UI_USER} UI_USER=${UI_USER}
UI_PASS=${UI_PASS} UI_PASS=${UI_PASS}
JWT_SECRET=${JWT_SECRET} JWT_SECRET=${JWT_SECRET}
# ── подписка sen:// для SenAWG (мастер-ключи) ──────────────────────────────
SUB_PORT=${SUB_PORT}
SUB_TLS=${SUB_TLS}
SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV}
SUB_TLS_KEY_FILE=${SUB_TLS_KEY}
SUB_TLS_CERT_FILE=${SUB_TLS_CRT}
ENV ENV
# Имя продукта попадает в cli.env, только если оператор задал его явно # Имя продукта попадает в cli.env, только если оператор задал его явно
@@ -896,6 +1070,7 @@ echo
echo -e " ${BLD}Сервисы:${NC}" echo -e " ${BLD}Сервисы:${NC}"
echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)" echo " awg-ctrl → http://localhost:${AWGCTRL_PORT} (внутренний)"
echo " awg-ui → http://${SERVER_IP}:${UI_PORT}" echo " awg-ui → http://${SERVER_IP}:${UI_PORT}"
echo " sen:// → $([[ "$SUB_TLS" == "on" ]] && echo https || echo http)://${SERVER_IP}:${SUB_PORT} (подписка SenAWG)"
echo echo
echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}" echo -e " ${BLD}${YLW}UI логин:${NC} ${UI_USER}"
echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}" echo -e " ${BLD}${YLW}UI пароль:${NC} ${UI_PASS}"