16 Commits
Author SHA1 Message Date
maenekoandClaude Opus 5 fd87884bde Bump to Beta 0.2.2
ci / check (push) Successful in 7m1s
release / release (push) Successful in 3m40s
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:51:19 +03:00
maenekoandClaude Opus 5 4971a90e41 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
maeneko 068cab4d24 Remove the MA7 integration
release / release (push) Successful in 2m47s
The integration was half-committed: server.ts imported ./ma7 and the profile
tab was documented, but neither file ever reached the repository, so awg-ui
could not start at all — `Error: Cannot find module './ma7'`.

Everything that referenced it is gone: the import and the /ui/ma7/profile
route, the three MA7_* variables the CLI passed to awg-ui, the installer
prompts and their cli.env block, and the README section. The panel login
prompt goes back to defaulting to admin instead of the MA7 subscriber login.

awg-ui type-checks, builds and starts again.
2026-08-29 03:23:40 +03:00
maeneko 21c7409017 Check every push with CI
Nothing verified a push to main: the only workflow triggers on v* tags, so an
incomplete commit — one whose new files were never staged, leaving an import
pointing at a file that does not exist — sat on main for two days without a
signal.

This runs on every branch push and pull request: bash -n on the installer,
tsc across awg-ctrl, awg-ui and cli, and the Vite build. awg-ui's tsconfig
covers server.ts, so the missing-module case is exactly what the awg-ui step
reports. The same workflow is added for both hosts, since releases are now
published from GitHub and Gitea alike.

awg-ctrl's tsconfig had `include: [""]`, which matches no files at all
(TS18003) — the awg-ctrl step would have failed on the config rather than on
real errors, so it now points at index.ts.
2026-08-29 03:21:40 +03:00
maeneko ca1c8e8c68 Add a Gitea release workflow
Releases only existed on GitHub, while install.sh now defaults to pulling the
archive from the Gitea mirror. This builds the same archive on a v* tag and
publishes it through the Gitea API, since the runner has no gh CLI: create the
release, fall back to looking it up by tag when it already exists, then upload
the tarball as an asset.

Two details are easy to get wrong and are commented in place. The release id is
the first "id" in Gitea's JSON, so the match must be non-greedy — a greedy one
picks up the author or asset id instead. And the id helper needs `|| true`,
because an empty grep under pipefail would abort the step on the path where the
release already exists.

The workflow cannot run yet: the Gitea repo is a pull mirror, which neither
runs Actions nor accepts release creation. It starts working once the repo is
un-mirrored, Actions are enabled and a runner with the ubuntu-latest label is
registered; the header of the file lists those prerequisites.
2026-08-29 03:08:51 +03:00
maeneko 602c0416ba Move panel name and version into .env, bump to Beta 0.2.0
The product name, the channel word "Alpha" and the version were scattered
across install.sh, the CLI banner, two spots in App.tsx and three
package.json files, so a rename or a bump meant editing strings in code.

They now live in one committed .env at the project root (BRAND, CHANNEL,
VERSION), shipped inside the release archive and read at runtime: the CLI
loads it after cli.env — so real env and machine settings still win — and
injects the three values into awg-ui, which serves them from GET /ui/brand
for the wordmark. awg-ui also parses the same file directly, so
`npm run server` in dev shows what production shows.

install.sh keeps its own VERSION and CHANNEL and says why: it prints the
banner and builds the release URL before the archive holding .env has been
downloaded. It writes BRAND into cli.env only when the operator passed one
explicitly, so the file stays the single source otherwise.

Version bumped to Beta 0.2.0 across .env, install.sh and the three
package.json files.
2026-08-29 03:08:51 +03:00
maeneko 18972ed58f Make the distribution source and brand configurable
Nothing about the install was overridable: the archive could only come from
one GitHub repo, the connectivity precheck pinged github.com whatever the
source was, and the product name was duplicated across install.sh, the CLI
banner and two spots in App.tsx.

install.sh now reads REPO_BASE (default the Gitea mirror), REPO_FALLBACK
(default GitHub), ARCHIVE_URL and BRAND from the environment. Gitea and
GitHub share the same release path, so one scheme covers both:
download_extract tries the primary, warns and falls back to the secondary,
and fails with both URLs listed. The fallback carries real weight today —
the Gitea repo is a pull mirror that does not sync releases, so artifacts
live only on GitHub until release sync is enabled there. The precheck now
probes the hosts actually used.

BRAND flows install.sh -> cli.env -> CLI banner and awg-ui, which exposes
it via GET /ui/brand along with the package version. That route is
unauthenticated because the wordmark is on the login screen, before a JWT
exists; the SPA falls back to a constant if it fails. Renaming a fork is
one environment variable, and no version string is hardcoded in the UI or
the CLI any more.

Also adds /ui to the Vite dev proxy (GET /ui/brand and the API-keys tab
were unreachable in dev), documents the overrides in the README, fixes a
typo in the API.md base URL and states there that the API is not bound to
any host.
2026-08-29 03:08:51 +03:00
maeneko 38e76da84f Make AmneziaWG 3.1 the default generation
install.sh now defaults to AWG_GEN=3.1: an incompatible module or kernel
fails with diagnostics instead of silently falling back to 2.0 and handing
out a server with weaker obfuscation. AWG_GEN=auto keeps the old fallback
for old kernels, AWG_GEN=2.0 still forces 2.0.

Servers already installed on 2.0 no longer need a reinstall: POST
/awg/upgrade (button in the users tab) backs up awg1.conf, rewrites
[Interface] with HeaderProtectionKey and the 3.1 block install.sh writes,
raises any S1-S4 below 12 (header protection needs a 12-byte nonce and the
module only reports "Invalid argument"), restarts the interface and
reissues every vpn:// key. Bringing the interface up is the only real proof
the kernel accepted the params, so a failure restores the previous conf and
brings 2.0 back up. Reissue is not optional: 2.0-era keys stop working the
moment the generation changes.

restartAwg() now re-reads the obfuscation params, generation and keepalive
from awg1.conf before syncing peers. The conf can change while the
interface is down, and a stale CONFIG.gen meant new keys were built on the
old params.

README: install from git.ma7neko.ru (Gitea) instead of raw.githubusercontent,
and document the 3.1 default with the AWG_GEN=auto escape hatch.
2026-08-29 03:08:07 +03:00
maeneko 0dbb13f120 com 2026-08-27 01:39:06 +03:00
maeneko b097ccd5fb Bump version to 0.1.4.2
Ships the AWG_GEN/AWG_PIN installer switches and module-version
surfacing from the previous commit.
2026-08-25 15:00:59 +03:00
maeneko c0e19f284d Add AWG_GEN/AWG_PIN switches and surface module version
Upstream AmneziaWG 3.x is still shifting under active development
(unanswered kernel-module issues around header protection and
handshake-without-data regressions), so give operators an explicit
opt-out/opt-in instead of always auto-selecting 3.1, and pin the
installed package so unattended-upgrades can't swap the module under
a live server. Surface the running module/tools version and protocol
generation through /health, the CLI status command, and the panel's
server card so a mismatch is visible instead of silent.
2026-08-25 14:57:41 +03:00
maeneko 6fdacd71c3 Fix 3.1 rollout on servers with an older loaded module and UFW
Three defects surfaced on a real upgrade from 2.0.

The module gate read modinfo, which reports the version on disk. apt
--only-upgrade rebuilds the DKMS module but does not replace the one already
loaded, so the installer wrote a 3.1 conf that the running 2.0 module rejected
with 'Unable to modify interface: Invalid argument'. It now compares
/sys/module/amneziawg/version, reloads the module when it lags behind the disk,
and decides the generation from the loaded version only.

Bringing the interface up is the only real proof the kernel accepted the 3.1
parameters, so a failure there no longer aborts the install: it enables dynamic
debug, retries, prints the amneziawg lines from dmesg, then strips the 3.x keys
and comes up on 2.0. The rejected conf is kept as awg1.conf.awg31.

PostUp appends 'iptables -A FORWARD', but UFW inserts its jumps at the head of
the chain, so client traffic was dropped with '[UFW BLOCK] IN=awg1 OUT=eth0' —
the tunnel connected and had no internet. When UFW is active the installer now
adds 'ufw route allow in on awg1 out on eth0' and opens the AWG and UI ports.

Version 0.1.4.1.
2026-08-17 18:48:38 +03:00
40 changed files with 6036 additions and 242 deletions
+14
View File
@@ -0,0 +1,14 @@
# Имя панели и версия. Единственное место, где они заданы: отсюда их читают CLI
# (баннер меню) и awg-ui (вордмарк в панели, GET /ui/brand). Файл едет в архив
# релиза и лежит в /opt/awg-control/.env на сервере.
#
# Переменные окружения перекрывают эти значения (BRAND=… в cli.env пишет
# install.sh, если оператор задал своё имя при установке).
#
# ⚠️ VERSION и CHANNEL продублированы в install.sh: установщик печатает баннер и
# собирает URL релиза ДО того, как архив с этим файлом скачан. При бампе версии
# правь оба места (и три package.json).
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.2.2
+48
View File
@@ -0,0 +1,48 @@
# Проверка на каждый пуш и PR (Gitea Actions). Ловит то, из-за чего main однажды уехал
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
# Релиз собирается отдельно (release.yml, только на теги v*).
#
# Требует включённых Actions в настройках репозитория и раннера с меткой
# ubuntu-latest — иначе поправь runs-on под свою метку.
name: ci
on:
push:
branches: ['**']
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Installer syntax
run: bash -n install.sh
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
npm install
npx tsc --noEmit -p tsconfig.json
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
# пропавший ./ma7.
- name: awg-ui types and build
working-directory: awg-ui
run: |
npm install
npx tsc --noEmit -p tsconfig.json
npm run build
- name: cli types
working-directory: cli
run: |
npm install
npx tsc --noEmit -p tsconfig.json
+89
View File
@@ -0,0 +1,89 @@
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает тот
# же архив, но заливает его через API Gitea — gh CLI в раннере нет.
#
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
# GitHub.
#
# Что нужно на стороне сервера, иначе workflow просто не запустится:
# 1. Репозиторий НЕ должен быть pull-зеркалом — в зеркале Actions не
# запускаются, а релизы через API не создаются (сейчас
# maeneko/forgetting — зеркало GitHub, has_actions: false).
# 2. В настройках репозитория включены Actions.
# 3. Зарегистрирован act_runner с меткой ubuntu-latest (иначе поправь runs-on
# под свою метку — Gitea принимает только `runs-on: метка`).
# 4. Токен: по умолчанию берётся встроенный secrets.GITEA_TOKEN. Если ему не
# хватает прав, заведи секрет RELEASE_TOKEN с PAT (имя не может начинаться
# с GITEA_/GITHUB_ — эти префиксы зарезервированы).
name: release
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Build frontend
working-directory: awg-ui
run: |
npm install
npm run build
- name: Package archive
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
# зависит от версии движка выражений в Gitea.
- name: Publish release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -euo pipefail
TOKEN="${RELEASE_TOKEN:-$GITEA_TOKEN}"
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
VERSION="${GITHUB_REF_NAME#v}"
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
# "id" (дальше идут id автора и ассетов; жадный sed схватил бы их).
# jq в раннере может не быть, поэтому grep. Если релиз на этот тег уже
# есть (повторный запуск), создание вернёт ошибку — берём его по тегу.
# `|| true` обязателен: без совпадения grep вернёт 1, а с pipefail
# это уронило бы шаг там, где пустой ответ — штатная ветка.
release_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -n1 | grep -oE '[0-9]+$' || true; }
id=$(curl -sS -X POST "$API/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":true}" \
| release_id)
if [ -z "$id" ]; then
echo "релиз ${GITHUB_REF_NAME} уже существует — дополняем его"
id=$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/${GITHUB_REF_NAME}" | release_id)
fi
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
-H "Authorization: token $TOKEN" \
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
+45
View File
@@ -0,0 +1,45 @@
# Проверка на каждый пуш и PR. Ловит то, из-за чего main однажды уехал
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
# Релиз собирается отдельно (release.yml, только на теги v*).
name: ci
on:
push:
branches: ['**']
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Installer syntax
run: bash -n install.sh
- name: awg-ctrl types
working-directory: awg-ctrl
run: |
npm install
npx tsc --noEmit -p tsconfig.json
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
# пропавший ./ma7.
- name: awg-ui types and build
working-directory: awg-ui
run: |
npm install
npx tsc --noEmit -p tsconfig.json
npm run build
- name: cli types
working-directory: cli
run: |
npm install
npx tsc --noEmit -p tsconfig.json
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}" mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/" cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release - name: Publish release
+129 -101
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), внешний API (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. | | **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl, публичный API `/api/v1` и своя база его ключей. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | | **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
## Требования ## Требования
- **ОС**: 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-модуля).
@@ -28,8 +28,134 @@
На сервере, от root: На сервере, от root:
```bash ```bash
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
``` ```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
```bash
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
```
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
Источник архива и имя панели не зашиты в код — задаются переменными
`REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `BRAND` перед запуском установщика
(подробности — в комментариях `install.sh` и в `.env`).
## API
Наружу смотрят две независимые поверхности. **Публичный API** `/api/v1` —
стабильный контракт для сторонних программ, авторизация по API-ключу.
**Роуты панели** — всё, что умеет веб-интерфейс, авторизация по JWT. Базовый
URL в обоих случаях — адрес сервера и порт, выбранный при установке.
> [!WARNING]
> Роуты панели отдают приватные поля пиров (`vpn_key`, `psk_key`) — это не
> публичный контракт, в отличие от `/api/v1`. Не выставляй её порт в интернет
> без TLS и ограничения по адресам.
### Публичный API (`/api/v1`)
Ключ создаётся во вкладке «API-ключи» и уходит в заголовке `X-Api-Key`.
Открытое значение показывается один раз при создании — на сервере лежит только
SHA-256 и префикс для показа в списке. Наружу отдаются только публичные поля:
`psk_key` и `pub_key` не покидают сервер.
```bash
curl -s -X POST http://HOST:PORT/api/v1/users \
-H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \
-d '{"name":"alice"}' | jq -r .vpn_key
```
| Метод | Что делает |
|---|---|
| `POST /api/v1/users` | Создаёт пира. Тело `{ "name": "alice" }`. `201` с `{ name, ip, gen, vpn_key }`; `400` — имя не прошло валидацию, `409` — уже есть |
| `GET /api/v1/users` | Список со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` |
| `GET /api/v1/users/:name` | `{ name, ip, gen, vpn_key }`; `404` — не найден |
| `DELETE /api/v1/users/:name` | `{ "success": true, "name": "alice" }` |
`gen` — поколение AmneziaWG, на параметрах которого собран ключ (`"2"` или
`"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить»
ключ = удалить и создать заново.
| Код | Когда |
|---|---|
| `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` |
| `401 {"error":"Invalid API key"}` | ключ не найден — удалён или неверен |
| `502 {"error":"awg-ctrl недоступен"}` | awg-ui посредничает, а awg-ctrl не отвечает |
Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне
каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про
внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией.
Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и
`DELETE /ui/apikeys/:id`.
### Авторизация панели
```bash
TOKEN=$(curl -s -X POST http://HOST:PORT/login \
-H "Content-Type: application/json" \
-d '{"user":"admin","pass":"***"}' | jq -r .token)
```
| Метод | Ответ и ошибки |
|---|---|
| `POST /login` | `{ "token": "<JWT>" }`, срок 24 часа. `401` — неверная пара; `429` — больше 5 неудачных попыток с одного IP за 15 минут |
| `POST /logout` | `{ "ok": true }`, токен отзывается немедленно |
Дальше в каждый запрос: `Authorization: Bearer $TOKEN`. Ниже — роуты, которые
дёргает сама панель; в отличие от `/api/v1` их форма может меняться.
### Состояние
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.2.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
`gen` — поколение AmneziaWG, на параметрах которого сейчас работает интерфейс:
`"2"` или `"3.1"`. Оно выводится из `awg1.conf`, а не хранится отдельно.
### Пользователи
| Метод | Что делает |
|---|---|
| `POST /api/users` | Создаёт пира. Тело `{ "name": "alice" }`, имя — `a–z A–Z 0–9 _ -`, до 32 символов. `201` с `{ name, ip, pub_key, psk_key, vpn_key, key_gen, vpn_key_prev }`; `400` — имя не прошло валидацию, `409` — уже есть |
| `GET /api/users` | Список: `{ users: [{ name, ip, pub_key, vpn_key, key_gen, online, lastHandshake }] }` |
| `GET /api/users/stats` | То же без ключей, но со счётчиками: `{ users: [{ name, ip, key_gen, online, lastHandshake, rx, tx }] }` |
| `POST /api/users/:name` | Возвращает пользователя целиком, включая `vpn_key`. Метод именно `POST`, чтобы ключ не оседал в логах и истории как параметр `GET`. `404` — не найден |
| `DELETE /api/users/:name` | `{ "success": true, "name": "alice" }` |
| `POST /api/users/reissue` | Пересобирает `vpn://` всем на текущих параметрах: `{ total, reissued, regenerated: [], backup }`. IP и ключевая пара сохраняются, клиентам нужно заново импортировать ключ; `backup` — путь к снимку базы, снятому перед проходом |
```bash
curl -s -X POST http://HOST:PORT/api/users \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"name":"alice"}' | jq -r .vpn_key
```
Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс `vpn://`,
base64url-декодировать, отбросить первые 4 байта (длина), распаковать
`zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
### Интерфейс AWG
| Метод | Что делает |
|---|---|
| `POST /awg/start` | Поднимает интерфейс, если он лежит; возвращает его статус |
| `POST /awg/restart` | `awg-quick down/up` + ресинк пиров, `{ "success": true }`. Соединения клиентов кратковременно рвутся |
| `POST /awg/upgrade` | Переводит сервер с 2.0 на 3.1: правит `awg1.conf`, перезапускает интерфейс и перевыпускает все ключи. `{ gen, backup, bumpedS: [], reissue: { total, reissued, … } }` |
`POST /awg/upgrade` отвечает `409`, если сервер уже на 3.1, модуль в ядре не
3.x или ядро старше 5.5, и `500` с текстом причины, если ядро не приняло
3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс
поднимается обратно на 2.0.
## Управление ## Управление
```bash ```bash
@@ -42,103 +168,5 @@ awg-ctrl
systemctl start|stop|restart awg-control systemctl start|stop|restart awg-control
``` ```
## 1. Публичный API (`/api/v1`)
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные
ключи и PSK наружу не отдаются.
### Авторизация
Каждый запрос обязан содержать заголовок:
```
X-Api-Key: awgk_<...>
```
Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys`
(раздел 3). Открытое значение ключа показывается **один раз** при создании —
сохрани его сразу, дальше доступен только хэш.
Ответы при проблемах с ключом:
| Код | Когда |
|---|---|
| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` |
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) |
> Ключ привязан к серверу (`server_id`). Сейчас сервер один — поле всегда `0`
### `POST /api/v1/users` — создать пользователя
Создаёт VPN-пира и возвращает готовый `vpn://` ключ.
**Тело:** `{ "name": "<имя>" }`
Имя: `a–z A–Z 0–9 _ -`, 1–32 символа.
**`201 Created`:**
```json
{
"name": "alice",
"ip": "10.9.0.2",
"vpn_key": "vpn://AAAI..."
}
```
| Код ошибки | Причина |
|---|---|
| `400` | имя не прошло валидацию |
| `409` | пользователь с таким именем уже есть |
```bash
curl -X POST http://HOST:PORT/api/v1/users \
-H "X-Api-Key: awgk_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"alice"}'
```
### `GET /api/v1/users` — список пользователей
Пиры с трафиком и статусом онлайн.
**`200 OK`:**
```json
{
"users": [
{ "name": "alice", "ip": "10.9.0.2", "online": true, "lastHandshake": 1718800000, "rx": 142400000, "tx": 9800000 },
{ "name": "bob", "ip": "10.9.0.3", "online": false, "lastHandshake": 0, "rx": 0, "tx": 0 }
]
}
```
- `lastHandshake` — unix-секунды последнего рукопожатия (`0` — не было).
- `rx`/`tx` — байты принято/отдано.
```bash
curl http://HOST:PORT/api/v1/users -H "X-Api-Key: awgk_xxx"
```
### `GET /api/v1/users/:name` — получить ключ пользователя
**`200 OK`:**
```json
{ "name": "alice", "ip": "10.9.0.2", "vpn_key": "vpn://AAAI..." }
```
| Код ошибки | Причина |
|---|---|
| `404` | пользователь не найден |
```bash
curl http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
> Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс
> `vpn://`, base64url-декодировать, отбросить первые 4 байта (длина), распаковать
> `zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
### `DELETE /api/v1/users/:name` — удалить пользователя
**`200 OK`:**
```json
{ "success": true, "name": "alice" }
```
```bash
curl -X DELETE http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
## Лицензия ## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev [MIT](LICENSE) © 2026 Ivan Vasilev
+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',
},
},
});
+195 -2
View File
@@ -3,6 +3,7 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path"; import path from "path";
import os from "os";
import crypto from "crypto"; import crypto from "crypto";
import { execSync, spawnSync } from "child_process"; import { execSync, spawnSync } from "child_process";
import * as zlib from "zlib"; import * as zlib from "zlib";
@@ -196,13 +197,56 @@ function genOf(p: AwgParams): "2" | "3.1" {
return p.HeaderProtectionKey ? "3.1" : "2"; return p.HeaderProtectionKey ? "3.1" : "2";
} }
// Версия модуля/tools для /health — показывает оператору, что реально работает
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
// это единственные моменты, когда модуль может реально смениться в течение
// жизни процесса; из обработчика /health не вызывается — этот роут без
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
function readAwgVersions(): { module: string; tools: string } {
let module = "";
try {
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
} catch {
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
}
let tools = "";
try {
const out = run("awg --version");
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
} catch { /* awg-tools не найден */ }
return { module, tools };
}
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN); // PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг. // в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" }; const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
// Значения 3.1-параметров для перевода уже работающего 2.0-сервера (POST
// /awg/upgrade) — те же, что пишет install.sh (дефолты клиента AmneziaVPN,
// protocolConstants.h), в порядке AWG3_KEYS. RandomTrailers/DisableCookies не
// включаем — как и инсталлятор. HeaderProtectionKey сюда не входит: он
// генерируется на месте (awg genpsk) и фиксируется на весь срок жизни сервера.
const AWG31_DEFAULTS: Record<string, string> = {
ContentPaddingAddition: "10-100",
RekeyAfterTime: "100-120",
RekeyTimeout: "3-7",
RejectAfterTime: "150-180",
KeepaliveTimeout: "5-15",
MaxHandshakeAttempts: "15-20",
};
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12
// (HEADER_PROTECTION_NONCE_SIZE). Модуль на нарушение отвечает только
// «Invalid argument»; причина видна лишь при
// `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.
const MIN_S_FOR_HEADER_PROTECTION = 12;
const runtimeConfig = initConfig(); const runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams(); const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS); const AWG_GEN = genOf(AWG_PARAMS);
const AWG_VERSIONS = readAwgVersions();
const CONFIG = { const CONFIG = {
interface: "awg1", interface: "awg1",
confDir: "/etc/amnezia/amneziawg", confDir: "/etc/amnezia/amneziawg",
@@ -216,6 +260,10 @@ const CONFIG = {
keepalive: KEEPALIVE_BY_GEN[AWG_GEN], keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: AWG_PARAMS, awgParams: AWG_PARAMS,
gen: AWG_GEN, gen: AWG_GEN,
// Мутируются в restartAwg() — единственном месте, где модуль может
// реально смениться в течение жизни процесса.
awgModule: AWG_VERSIONS.module,
awgTools: AWG_VERSIONS.tools,
}; };
interface UserRow { interface UserRow {
@@ -574,6 +622,17 @@ function ensureInterfaceUp() {
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`); if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
} }
// Параметры обфускации и поколение живут в awg1.conf, а не в процессе:
// перечитываем их после любой правки конфига. Иначе awg-ctrl продолжит
// собирать vpn:// ключи на старых параметрах — например, на 2.0 уже после
// перехода интерфейса на 3.1.
function reloadAwgParams(): "2" | "3.1" {
CONFIG.awgParams = readAwgParams();
CONFIG.gen = genOf(CONFIG.awgParams);
CONFIG.keepalive = KEEPALIVE_BY_GEN[CONFIG.gen];
return CONFIG.gen;
}
function restartAwg() { function restartAwg() {
logger.info("AWG restart: down"); logger.info("AWG restart: down");
const down = spawnSync("awg-quick", ["down", CONFIG.interface]); const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
@@ -581,8 +640,126 @@ function restartAwg() {
logger.info("AWG restart: up"); logger.info("AWG restart: up");
const up = spawnSync("awg-quick", ["up", CONFIG.interface]); const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`); if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная
// правка awg1.conf) — перечитываем ДО syncPeers: keepalive из CONFIG уходит
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams();
syncPeers(); syncPeers();
logger.info("AWG restart: done"); const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
logger.info("AWG restart: done", { ...versions, gen });
}
// ─────────────────────────── переход 2.0 → 3.1 ───────────────────────────
// 3.1 — основное поколение (install.sh ставит его по умолчанию). Сервера,
// установленные раньше, переводятся отсюда, без переустановки: панель дёргает
// POST /awg/upgrade.
// Header protection использует библиотечный chacha-API (chacha_init/
// chacha20_crypt), которого нет до ядра 5.5 — там модуль 3.x просто не
// соберётся (upstream issue #210).
function kernelSupportsHeaderProtection(): boolean {
const [maj, min] = os.release().split(".").map(n => Number.parseInt(n, 10));
if (!Number.isFinite(maj) || !Number.isFinite(min)) return false;
return maj > 5 || (maj === 5 && min >= 5);
}
// Пред-проверка перед тем, как трогать конфиг: решает ЗАГРУЖЕННЫЙ модуль
// (readAwgVersions читает /sys/module/amneziawg/version), а не тот, что лежит
// на диске после apt — обслуживает интерфейс именно загруженный.
function checkGen31Support(): string | null {
const { module } = readAwgVersions();
if (!module.startsWith("3."))
return `В ядре загружен модуль amneziawg ${module || "неизвестной версии"} — для 3.1 нужна 3.x. `
+ "Обнови пакет (apt) и перезагрузи сервер, затем повтори переход.";
if (!kernelSupportsHeaderProtection())
return `Ядро ${os.release()} старше 5.5 — header protection в нём не работает (upstream issue #210).`;
return null;
}
const AWG3_KEY_LINE = new RegExp(`^\\s*(${AWG3_KEYS.join("|")})\\s*=`);
// Готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и
// дописывает 3.x-блок в конец [Interface]. [Peer]-блоки не трогаются —
// личности пиров переход не меняет.
function confWithGen31(conf: string, headerKey: string): { conf: string; bumpedS: string[] } {
const peerAt = conf.search(/^\[Peer\]/m);
let rest = peerAt >= 0 ? conf.slice(peerAt) : "";
const head = (peerAt >= 0 ? conf.slice(0, peerAt) : conf)
.split("\n")
.filter(l => !AWG3_KEY_LINE.test(l));
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому
// [Peer] (их пишет rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь
// [Interface], а не между комментарием и его пиром.
const tail: string[] = [];
while (head.length && (head[head.length - 1].trim() === "" || head[head.length - 1].trim().startsWith("#")))
tail.unshift(head.pop()!);
rest = tail.join("\n").trim() ? `${tail.join("\n").trim()}\n${rest}` : rest;
let iface = head.join("\n");
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет.
// Правка безопасна ровно потому, что следом идёт перевыпуск ключей: обе
// стороны получают новые значения одновременно.
const bumpedS: string[] = [];
for (const k of ["S1", "S2", "S3", "S4"] as const) {
const re = new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)\\s*$`, "m");
const cur = Number(iface.match(re)?.[1]);
if (Number.isFinite(cur) && cur >= MIN_S_FOR_HEADER_PROTECTION) continue;
const val = Math.max(DEFAULT_AWG_PARAMS[k], MIN_S_FOR_HEADER_PROTECTION);
iface = re.test(iface)
? iface.replace(re, `${k} = ${val}`)
: `${iface.trimEnd()}\n${k} = ${val}`;
bumpedS.push(`${k}: ${Number.isFinite(cur) ? cur : "нет"} → ${val}`);
}
const awg3 = [
`HeaderProtectionKey = ${headerKey}`,
...Object.entries(AWG31_DEFAULTS).map(([k, v]) => `${k} = ${v}`),
].join("\n");
return { conf: `${iface.trimEnd()}\n${awg3}\n${rest ? `\n${rest}` : ""}`, bumpedS };
}
interface UpgradeResult { gen: "2" | "3.1"; backup: string; bumpedS: string[]; reissue: ReissueResult }
// Перевод интерфейса на 3.1. Единственная настоящая проверка, что ядро приняло
// параметры, — подъём интерфейса, поэтому шаги такие: бэкап конфига → запись
// 3.1 → restartAwg() (он же перечитает параметры) → перевыпуск ключей. Если
// ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
function upgradeToGen31(): UpgradeResult {
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
const before = readFileSync(confFile, "utf8");
const backup = `${confFile}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
writeFileSync(backup, before, { mode: 0o600 });
const { conf, bumpedS } = confWithGen31(before, run("awg genpsk"));
writeFileSync(confFile, conf, { mode: 0o600 });
logger.info("upgrade: conf rewritten for 3.1", { backup, bumpedS });
try {
restartAwg();
if (CONFIG.gen !== "3.1") throw new Error("интерфейс поднялся, но конфиг всё ещё 2.0");
} catch (e) {
logger.error("upgrade: kernel rejected 3.1, rolling back", { error: e });
writeFileSync(confFile, before, { mode: 0o600 });
try { restartAwg(); } catch (e2) { logger.error("upgrade: rollback restart failed", { error: e2 }); }
throw new Error(
`Ядро не приняло конфиг 3.1 — вернули прежний (копия: ${backup}). `
+ 'Причина видна в dmesg после `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.',
);
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся,
// поэтому перевыпускаем сразу — сервер не должен оставаться в состоянии
// «3.1 поднят, у всех нерабочие ключи». Личности пиров сохраняются.
const reissue = reissueAll();
logger.info("upgrade: done", { gen: CONFIG.gen, reissued: reissue.reissued, bumpedS });
return { gen: CONFIG.gen, backup, bumpedS, reissue };
} }
function startInterface() { function startInterface() {
@@ -697,7 +874,7 @@ app.get("/health", (_req, res) => {
server: CONFIG.serverName, server: CONFIG.serverName,
ip: CONFIG.serverIp, ip: CONFIG.serverIp,
gen: CONFIG.gen, gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers }, awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
}); });
}); });
@@ -759,6 +936,22 @@ app.post("/awg/restart", auth, handler((_req, res) => {
res.json({ success: true }); res.json({ success: true });
})); }));
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей.
// Ошибку отдаём текстом: панель показывает её оператору, а причины отказа
// (старый модуль, старое ядро, отказ ядра на подъёме) требуют разных действий.
app.post("/awg/upgrade", auth, handler((_req, res) => {
if (CONFIG.gen === "3.1") {
res.status(409).json({ error: "Сервер уже работает на AmneziaWG 3.1" }); return;
}
const blocker = checkGen31Support();
if (blocker) { res.status(409).json({ error: blocker }); return; }
try {
res.json(upgradeToGen31());
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : "Не удалось перейти на 3.1" });
}
}));
app.get("/awg/status", auth, handler((_req, res) => { app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus()); res.json(getInterfaceStatus());
})); }));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.1.4", "version": "0.2.2",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -9,6 +9,6 @@
"outDir": "dist" "outDir": "dist"
}, },
"include": [ "include": [
"" "index.ts"
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.1.4", "version": "0.2.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+43 -8
View File
@@ -16,6 +16,20 @@ const UI_PASS = process.env.UI_PASS ?? "";
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex"); const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`; const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
// Имя панели, канал и версия. В проде их прокидывает CLI (он читает .env в
// корне проекта и cli.env); при самостоятельном запуске `npm run server`
// читаем тот же .env сами, чтобы дев-режим показывал то же, что прод.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const ROOT_ENV = path.join(__dirname, "..", ".env");
const BRAND = process.env.BRAND || envFromFile(ROOT_ENV, "BRAND") || "Forgetting";
const CHANNEL = process.env.CHANNEL || envFromFile(ROOT_ENV, "CHANNEL") || "Beta";
const VERSION = process.env.VERSION || envFromFile(ROOT_ENV, "VERSION") || "";
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
?? path.join(__dirname, "internal_auth_private.key"); ?? path.join(__dirname, "internal_auth_private.key");
let internalAuthKey: crypto.KeyObject | null = null; let internalAuthKey: crypto.KeyObject | null = null;
@@ -37,6 +51,9 @@ function mintInternalToken(): string {
const revoked = new Set<string>(); const revoked = new Set<string>();
// ── Ключи внешнего API (/api/v1) ───────────────────────────────────────────
// Живут в собственной БД awg-ui: awg-ctrl про них ничего не знает. Открытое
// значение ключа не хранится — только SHA-256 и префикс для показа в панели.
interface ApiKeyRow { interface ApiKeyRow {
id: number; label: string; key_hash: string; prefix: string; id: number; label: string; key_hash: string; prefix: string;
server_id: number; created_at: number; last_used: number | null; server_id: number; created_at: number; last_used: number | null;
@@ -146,6 +163,13 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
// до выдачи JWT. Наружу уходит только имя продукта и версия.
app.get("/ui/brand", (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
});
// Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут.
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() }); res.json({ keys: keyStmts.list.all() });
}); });
@@ -159,6 +183,7 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
// список серверов — валидировать его против реального реестра серверов. // список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey(); const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000)); const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
}); });
@@ -217,31 +242,41 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router(); const ext = express.Router();
ext.use(requireApiKey); ext.use(requireApiKey);
ext.post("/users", async (req: Request, res: Response) => { // Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
// тот же, что у proxy() в такой ситуации.
type Handler = (req: Request, res: Response) => Promise<void>;
const wrap = (fn: Handler) => (req: Request, res: Response) => {
fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); });
};
ext.post("/users", wrap(async (req, res) => {
const { name } = (req.body ?? {}) as { name?: string }; const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name }); const r = await ctrl("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser; 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 }); res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}); }));
ext.get("/users", async (_req: Request, res: Response) => { ext.get("/users", wrap(async (_req, res) => {
const r = await ctrl("GET", "/api/users/stats"); const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data); res.status(r.status).json(r.data);
}); }));
ext.get("/users/:name", async (req: Request, res: Response) => { ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser; const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}); }));
ext.delete("/users/:name", async (req: Request, res: Response) => { ext.delete("/users/:name", wrap(async (req, res) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data); res.status(r.status).json(r.data);
}); }));
// ВАЖНО: /api/v1 монтируется ДО JWT-прокси на /api — иначе прокси перехватит
// его и потребует JWT вместо ключа.
app.use("/api/v1", ext); app.use("/api/v1", ext);
app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
+8 -19
View File
@@ -406,6 +406,13 @@ body {
color: var(--neutral-text); color: var(--neutral-text);
line-height: 1.2; line-height: 1.2;
} }
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-gen-chip {
align-self: flex-start;
margin-top: 2px;
font-size: 11px;
padding: 2px 8px;
}
.server-card-peers { .server-card-peers {
font-size: 12px; font-size: 12px;
color: var(--neutral-text); color: var(--neutral-text);
@@ -466,22 +473,6 @@ body {
} }
.tip-wrap:hover::after { opacity: 1; } .tip-wrap:hover::after { opacity: 1; }
.server-add {
display: inline-flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 16px;
border-radius: var(--r-pill);
border: 1.5px dashed var(--outline);
background: transparent;
font-family: var(--font);
font-size: 13px;
color: var(--neutral-dot);
cursor: not-allowed;
white-space: nowrap;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -666,11 +657,9 @@ tbody td:last-child { padding-right: 20px; }
.server-toggle { display: flex; } .server-toggle { display: flex; }
.server-online-chip { display: none; } .server-online-chip { display: none; }
/* Кнопка «Добавить сервер» — полная ширина */
.tip-wrap { width: 100%; } .tip-wrap { width: 100%; }
.server-add { width: 100%; justify-content: center; }
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */ /* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
.awg-restart { display: none; } .awg-restart { display: none; }
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; } .awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
+23 -6
View File
@@ -4,7 +4,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import './App.css'; import './App.css';
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared'; import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar'; import ServerBar from './components/ServerBar';
import { TABS } from './tabs'; import { TABS } from './tabs';
@@ -22,6 +22,7 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id); const [activeTab, setActiveTab] = useState(TABS[0].id);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() => const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0); const touchStartX = useRef(0);
@@ -36,7 +37,10 @@ export default function App() {
// вкладок (юзеры, ключи) грузят сами вкладки. // вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => { const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' }); setServerInfo({
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
}, []); }, []);
@@ -107,6 +111,18 @@ export default function App() {
} }
}, [startSession]); }, [startSession]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
channel: data.channel || '',
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
// Применять и сохранять тему // Применять и сохранять тему
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
@@ -160,7 +176,7 @@ export default function App() {
{!token ? ( {!token ? (
<div className="login-screen"> <div className="login-screen">
<div className="login-card"> <div className="login-card">
<span className="logo-name">Forgetting</span> <span className="logo-name">{brand.brand}</span>
<p className="login-sub">Войдите, чтобы продолжить</p> <p className="login-sub">Войдите, чтобы продолжить</p>
<input <input
className="field" className="field"
@@ -213,8 +229,10 @@ export default function App() {
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
<div className="sidebar-logo"> <div className="sidebar-logo">
<span className="logo-name">Forgetting</span> <span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">Alpha 0.1.4</span> <span className="sidebar-version">
{[brand.channel, brand.version].filter(Boolean).join(' ')}
</span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS.map(t => ( {TABS.map(t => (
@@ -243,7 +261,6 @@ export default function App() {
serverOnline={isOnline} serverOnline={isOnline}
onRestartAwg={restartAwg} onRestartAwg={restartAwg}
restarting={restarting} restarting={restarting}
showAddServer={chrome.addServer !== false}
showRestart={showRestart} showRestart={showRestart}
/> />
)} )}
+16 -16
View File
@@ -1,23 +1,20 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { type ServerInfo } from '../lib/shared'; import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoPlus, IcoRefresh } from './icons'; import { IcoRefresh } from './icons';
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной) // Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает) // ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// сервер, к которому относится содержимое вкладки (пиры / API-ключи). // вкладки (пиры / абоненты).
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
// станет несколько — карточки станут кликабельными, активная = выбранная.
export default function ServerBar({ export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting, serverInfo, serverOnline, onRestartAwg, restarting,
showAddServer = true, showRestart = true, showRestart = true,
}: { }: {
serverInfo: ServerInfo | null; serverInfo: ServerInfo | null;
serverOnline: boolean; serverOnline: boolean;
onRestartAwg: () => void; onRestartAwg: () => void;
restarting: boolean; restarting: boolean;
showAddServer?: boolean;
showRestart?: boolean; showRestart?: boolean;
}) { }) {
return ( return (
@@ -38,17 +35,20 @@ export default function ServerBar({
<div className="server-card-info"> <div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span> <span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span> <span className="server-card-ip">{serverInfo.ip}</span>
{(serverInfo.mod || serverInfo.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
)}
</div> </div>
<span className="server-card-peers">{serverInfo.peers} peers</span> <span className="server-card-peers">{serverInfo.peers} peers</span>
</div> </div>
)} )}
{showAddServer && (
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
<button className="server-add" disabled>
<IcoPlus /> Добавить сервер
</button>
</span>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && ( {showRestart && (
<button <button
+13
View File
@@ -21,7 +21,13 @@ export interface ServerInfo {
ip: string; ip: string;
peers: number; peers: number;
gen: AwgGen; gen: AwgGen;
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
tools: string; // версия awg-tools, из /health awg.tools
} }
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
// Ключ внешнего API в списке вкладки: открытого значения тут нет — только
// префикс, сервер хранит лишь хэш.
export interface ApiKey { export interface ApiKey {
id: number; id: number;
label: string; label: string;
@@ -30,10 +36,17 @@ export interface ApiKey {
created_at: number; created_at: number;
last_used: number | null; last_used: number | null;
} }
export interface PageProps { export interface PageProps {
token: string; token: string;
showMsg: (text: string) => void; showMsg: (text: string) => void;
} }
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
// Эта константа — фолбэк на время загрузки и при недоступности роута.
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token'; export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme'; export const THEME_KEY = 'awg_theme';
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> { export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
-1
View File
@@ -18,7 +18,6 @@ import { ICONS } from '../components/icons';
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true. // Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
export interface TabChrome { export interface TabChrome {
serverBar?: boolean; serverBar?: boolean;
addServer?: boolean;
restart?: boolean; restart?: boolean;
} }
+33 -5
View File
@@ -4,7 +4,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen, type User, type PageProps, type AwgGen,
} from '../../lib/shared'; } from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
@@ -19,6 +19,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом // Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить. // поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2'); const [serverGen, setServerGen] = useState<AwgGen>('2');
const [upgrading, setUpgrading] = useState(false);
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null); const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen); const stale = users.filter(u => u.key_gen !== serverGen);
@@ -50,7 +51,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ. // IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => { const reissueKeys = useCallback(async () => {
if (!confirm( if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` + `Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.', 'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return; )) return;
try { try {
@@ -62,6 +63,28 @@ export default function UsersPage({ token, showMsg }: PageProps) {
} }
}, [stale.length, serverGen, token, loadUsers, showMsg]); }, [stale.length, serverGen, token, loadUsers, showMsg]);
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
// их не примет) и сразу перевыпускает ключи — старые собраны на
// 2.0-параметрах и работать не будут.
const upgradeGen = useCallback(async () => {
if (!confirm(
'Перевести сервер на AmneziaWG 3.1?\n\n' +
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
'клиентам придётся импортировать ключ заново.',
)) return;
setUpgrading(true);
try {
const r = await apiFetch('POST', '/awg/upgrade', token);
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
await loadUsers(token);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
} finally {
setUpgrading(false);
}
}, [token, loadUsers, showMsg]);
const createUser = useCallback(async () => { const createUser = useCallback(async () => {
if (!newName) return; if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName }); const u = await apiFetch('POST', '/api/users', token, { name: newName });
@@ -120,6 +143,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<button className="btn btn--tonal" onClick={() => loadUsers(token)}> <button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить <IcoRefresh /> Обновить
</button> </button>
{serverGen !== '3.1' && (
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
</button>
)}
{stale.length > 0 && ( {stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}> <button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length}) <IcoRefresh /> Перевыпустить ключи ({stale.length})
@@ -162,11 +190,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
{u.key_gen !== serverGen && ( {u.key_gen !== serverGen && (
<span <span
className="tip-wrap user-gen" className="tip-wrap user-gen"
data-tip={`Ключ выдан на AWG ${u.key_gen}, сервер работает на ${serverGen}`} data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
> >
<span className="chip chip--error"> <span className="chip chip--error">
<span className="chip-dot" /> <span className="chip-dot" />
AWG {u.key_gen} {genLabel(u.key_gen)}
</span> </span>
</span> </span>
)} )}
@@ -207,7 +235,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
{u.key_gen !== serverGen && ( {u.key_gen !== serverGen && (
<span className="chip chip--error"> <span className="chip chip--error">
<span className="chip-dot" /> <span className="chip-dot" />
AWG {u.key_gen} {genLabel(u.key_gen)}
</span> </span>
)} )}
</div> </div>
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080', '/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080', '/health': 'http://localhost:8080',
'/login': 'http://localhost:8080', '/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080', '/logout': 'http://localhost:8080',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.1.4", "version": "0.2.2",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+55 -11
View File
@@ -15,19 +15,31 @@ const LOGS = path.join(ROOT, "logs");
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true }); if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
const envFile = path.join(__dirname, "..", "cli.env"); // Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
if (existsSync(envFile)) { // реальное окружение → cli.env (машинные настройки, пишет install.sh) →
for (const line of readFileSync(envFile, "utf8").split("\n")) { // .env в корне проекта (имя панели и версия, едет в архиве релиза).
function loadEnvFile(file: string) {
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split("\n")) {
const t = line.trim(); const t = line.trim();
if (!t || t.startsWith("#")) continue; if (!t || t.startsWith("#")) continue;
const idx = t.indexOf("="); const idx = t.indexOf("=");
if (idx === -1) continue; if (idx === -1) continue;
const k = t.slice(0, idx).trim(); process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
const v = t.slice(idx + 1).trim();
process.env[k] ??= v;
} }
} }
const envFile = path.join(__dirname, "..", "cli.env"); // машинные настройки
loadEnvFile(envFile);
loadEnvFile(path.join(ROOT, ".env")); // имя панели и версия
// Имя панели, канал и версия — из .env (или из cli.env, если оператор задал
// своё имя при установке). В коде только запасные значения на случай, когда
// .env не доехал.
const BRAND = process.env.BRAND || "Forgetting";
const CHANNEL = process.env.CHANNEL || "Beta";
const VERSION = process.env.VERSION || "";
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms)); const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const C = { const C = {
@@ -71,10 +83,15 @@ const SERVICES = {
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl). // Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key", ?? "/etc/amnezia/amneziawg/internal_auth_private.key",
// Панель показывает их в вордмарке (GET /ui/brand).
BRAND: BRAND,
CHANNEL: CHANNEL,
VERSION: VERSION,
UI_USER: process.env.UI_USER ?? "admin", UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "", UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "", JWT_SECRET: process.env.JWT_SECRET ?? "",
// ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку. // ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
// переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
}, },
pidFile: "/tmp/awg-ui.pid", pidFile: "/tmp/awg-ui.pid",
@@ -196,7 +213,27 @@ async function restart(name: SvcName) {
await start(name); await start(name);
} }
function status() { // GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
// молча даёт null — health-строка в status() тогда просто не печатается.
interface HealthBody {
gen?: string;
awg?: { module?: string; tools?: string };
}
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
return new Promise(resolve => {
const req = http.get(url, res => {
let body = "";
res.on("data", (c: Buffer) => { body += c; });
res.on("end", () => {
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
});
});
req.on("error", () => resolve(null));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
});
}
async function status() {
console.log(""); console.log("");
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
@@ -205,6 +242,13 @@ function status() {
const info = running ? green("running") + ` pid ${pid}` : grey("stopped"); const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`); console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
} }
if (isRunning("awgctrl")) {
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
if (h?.gen) {
const gen = h.gen === "3.1" ? "3.1" : "2.0";
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
}
}
console.log(""); console.log("");
} }
@@ -287,7 +331,7 @@ async function interactiveMenu() {
const printMenu = () => { const printMenu = () => {
console.clear(); console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4 ──${C.reset}`)}\n`); console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
@@ -321,7 +365,7 @@ async function interactiveMenu() {
case "1": await startAll(); break; case "1": await startAll(); break;
case "2": await stopAll(); break; case "2": await stopAll(); break;
case "3": await restartAll(); break; case "3": await restartAll(); break;
case "4": status(); break; case "4": await status(); break;
case "5": await setCredentials("user"); break; case "5": await setCredentials("user"); break;
case "6": await setCredentials("pass"); break; case "6": await setCredentials("pass"); break;
case "0": rl.close(); process.exit(0); case "0": rl.close(); process.exit(0);
@@ -350,7 +394,7 @@ async function main() {
case "start": await startAll(); break; case "start": await startAll(); break;
case "stop": await stopAll(); break; case "stop": await stopAll(); break;
case "restart": await restartAll(); break; case "restart": await restartAll(); break;
case "status": status(); break; case "status": await status(); break;
case "credentials": case "credentials":
if (sub === "user") await setCredentials("user"); if (sub === "user") await setCredentials("user");
else if (sub === "pass") await setCredentials("pass"); else if (sub === "pass") await setCredentials("pass");
+357 -48
View File
@@ -19,9 +19,28 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh" [[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.4" # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
GH_REPO="maeneko/forgetting" # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}" # При бампе версии правь оба места.
VERSION="0.2.2"
CHANNEL="${CHANNEL:-Beta}"
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
# ничего не зашито. У Gitea и GitHub путь релиза одинаковый
# (<repo>/releases/download/v<VERSION>/<file>), поэтому одна схема покрывает оба.
# REPO_BASE — основной источник (своё зеркало);
# REPO_FALLBACK — запасной, если в основном релиза ещё нет;
# ARCHIVE_URL — полный URL архива, перекрывает оба варианта;
# BRAND — имя продукта в баннерах, systemd-юните и панели; по
# умолчанию берётся из .env в архиве, здесь только запасное
# значение для баннера самого установщика.
BRAND_OVERRIDE="${BRAND:-}"
BRAND="${BRAND:-Forgetting}"
REPO_BASE="${REPO_BASE:-https://git.ma7neko.ru/maeneko/forgetting}"
REPO_FALLBACK="${REPO_FALLBACK:-https://github.com/maeneko/forgetting}"
ARCHIVE_NAME="awgcontrol-${VERSION}.tar.gz"
ARCHIVE_URL="${ARCHIVE_URL:-}"
url_host() { sed -E 's#^https?://([^/]+).*#\1#' <<< "$1"; }
PROJECT="/opt/awg-control" PROJECT="/opt/awg-control"
AMNEZIA_DIR="/etc/amnezia" AMNEZIA_DIR="/etc/amnezia"
AWG_DIR="$AMNEZIA_DIR/amneziawg" AWG_DIR="$AMNEZIA_DIR/amneziawg"
@@ -43,7 +62,7 @@ MTU="1376"
TSX="$PROJECT/cli/node_modules/.bin/tsx" TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts" CLI="$PROJECT/cli/src/index.ts"
echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}" echo -e "${BLD}${BRAND} ${CHANNEL} ${VERSION}${NC}"
echo echo
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой # Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
@@ -53,13 +72,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
@@ -79,25 +136,34 @@ 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: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. # HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
if ! curl -sS --connect-timeout 8 -o /dev/null "https://github.com" 2>/dev/null; then # Проверяем хосты, с которых реально качаем: хватает любого из двух —
# download_extract() умеет откатываться на запасной.
NET_HOSTS=("$(url_host "$REPO_BASE")")
[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")")
[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \
&& NET_HOSTS+=("$(url_host "$REPO_FALLBACK")")
NET_OK=0 NET_OK=0
NET_WHY="нет доступа к github.com — проверь интернет и DNS" for h in "${NET_HOSTS[@]}"; do
fi if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi
done
[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS"
# Чеклист: ✓ — пройдено, ✗ — нет. # Чеклист: ✓ — пройдено, ✗ — нет.
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; } mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
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"
@@ -134,6 +200,23 @@ H2="2122053282-2133204808"
H3="2133604274-2140756116" H3="2133604274-2140756116"
H4="2143656228-2147444225" H4="2143656228-2147444225"
# Поколение протокола и закрепление версии пакета — управляются переменными
# окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`.
# AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x
# или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0
# здесь нежелателен: он молча выдал бы сервер со слабой обфускацией.
# AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0
# без вопросов. Для установки на заведомо старое ядро/модуль.
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
# Сервер, установленный на 2.0, переводится на 3.1 позже из панели
# (POST /awg/upgrade) — переустановка для этого не нужна.
AWG_GEN="${AWG_GEN:-3.1}"
[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)"
# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
# сервером (issue #215 — именно так словили регрессию между dkms-сборками).
AWG_PIN="${AWG_PIN:-y}"
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" [[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
@@ -174,17 +257,45 @@ start_logging() {
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
# файлы на месте. # файлы на месте.
download_extract() { download_extract() {
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz" local tmp="/tmp/${ARCHIVE_NAME}"
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz" local keep_tarball=0
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
# встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
tmp="$AWG_LOCAL_TARBALL"
keep_tarball=1 # чужой файл — после распаковки не удаляем
echo " → локальный архив: $tmp"
ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)"
else
local urls=()
if [[ -n "$ARCHIVE_URL" ]]; then
urls=("$ARCHIVE_URL")
else
urls=("$REPO_BASE/releases/download/v${VERSION}/${ARCHIVE_NAME}")
[[ "$REPO_FALLBACK" != "$REPO_BASE" ]] \
&& urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}")
fi
echo " → версия: ${VERSION}" echo " → версия: ${VERSION}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ local url got=""
|| fail "Не удалось скачать архив: $url" for url in "${urls[@]}"; do
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi
warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник"
done
[[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника:
$(printf ' %s\n' "${urls[@]}")
Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh"
ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)"
fi
mkdir -p "$PROJECT" mkdir -p "$PROJECT"
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив" || fail "Не удалось распаковать архив"
rm -f "$tmp" [[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT" ok "Распакован → $PROJECT"
local f local f
@@ -232,7 +343,7 @@ setup_service() {
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)") node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
cat > "$SERVICE_UNIT" <<UNIT cat > "$SERVICE_UNIT" <<UNIT
[Unit] [Unit]
Description=AWG Control — awg-ctrl + awg-ui (Forgetting) Description=AWG Control — awg-ctrl + awg-ui (${BRAND})
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -390,37 +501,107 @@ 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
# Debian держит в архиве только последнюю сборку ядра: если сервер
# давно не перезагружали, пакета под запущенное ядро там уже нет.
warn "Debian хранит в архиве только свежую сборку ядра — запущенное $KERNEL"
warn "уже устарело. Поставь актуальное ядро вместе с заголовками:"
else
warn "Часто бывает на кастомном ядре провайдера. Решение:" warn "Часто бывает на кастомном ядре провайдера. Решение:"
warn " apt-get install -y linux-generic && reboot" fi
warn "и после загрузки в generic-ядро запусти install.sh заново." warn " $HDR_FIX"
warn "и после перезагрузки запусти install.sh заново."
fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль" fail "Отсутствуют заголовки ядра $KERNEL — DKMS не соберёт модуль"
fi fi
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
# Если репозиторий amnezia уже настроен (например, ключ и источник заведены
# руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически
# отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно.
# Проверяем именно файлы источников, а не `apt-cache policy`: сразу после
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
elif [[ "$OS_FAMILY" == "debian" ]]; then
add_amnezia_ppa_debian
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 \
|| fail "Не удалось добавить PPA ppa:amnezia/ppa" || fail "Не удалось добавить PPA ppa:amnezia/ppa"
fi
echo " → apt-get update (после PPA)" echo " → apt-get update (после PPA)"
apt-get update \ apt-get update \
@@ -457,10 +638,11 @@ fi
# - ядро: header protection использует библиотечный chacha-API # - ядро: header protection использует библиотечный chacha-API
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
# Если 3.1 недоступен — не падаем, а пишем 2.0-конфиг: awg-ctrl определяет # При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий
# поколение по наличию HeaderProtectionKey в конфиге, так что всё продолжит # откат подсунул бы более слабую обфускацию незаметно для оператора.
# работать ровно как раньше. # При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение
AWG3="y" # по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно
# как раньше, а перейти на 3.1 можно потом из панели.
kernel_lt_5_5() { kernel_lt_5_5() {
local maj min local maj min
@@ -469,28 +651,78 @@ kernel_lt_5_5() {
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
} }
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") # 🛑 Версий у модуля две, и путать их нельзя:
# modinfo — что лежит на диске (после apt/DKMS);
# /sys/module/amneziawg/version — что реально загружено в ядро.
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
if [[ "$AWG_GEN" == "2.0" ]]; then
AWG3="n"
MOD_VER=$(disk_mod_ver)
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
else
AWG3="y"
MOD_VER=$(disk_mod_ver)
if [[ "${MOD_VER%%.*}" != "3" ]]; then if [[ "${MOD_VER%%.*}" != "3" ]]; then
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
# снимаем, иначе --only-upgrade молча не тронет held-версию.
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
apt-get update >/dev/null 2>&1 || true apt-get update >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") MOD_VER=$(disk_mod_ver)
fi fi
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
LOADED_VER=$(loaded_mod_ver)
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
awg-quick down "$IFACE" 2>/dev/null || true
ip link delete dev "$IFACE" 2>/dev/null || true
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
LOADED_VER=$(loaded_mod_ver)
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
else
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
fi
fi
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
if [[ "${MOD_VER%%.*}" != "3" ]]; then if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n" AWG3="n"
warn "Модуль остался на версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." if [[ "$AWG_GEN" == "3.1" ]]; then
fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)).
Перезагрузи сервер и запусти установку снова — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
[[ "$(disk_mod_ver)" == 3.* ]] && \
warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1."
elif kernel_lt_5_5; then elif kernel_lt_5_5; then
AWG3="n" AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then
fail "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210).
Обнови ядро — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
warn "Ставим сервер на AmneziaWG 2.0." warn "Ставим сервер на AmneziaWG 2.0."
fi fi
fi
if [[ "$AWG3" == "y" ]]; then if [[ "$AWG3" == "y" ]]; then
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)" ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
else else
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль и переустанови, чтобы перейти на 3.1." warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1"
warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна."
fi fi
step "2/7 Node.js" step "2/7 Node.js"
@@ -557,12 +789,16 @@ 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 \ # Свой файл в /etc/sysctl.d, а не /etc/sysctl.conf: в Debian 13 systemd-sysctl
|| echo 'net.ipv4.ip_forward=1' >> /etc/sysctl.conf # /etc/sysctl.conf при загрузке больше не читает — forwarding включился бы
grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \ # только до первой перезагрузки, и клиенты остались бы без интернета.
|| echo 'net.ipv6.conf.all.forwarding=1' >> /etc/sysctl.conf SYSCTL_FILE="/etc/sysctl.d/99-awg-control.conf"
sysctl -qp cat > "$SYSCTL_FILE" <<'SYSCTL'
ok "IP forwarding включён" 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), чтобы сервер и клиент не расходились.
@@ -637,7 +873,44 @@ if awg show "$IFACE" &>/dev/null; then
awg-quick down "$IFACE" 2>/dev/null || true awg-quick down "$IFACE" 2>/dev/null || true
fi fi
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой" # Подъём интерфейса — единственная настоящая проверка, что ядро приняло
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
if ! awg-quick up "$IFACE"; then
ip link delete dev "$IFACE" 2>/dev/null || true
if [[ "$AWG3" != "y" ]]; then
fail "awg-quick up $IFACE завершился с ошибкой"
fi
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
if awg-quick up "$IFACE"; then
ok "Со второй попытки интерфейс поднялся"
else
ip link delete dev "$IFACE" 2>/dev/null || true
echo " ── dmesg (последние 15 строк) ─────────────────"
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
echo " ───────────────────────────────────────────────"
cp "$AWG_CONF" "${AWG_CONF}.awg31"
sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF"
AWG3="n"; NEW_GEN="2.0"
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
[[ "$AWG_GEN" == "3.1" ]] && \
warn "Модуль и ядро подходят под 3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше."
warn "Разберись с причиной и повтори переход кнопкой «Перейти на AWG 3.1» в панели."
awg-quick up "$IFACE" \
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
ok "Интерфейс поднят на AmneziaWG 2.0"
fi
fi
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp # awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не # (наблюдалось: awg show public-key = none сразу после up). Если ключ не
@@ -655,6 +928,37 @@ else
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY" fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
fi fi
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
if [[ "$AWG_PIN" == "y" ]]; then
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
fi
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
echo " → UFW активен, открываем маршрут и порты"
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
ufw reload >/dev/null 2>&1 || true
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
else
echo " → UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install" step "6/7 npm install"
npm_install_all npm_install_all
@@ -691,6 +995,11 @@ UI_PASS=${UI_PASS}
JWT_SECRET=${JWT_SECRET} JWT_SECRET=${JWT_SECRET}
ENV ENV
# Имя продукта попадает в cli.env, только если оператор задал его явно
# (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, —
# чтобы источник правды оставался один.
[[ -n "$BRAND_OVERRIDE" ]] && echo "BRAND=${BRAND_OVERRIDE}" >> "$PROJECT/cli/cli.env"
chmod 600 "$PROJECT/cli/cli.env" chmod 600 "$PROJECT/cli/cli.env"
ok "$PROJECT/cli/cli.env" ok "$PROJECT/cli/cli.env"