13 Commits
Author SHA1 Message Date
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
maeneko af5c0e1b43 Publish releases as prereleases
The project is still Alpha, so a v* tag should not become the repo's latest
stable release.
2026-08-17 18:06:59 +03:00
maeneko 01d8524f8c Bump version to 0.1.4
install.sh drives the release URL and tarball name off VERSION, so the
matching tag is v0.1.4. awg-ctrl/package.json was still on the default 1.0.0
and is now in step with cli and awg-ui.
2026-08-17 18:01:35 +03:00
maeneko 44c637f7af Add AmneziaWG 3.1 support with key reissue
Protocol generation is now derived from awg1.conf rather than stored: an
interface speaks 3.1 when HeaderProtectionKey is set and 2.0 otherwise. The
3.0/3.1-only keys (HeaderProtectionKey, ContentPaddingAddition, the
Rekey/Reject/Keepalive timers, MaxHandshakeAttempts, RandomTrailers,
DisableCookies) are read from the conf and emitted only when non-empty, so 2.0
output stays byte-identical — verified by diffing both the client .conf and the
full vpn:// string against the previous implementation.

Changing generation invalidates every issued vpn:// key, so add
POST /api/users/reissue. It recovers the client private key from inside the
stored blob (the only place it exists), keeping ip/pub_key/psk_key intact, so
nothing changes on the wire and users only need to re-import. users gains
key_gen and vpn_key_prev, and users.db is snapshotted before the pass.

install.sh gates 3.1 on module 3.x and kernel >= 5.5 (header protection needs
the chacha library API, absent before 5.5) and falls back to writing a 2.0 conf
instead of aborting, so older kernels keep working as before.
HeaderProtectionKey is generated with awg genpsk and preserved on KEEP_DATA.

The panel marks keys issued on a different generation and offers to reissue
them; /api/v1 now returns gen alongside name/ip/vpn_key.

Also add MIT license headers across the awg-ui sources.
2026-08-17 17:58:00 +03:00
28 changed files with 1233 additions and 570 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.0
+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
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release
@@ -37,4 +37,4 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
+54 -100
View File
@@ -9,7 +9,7 @@
| Компонент | Роль |
|---|---|
| **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. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
@@ -28,8 +28,60 @@
На сервере, от root:
```bash
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh)
bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
```bash
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
```
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
### Свой источник и своё имя
В коде не зашиты ни репозиторий, ни бренд — всё задаётся окружением до запуска:
| Переменная | По умолчанию | Зачем |
|---|---|---|
| `REPO_BASE` | `https://git.ma7neko.ru/maeneko/forgetting` | основной источник архива релиза |
| `REPO_FALLBACK` | `https://github.com/maeneko/forgetting` | запасной, если в основном релиза ещё нет |
| `ARCHIVE_URL` | — | полный URL архива, перекрывает оба варианта |
| `BRAND` | из `.env` | имя продукта в баннерах, systemd-юните и панели |
Путь релиза у Gitea и GitHub одинаковый (`<repo>/releases/download/v<версия>/awgcontrol-<версия>.tar.gz`),
так что подходит любой из них. Если архив не найден в основном источнике,
установщик предупреждает и берёт его из запасного.
Архив собирается по тегу `v*`: на GitHub — `.github/workflows/release.yml`, в
Gitea — `.gitea/workflows/release.yml` (сборка та же, публикация через API
Gitea). Для запуска в Gitea репозиторий не должен быть pull-зеркалом, Actions
включены, а runner зарегистрирован с меткой `ubuntu-latest`.
```bash
REPO_BASE=https://git.example.org/me/vpn BRAND="My VPN" \
bash <(curl -Ls https://git.example.org/me/vpn/raw/branch/main/install.sh)
```
Имя панели, канал и версия лежат в `.env` в корне проекта (едет в архиве
релиза, на сервере — `/opt/awg-control/.env`):
```env
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.2.0
```
Оттуда их читают CLI (баннер меню) и панель (вордмарк, `GET /ui/brand`).
`BRAND=` при установке перекрывает значение из файла.
Сама панель и её API к домену не привязаны: работают по адресу того сервера,
куда установлены, внешних сервисов не требуют.
## Управление
```bash
@@ -42,103 +94,5 @@ awg-ctrl
systemctl start|stop|restart awg-control
```
## 1. Публичный API (`/api/v1`)
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные
ключи и PSK наружу не отдаются.
### Авторизация
Каждый запрос обязан содержать заголовок:
```
X-Api-Key: awgk_<...>
```
Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys`
(раздел 3). Открытое значение ключа показывается **один раз** при создании —
сохрани его сразу, дальше доступен только хэш.
Ответы при проблемах с ключом:
| Код | Когда |
|---|---|
| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` |
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) |
> Ключ привязан к серверу (`server_id`). Сейчас сервер один — поле всегда `0`
### `POST /api/v1/users` — создать пользователя
Создаёт VPN-пира и возвращает готовый `vpn://` ключ.
**Тело:** `{ "name": "<имя>" }`
Имя: `a–z A–Z 0–9 _ -`, 1–32 символа.
**`201 Created`:**
```json
{
"name": "alice",
"ip": "10.9.0.2",
"vpn_key": "vpn://AAAI..."
}
```
| Код ошибки | Причина |
|---|---|
| `400` | имя не прошло валидацию |
| `409` | пользователь с таким именем уже есть |
```bash
curl -X POST http://HOST:PORT/api/v1/users \
-H "X-Api-Key: awgk_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"alice"}'
```
### `GET /api/v1/users` — список пользователей
Пиры с трафиком и статусом онлайн.
**`200 OK`:**
```json
{
"users": [
{ "name": "alice", "ip": "10.9.0.2", "online": true, "lastHandshake": 1718800000, "rx": 142400000, "tx": 9800000 },
{ "name": "bob", "ip": "10.9.0.3", "online": false, "lastHandshake": 0, "rx": 0, "tx": 0 }
]
}
```
- `lastHandshake` — unix-секунды последнего рукопожатия (`0` — не было).
- `rx`/`tx` — байты принято/отдано.
```bash
curl http://HOST:PORT/api/v1/users -H "X-Api-Key: awgk_xxx"
```
### `GET /api/v1/users/:name` — получить ключ пользователя
**`200 OK`:**
```json
{ "name": "alice", "ip": "10.9.0.2", "vpn_key": "vpn://AAAI..." }
```
| Код ошибки | Причина |
|---|---|
| `404` | пользователь не найден |
```bash
curl http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
> Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс
> `vpn://`, base64url-декодировать, отбросить первые 4 байта (длина), распаковать
> `zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
### `DELETE /api/v1/users/:name` — удалить пользователя
**`200 OK`:**
```json
{ "success": true, "name": "alice" }
```
```bash
curl -X DELETE http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev
+411 -35
View File
@@ -3,6 +3,7 @@
// LICENSE file in the root directory of this source tree.
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import os from "os";
import crypto from "crypto";
import { execSync, spawnSync } from "child_process";
import * as zlib from "zlib";
@@ -64,6 +65,21 @@ db.exec(`
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)");
// Миграции users: better-sqlite3 синхронный, ALTER TABLE идемпотентным не бывает,
// поэтому смотрим фактический список колонок.
// key_gen — поколение протокола, на параметрах которого выдан vpn_key
// ('2' для всего, что заведено до появления 3.1)
// vpn_key_prev — предыдущий блоб, чтобы перевыпуск можно было откатить
{
const cols = new Set(
(db.prepare("PRAGMA table_info(users)").all() as { name: string }[]).map(c => c.name),
);
if (!cols.has("key_gen"))
db.exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'");
if (!cols.has("vpn_key_prev"))
db.exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''");
}
db.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
@@ -106,12 +122,29 @@ function initConfig() {
return { serverIp, serverPort: Number(serverPort), serverName };
}
interface AwgParams {
// Ключи, появившиеся в AmneziaWG 3.0/3.1. Пустая строка = ключ не задан, тогда
// интерфейс работает в режиме 2.0 и его нет ни в conf, ни в vpn:// ключе.
// Порядок массива = порядок строк в клиентском .conf (см. buildClientConf).
const AWG3_KEYS = [
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
] as const;
type Awg3Key = (typeof AWG3_KEYS)[number];
type AwgParams = {
Jc: number; Jmin: number; Jmax: number;
S1: number; S2: number; S3: number; S4: number;
H1: string; H2: string; H3: string; H4: string;
I1: string; I2: string; I3: string; I4: string; I5: string;
}
} & Record<Awg3Key, string>;
const DEFAULT_AWG_PARAMS: AwgParams = {
Jc: 6, Jmin: 10, Jmax: 50,
@@ -122,6 +155,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = {
H4: "2143656228-2147444225",
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
I2: "", I3: "", I4: "", I5: "",
// 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0.
HeaderProtectionKey: "", ContentPaddingAddition: "",
RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "",
KeepaliveTimeout: "", MaxHandshakeAttempts: "",
RandomTrailers: "", DisableCookies: "",
};
function readAwgParams(): AwgParams {
@@ -134,7 +172,9 @@ function readAwgParams(): AwgParams {
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"];
const strKeys: (keyof AwgParams)[] = ["H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
const strKeys: (keyof AwgParams)[] = [
"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5", ...AWG3_KEYS,
];
for (const k of numKeys) {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
@@ -144,11 +184,69 @@ function readAwgParams(): AwgParams {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"));
if (m) (params[k] as string) = m[1].trim();
}
logger.info("awg params loaded from conf", { Jc: params.Jc, H1: params.H1 });
logger.info("awg params loaded from conf", {
Jc: params.Jc, H1: params.H1, gen: genOf(params),
});
return params;
}
// Поколение протокола выводим из самих параметров, отдельного флага нет: conf
// остаётся единственным источником правды. Header protection — та фича, которая
// ломает совместимость с 2.0, поэтому именно она и определяет поколение.
function genOf(p: AwgParams): "2" | "3.1" {
return p.HeaderProtectionKey ? "3.1" : "2";
}
// Версия модуля/tools для /health — показывает оператору, что реально работает
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
// это единственные моменты, когда модуль может реально смениться в течение
// жизни процесса; из обработчика /health не вызывается — этот роут без
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
function readAwgVersions(): { module: string; tools: string } {
let module = "";
try {
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
} catch {
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
}
let tools = "";
try {
const out = run("awg --version");
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
} catch { /* awg-tools не найден */ }
return { module, tools };
}
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
// Значения 3.1-параметров для перевода уже работающего 2.0-сервера (POST
// /awg/upgrade) — те же, что пишет install.sh (дефолты клиента AmneziaVPN,
// protocolConstants.h), в порядке AWG3_KEYS. RandomTrailers/DisableCookies не
// включаем — как и инсталлятор. HeaderProtectionKey сюда не входит: он
// генерируется на месте (awg genpsk) и фиксируется на весь срок жизни сервера.
const AWG31_DEFAULTS: Record<string, string> = {
ContentPaddingAddition: "10-100",
RekeyAfterTime: "100-120",
RekeyTimeout: "3-7",
RejectAfterTime: "150-180",
KeepaliveTimeout: "5-15",
MaxHandshakeAttempts: "15-20",
};
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12
// (HEADER_PROTECTION_NONCE_SIZE). Модуль на нарушение отвечает только
// «Invalid argument»; причина видна лишь при
// `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.
const MIN_S_FOR_HEADER_PROTECTION = 12;
const runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS);
const AWG_VERSIONS = readAwgVersions();
const CONFIG = {
interface: "awg1",
confDir: "/etc/amnezia/amneziawg",
@@ -159,23 +257,36 @@ const CONFIG = {
dns1: "1.1.1.1",
dns2: "1.0.0.1",
mtu: 1376,
keepalive: 25,
awgParams: readAwgParams(),
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: AWG_PARAMS,
gen: AWG_GEN,
// Мутируются в restartAwg() — единственном месте, где модуль может
// реально смениться в течение жизни процесса.
awgModule: AWG_VERSIONS.module,
awgTools: AWG_VERSIONS.tools,
};
interface UserRow {
name: string;
ip: string;
pub_key: string;
vpn_key: string;
psk_key: string;
name: string;
ip: string;
pub_key: string;
vpn_key: string;
psk_key: string;
key_gen: string;
vpn_key_prev: string;
}
const stmts = {
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
insert: db.prepare<[string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key) VALUES (?, ?, ?, ?, ?)"),
all: db.prepare<[], UserRow>("SELECT * FROM users"),
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
// смениться, если исходный ключ не удалось разобрать.
reissue: db.prepare<[string, string, string, string, string]>(
"UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
),
};
function run(cmd: string): string {
@@ -207,7 +318,10 @@ function nextIp(): string {
throw new Error("Подсеть заполнена");
}
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → 3.x-ключи →
// Address → DNS. Порядок 3.x-блока взят из client/server_scripts/awg/template.conf
// клиента AmneziaVPN; пустые ключи не выводятся вовсе — тогда конфиг остаётся
// ровно тем же 2.0-конфигом, что и до появления поддержки 3.1.
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
@@ -217,6 +331,7 @@ function buildClientConf(
serverPub: string,
): string {
const p = CONFIG.awgParams;
const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join("");
return `[Interface]
PrivateKey = ${keys.privateKey}
Jc = ${p.Jc}
@@ -235,7 +350,7 @@ I2 =${" "}
I3 =${" "}
I4 =${" "}
I5 =${" "}
Address = ${ip}/32
${awg3}Address = ${ip}/32
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
[Peer]
@@ -255,12 +370,29 @@ function encodeVpnKey(
const p = CONFIG.awgParams;
const clientConf = buildClientConf(keys, ip, serverPub);
const lastConfigObj = {
// Ключи в объектах идут в том же ASCII-алфавитном порядке, в каком их
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи
// выбрасываются в dropEmptyAwg3 — на 2.0 объекты остаются прежними байт-в-байт.
const dropEmptyAwg3 = <T extends Record<string, unknown>>(o: T): T => {
for (const k of AWG3_KEYS) if (!p[k]) delete o[k];
return o;
};
const lastConfigObj = dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4),
allowed_ips: ["0.0.0.0/0", "::/0"],
clientId: keys.publicKey,
@@ -274,25 +406,34 @@ function encodeVpnKey(
port: CONFIG.serverPort,
psk_key: keys.presharedKey,
server_pub_key: serverPub,
};
});
const json = JSON.stringify({
containers: [{
container: "amnezia-awg2",
awg: {
awg: dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2),
S3: String(p.S3), S4: String(p.S4),
last_config: JSON.stringify(lastConfigObj, null, 2),
port: String(CONFIG.serverPort),
protocol_version: "2",
protocol_version: CONFIG.gen,
subnet_address: `${CONFIG.subnet}.0.0`,
transport_proto: "udp",
},
}),
}],
defaultContainer: "amnezia-awg2",
description: CONFIG.serverName,
@@ -311,6 +452,77 @@ function encodeVpnKey(
.replace(/=+$/, "");
}
// Обратная к encodeVpnKey: vpn:// → base64url → снять 4-байтовый BE-заголовок
// длины → inflate → JSON. Приватный ключ клиента больше нигде не хранится, поэтому
// это единственный способ перевыпустить ключ, не меняя личность пира.
function decodeVpnKey(vpnKey: string): any | null {
try {
const buf = Buffer.from(vpnKey.replace(/^vpn:\/\//, ""), "base64url");
if (buf.length <= 4) return null;
return JSON.parse(zlib.inflateSync(buf.subarray(4)).toString("utf8"));
} catch {
return null;
}
}
function clientPrivKeyFrom(vpnKey: string): string | null {
const lastConfig = decodeVpnKey(vpnKey)?.containers?.[0]?.awg?.last_config;
if (typeof lastConfig !== "string") return null;
try {
const priv = JSON.parse(lastConfig).client_priv_key;
return typeof priv === "string" && priv ? priv : null;
} catch {
return null;
}
}
interface ReissueResult { total: number; reissued: number; regenerated: string[]; backup: string }
// Перевыпуск всех vpn:// ключей на текущих параметрах интерфейса. Нужен после
// смены поколения (2.0 → 3.1): старые ключи собраны на старых параметрах и
// перестают работать. IP, pub_key и psk_key сохраняются — на проводе ничего не
// меняется, клиенту достаточно заново импортировать ключ.
function reissueAll(): ReissueResult {
const users = stmts.all.all();
const serverPub = getServerPublicKey();
const backup = `${dbPath}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
db.prepare("VACUUM INTO ?").run(backup);
logger.info("reissue: db backed up", { backup, users: users.length });
const regenerated: string[] = [];
let reissued = 0;
for (const u of users) {
const priv = clientPrivKeyFrom(u.vpn_key);
let keys: ReturnType<typeof generateKeys>;
if (priv) {
keys = { privateKey: priv, publicKey: u.pub_key, presharedKey: u.psk_key };
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
logger.warn("reissue: vpn_key не декодируется, генерируем новую пару", { name: u.name });
keys = generateKeys();
spawnSync("awg", ["set", CONFIG.interface, "peer", u.pub_key, "remove"]);
const r = setPeer(keys.publicKey, keys.presharedKey, u.ip);
if (r.status !== 0) {
logger.error("reissue: awg set failed", { name: u.name, stderr: r.stderr?.toString() });
continue;
}
regenerated.push(u.name);
}
stmts.reissue.run(
encodeVpnKey(keys, u.ip, serverPub),
keys.publicKey, keys.presharedKey, CONFIG.gen, u.name,
);
reissued++;
}
syncPeers();
logger.info("reissue: done", { total: users.length, reissued, regenerated: regenerated.length });
return { total: users.length, reissued, regenerated, backup };
}
function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
try {
const output = run(`awg show ${CONFIG.interface} dump`);
@@ -410,6 +622,17 @@ function ensureInterfaceUp() {
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
}
// Параметры обфускации и поколение живут в awg1.conf, а не в процессе:
// перечитываем их после любой правки конфига. Иначе awg-ctrl продолжит
// собирать vpn:// ключи на старых параметрах — например, на 2.0 уже после
// перехода интерфейса на 3.1.
function reloadAwgParams(): "2" | "3.1" {
CONFIG.awgParams = readAwgParams();
CONFIG.gen = genOf(CONFIG.awgParams);
CONFIG.keepalive = KEEPALIVE_BY_GEN[CONFIG.gen];
return CONFIG.gen;
}
function restartAwg() {
logger.info("AWG restart: down");
const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
@@ -417,8 +640,126 @@ function restartAwg() {
logger.info("AWG restart: up");
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная
// правка awg1.conf) — перечитываем ДО syncPeers: keepalive из CONFIG уходит
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams();
syncPeers();
logger.info("AWG restart: done");
const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
logger.info("AWG restart: done", { ...versions, gen });
}
// ─────────────────────────── переход 2.0 → 3.1 ───────────────────────────
// 3.1 — основное поколение (install.sh ставит его по умолчанию). Сервера,
// установленные раньше, переводятся отсюда, без переустановки: панель дёргает
// POST /awg/upgrade.
// Header protection использует библиотечный chacha-API (chacha_init/
// chacha20_crypt), которого нет до ядра 5.5 — там модуль 3.x просто не
// соберётся (upstream issue #210).
function kernelSupportsHeaderProtection(): boolean {
const [maj, min] = os.release().split(".").map(n => Number.parseInt(n, 10));
if (!Number.isFinite(maj) || !Number.isFinite(min)) return false;
return maj > 5 || (maj === 5 && min >= 5);
}
// Пред-проверка перед тем, как трогать конфиг: решает ЗАГРУЖЕННЫЙ модуль
// (readAwgVersions читает /sys/module/amneziawg/version), а не тот, что лежит
// на диске после apt — обслуживает интерфейс именно загруженный.
function checkGen31Support(): string | null {
const { module } = readAwgVersions();
if (!module.startsWith("3."))
return `В ядре загружен модуль amneziawg ${module || "неизвестной версии"} — для 3.1 нужна 3.x. `
+ "Обнови пакет (apt) и перезагрузи сервер, затем повтори переход.";
if (!kernelSupportsHeaderProtection())
return `Ядро ${os.release()} старше 5.5 — header protection в нём не работает (upstream issue #210).`;
return null;
}
const AWG3_KEY_LINE = new RegExp(`^\\s*(${AWG3_KEYS.join("|")})\\s*=`);
// Готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и
// дописывает 3.x-блок в конец [Interface]. [Peer]-блоки не трогаются —
// личности пиров переход не меняет.
function confWithGen31(conf: string, headerKey: string): { conf: string; bumpedS: string[] } {
const peerAt = conf.search(/^\[Peer\]/m);
let rest = peerAt >= 0 ? conf.slice(peerAt) : "";
const head = (peerAt >= 0 ? conf.slice(0, peerAt) : conf)
.split("\n")
.filter(l => !AWG3_KEY_LINE.test(l));
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому
// [Peer] (их пишет rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь
// [Interface], а не между комментарием и его пиром.
const tail: string[] = [];
while (head.length && (head[head.length - 1].trim() === "" || head[head.length - 1].trim().startsWith("#")))
tail.unshift(head.pop()!);
rest = tail.join("\n").trim() ? `${tail.join("\n").trim()}\n${rest}` : rest;
let iface = head.join("\n");
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет.
// Правка безопасна ровно потому, что следом идёт перевыпуск ключей: обе
// стороны получают новые значения одновременно.
const bumpedS: string[] = [];
for (const k of ["S1", "S2", "S3", "S4"] as const) {
const re = new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)\\s*$`, "m");
const cur = Number(iface.match(re)?.[1]);
if (Number.isFinite(cur) && cur >= MIN_S_FOR_HEADER_PROTECTION) continue;
const val = Math.max(DEFAULT_AWG_PARAMS[k], MIN_S_FOR_HEADER_PROTECTION);
iface = re.test(iface)
? iface.replace(re, `${k} = ${val}`)
: `${iface.trimEnd()}\n${k} = ${val}`;
bumpedS.push(`${k}: ${Number.isFinite(cur) ? cur : "нет"} → ${val}`);
}
const awg3 = [
`HeaderProtectionKey = ${headerKey}`,
...Object.entries(AWG31_DEFAULTS).map(([k, v]) => `${k} = ${v}`),
].join("\n");
return { conf: `${iface.trimEnd()}\n${awg3}\n${rest ? `\n${rest}` : ""}`, bumpedS };
}
interface UpgradeResult { gen: "2" | "3.1"; backup: string; bumpedS: string[]; reissue: ReissueResult }
// Перевод интерфейса на 3.1. Единственная настоящая проверка, что ядро приняло
// параметры, — подъём интерфейса, поэтому шаги такие: бэкап конфига → запись
// 3.1 → restartAwg() (он же перечитает параметры) → перевыпуск ключей. Если
// ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
function upgradeToGen31(): UpgradeResult {
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
const before = readFileSync(confFile, "utf8");
const backup = `${confFile}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
writeFileSync(backup, before, { mode: 0o600 });
const { conf, bumpedS } = confWithGen31(before, run("awg genpsk"));
writeFileSync(confFile, conf, { mode: 0o600 });
logger.info("upgrade: conf rewritten for 3.1", { backup, bumpedS });
try {
restartAwg();
if (CONFIG.gen !== "3.1") throw new Error("интерфейс поднялся, но конфиг всё ещё 2.0");
} catch (e) {
logger.error("upgrade: kernel rejected 3.1, rolling back", { error: e });
writeFileSync(confFile, before, { mode: 0o600 });
try { restartAwg(); } catch (e2) { logger.error("upgrade: rollback restart failed", { error: e2 }); }
throw new Error(
`Ядро не приняло конфиг 3.1 — вернули прежний (копия: ${backup}). `
+ 'Причина видна в dmesg после `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.',
);
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся,
// поэтому перевыпускаем сразу — сервер не должен оставаться в состоянии
// «3.1 поднят, у всех нерабочие ключи». Личности пиров сохраняются.
const reissue = reissueAll();
logger.info("upgrade: done", { gen: CONFIG.gen, reissued: reissue.reissued, bumpedS });
return { gen: CONFIG.gen, backup, bumpedS, reissue };
}
function startInterface() {
@@ -430,36 +771,47 @@ function startInterface() {
return getInterfaceStatus();
}
// PSK уходит во временный файл, а не в аргументы: иначе он виден в /proc/<pid>/cmdline.
function setPeer(pubKey: string, psk: string, ip: string) {
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
writeFileSync(tmpPsk, psk, { mode: 0o600 });
try {
return spawnSync("awg", [
"set", CONFIG.interface, "peer", pubKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", CONFIG.keepalive,
]);
} finally {
try { fs.unlinkSync(tmpPsk); } catch {}
}
}
function addUser(username: string): UserRow {
const keys = generateKeys();
const serverPub = getServerPublicKey();
const ip = db.transaction(() => {
const ip = nextIp();
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey);
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey, CONFIG.gen);
return ip;
})();
const vpn_key = encodeVpnKey(keys, ip, serverPub);
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
writeFileSync(tmpPsk, keys.presharedKey);
const r = spawnSync("awg", [
"set", CONFIG.interface, "peer", keys.publicKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", String(CONFIG.keepalive),
]);
try { fs.unlinkSync(tmpPsk); } catch {}
const r = setPeer(keys.publicKey, keys.presharedKey, ip);
if (r.status !== 0) {
stmts.delete.run(username);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
rebuildConf();
logger.info("user created", { name: username, ip });
return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey };
logger.info("user created", { name: username, ip, gen: CONFIG.gen });
return {
name: username, ip, pub_key: keys.publicKey, vpn_key,
psk_key: keys.presharedKey, key_gen: CONFIG.gen, vpn_key_prev: "",
};
}
function removeUser(username: string) {
@@ -521,7 +873,8 @@ app.get("/health", (_req, res) => {
status: up ? "ok" : "degraded",
server: CONFIG.serverName,
ip: CONFIG.serverIp,
awg: { status: up ? "ok" : "down", peers },
gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
});
});
@@ -534,7 +887,7 @@ app.post("/api/users", auth, validateName, handler((req, res) => {
}));
app.get("/api/users", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key, vpn_key FROM users WHERE vpn_key != ''").all() as UserRow[];
const users = db.prepare("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData();
res.json({
users: users.map(u => ({
@@ -546,12 +899,13 @@ app.get("/api/users", auth, handler((_req, res) => {
}));
app.get("/api/users/stats", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[];
const users = db.prepare("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData();
res.json({
users: users.map(u => ({
name: u.name,
ip: u.ip,
key_gen: u.key_gen,
online: peers[u.pub_key]?.online ?? false,
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0,
@@ -560,6 +914,12 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
});
}));
// ⚠️ Должен быть объявлен ДО «/api/users/:name», иначе тот перехватит «reissue»
// как имя пользователя.
app.post("/api/users/reissue", auth, handler((_req, res) => {
res.json(reissueAll());
}));
app.post("/api/users/:name", auth, validateName, handler((req, res) => {
const user = stmts.get.get(req.params.name);
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
@@ -576,6 +936,22 @@ app.post("/awg/restart", auth, handler((_req, res) => {
res.json({ success: true });
}));
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей.
// Ошибку отдаём текстом: панель показывает её оператору, а причины отказа
// (старый модуль, старое ядро, отказ ядра на подъёме) требуют разных действий.
app.post("/awg/upgrade", auth, handler((_req, res) => {
if (CONFIG.gen === "3.1") {
res.status(409).json({ error: "Сервер уже работает на AmneziaWG 3.1" }); return;
}
const blocker = checkGen31Support();
if (blocker) { res.status(409).json({ error: blocker }); return; }
try {
res.json(upgradeToGen31());
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : "Не удалось перейти на 3.1" });
}
}));
app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus());
}));
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "1.0.0",
"version": "0.2.0",
"description": "",
"main": "dist/index.js",
"scripts": {
+1 -1
View File
@@ -9,6 +9,6 @@
"outDir": "dist"
},
"include": [
""
"index.ts"
]
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.1.3+1",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -12,14 +12,14 @@
},
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^12.10.0",
"jsonwebtoken": "^9.0.0",
"qrcode": "^1.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.0",
"@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
+18 -105
View File
@@ -6,7 +6,6 @@ import path from "path";
import fs from "fs";
import crypto from "crypto";
import axios from "axios";
import Database from "better-sqlite3";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -16,6 +15,20 @@ const UI_PASS = process.env.UI_PASS ?? "";
const JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
// Имя панели, канал и версия. В проде их прокидывает CLI (он читает .env в
// корне проекта и cli.env); при самостоятельном запуске `npm run server`
// читаем тот же .env сами, чтобы дев-режим показывал то же, что прод.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const ROOT_ENV = path.join(__dirname, "..", ".env");
const BRAND = process.env.BRAND || envFromFile(ROOT_ENV, "BRAND") || "Forgetting";
const CHANNEL = process.env.CHANNEL || envFromFile(ROOT_ENV, "CHANNEL") || "Beta";
const VERSION = process.env.VERSION || envFromFile(ROOT_ENV, "VERSION") || "";
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
?? path.join(__dirname, "internal_auth_private.key");
let internalAuthKey: crypto.KeyObject | null = null;
@@ -37,43 +50,6 @@ function mintInternalToken(): string {
const revoked = new Set<string>();
interface ApiKeyRow {
id: number; label: string; key_hash: string; prefix: string;
server_id: number; created_at: number; last_used: number | null;
}
const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db");
const uidb = new Database(UI_DB_FILE);
uidb.pragma("journal_mode = WAL");
uidb.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
function genApiKey(): { key: string; hash: string; prefix: string } {
const key = "awgk_" + crypto.randomBytes(24).toString("base64url");
return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" };
}
function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
function sign(): string {
@@ -146,27 +122,10 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true });
});
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() });
});
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id);
res.json({ success: true, id });
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
// до выдачи JWT. Наружу уходит только имя продукта и версия.
app.get("/ui/brand", (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
});
async function proxy(req: Request, res: Response) {
@@ -184,22 +143,6 @@ async function proxy(req: Request, res: Response) {
}
}
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? "";
if (!key.startsWith("awgk_")) { res.status(401).json({ error: "API key required" }); return; }
const row = keyStmts.byHash.get(hashKey(key)) as ApiKeyRow | undefined;
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row;
next();
}
async function ctrl(method: string, urlPath: string, body?: unknown) {
return axios({
method,
@@ -210,36 +153,6 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
});
}
const ext = express.Router();
ext.use(requireApiKey);
ext.post("/users", async (req: Request, res: Response) => {
const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string };
res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
});
ext.get("/users", async (_req: Request, res: Response) => {
const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data);
});
ext.get("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string };
res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
});
ext.delete("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
});
app.use("/api/v1", ext);
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist")));
+11 -20
View File
@@ -352,6 +352,8 @@ body {
.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); }
.chip--error { background: var(--error-bg); color: var(--error-text); }
.chip--error .chip-dot { background: var(--error-dot); }
.main {
flex: 1;
@@ -404,6 +406,13 @@ body {
color: var(--neutral-text);
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 {
font-size: 12px;
color: var(--neutral-text);
@@ -464,22 +473,6 @@ body {
}
.tip-wrap:hover::after { opacity: 1; }
.server-add {
display: inline-flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 16px;
border-radius: var(--r-pill);
border: 1.5px dashed var(--outline);
background: transparent;
font-family: var(--font);
font-size: 13px;
color: var(--neutral-dot);
cursor: not-allowed;
white-space: nowrap;
}
.toolbar {
display: flex;
align-items: center;
@@ -664,11 +657,9 @@ tbody td:last-child { padding-right: 20px; }
.server-toggle { display: flex; }
.server-online-chip { display: none; }
/* Кнопка «Добавить сервер» — полная ширина */
.tip-wrap { width: 100%; }
.server-add { width: 100%; justify-content: center; }
.tip-wrap { width: 100%; }
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
/* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
.awg-restart { display: none; }
.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 axios from 'axios';
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 ServerBar from './components/ServerBar';
import { TABS } from './tabs';
@@ -22,6 +22,7 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0);
@@ -36,7 +37,10 @@ export default function App() {
// вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 });
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 ?? '?'));
}, []);
@@ -107,6 +111,18 @@ export default function App() {
}
}, [startSession]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
channel: data.channel || '',
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
// Применять и сохранять тему
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
@@ -160,7 +176,7 @@ export default function App() {
{!token ? (
<div className="login-screen">
<div className="login-card">
<span className="logo-name">Forgetting</span>
<span className="logo-name">{brand.brand}</span>
<p className="login-sub">Войдите, чтобы продолжить</p>
<input
className="field"
@@ -213,8 +229,10 @@ export default function App() {
onTouchEnd={handleTouchEnd}
>
<div className="sidebar-logo">
<span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.3.1</span>
<span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">
{[brand.channel, brand.version].filter(Boolean).join(' ')}
</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
@@ -243,7 +261,6 @@ export default function App() {
serverOnline={isOnline}
onRestartAwg={restartAwg}
restarting={restarting}
showAddServer={chrome.addServer !== false}
showRestart={showRestart}
/>
)}
+19 -17
View File
@@ -1,21 +1,20 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoRefresh } from './icons';
import { type ServerInfo } from '../lib/shared';
import { IcoPlus, IcoRefresh } from './icons';
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает)
// сервер, к которому относится содержимое вкладки (пиры / API-ключи).
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
// станет несколько — карточки станут кликабельными, активная = выбранная.
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting,
showAddServer = true, showRestart = true,
showRestart = true,
}: {
serverInfo: ServerInfo | null;
serverOnline: boolean;
onRestartAwg: () => void;
restarting: boolean;
showAddServer?: boolean;
showRestart?: boolean;
}) {
return (
@@ -36,17 +35,20 @@ export default function ServerBar({
<div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</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>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
{showAddServer && (
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
<button className="server-add" disabled>
<IcoPlus /> Добавить сервер
</button>
</span>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && (
<button
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
+18 -8
View File
@@ -1,10 +1,16 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Общие типы, константы и утилиты для всех вкладок панели.
import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1';
export interface User {
name: string;
ip: string;
pub_key: string;
vpn_key: string;
key_gen: AwgGen;
online: boolean;
lastHandshake: number;
rx?: number;
@@ -14,19 +20,23 @@ export interface ServerInfo {
name: string;
ip: string;
peers: number;
gen: AwgGen;
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
tools: string; // версия awg-tools, из /health awg.tools
}
export interface ApiKey {
id: number;
label: string;
prefix: string;
server_id: number;
created_at: number;
last_used: number | null;
}
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
export interface PageProps {
token: string;
showMsg: (text: string) => void;
}
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
// Эта константа — фолбэк на время загрузки и при недоступности роута.
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
-72
View File
@@ -1,72 +0,0 @@
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
-145
View File
@@ -1,145 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
+3 -1
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
@@ -15,7 +18,6 @@ import { ICONS } from '../components/icons';
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
export interface TabChrome {
serverBar?: boolean;
addServer?: boolean;
restart?: boolean;
}
+83 -4
View File
@@ -1,8 +1,11 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
type User, type PageProps,
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen,
} from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import './users.css';
@@ -13,7 +16,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const [users, setUsers] = useState<User[]>([]);
const [newName, setNewName] = useState('');
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2');
const [upgrading, setUpgrading] = useState(false);
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen);
const loadStats = useCallback(async (tok: string) => {
try {
@@ -27,14 +35,56 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const loadUsers = useCallback(async (tok: string) => {
try {
const data = await apiFetch('GET', '/api/users', tok);
const [data, health] = await Promise.all([
apiFetch('GET', '/api/users', tok),
apiFetch('GET', '/health', tok),
]);
setUsers(data.users ?? []);
setServerGen(health.gen ?? '2');
await loadStats(tok);
} catch {
showMsg('Ошибка загрузки');
}
}, [loadStats, showMsg]);
// Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса.
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => {
if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return;
try {
const r = await apiFetch('POST', '/api/users/reissue', token);
showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`);
await loadUsers(token);
} catch {
showMsg('Не удалось перевыпустить ключи');
}
}, [stale.length, serverGen, token, loadUsers, showMsg]);
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
// их не примет) и сразу перевыпускает ключи — старые собраны на
// 2.0-параметрах и работать не будут.
const upgradeGen = useCallback(async () => {
if (!confirm(
'Перевести сервер на AmneziaWG 3.1?\n\n' +
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
'клиентам придётся импортировать ключ заново.',
)) return;
setUpgrading(true);
try {
const r = await apiFetch('POST', '/awg/upgrade', token);
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
await loadUsers(token);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
} finally {
setUpgrading(false);
}
}, [token, loadUsers, showMsg]);
const createUser = useCallback(async () => {
if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName });
@@ -93,6 +143,16 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить
</button>
{serverGen !== '3.1' && (
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
</button>
)}
{stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length})
</button>
)}
</div>
</div>
@@ -125,7 +185,20 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</td>
<td>{u.name}</td>
<td>
{u.name}
{u.key_gen !== serverGen && (
<span
className="tip-wrap user-gen"
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
>
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
</span>
)}
</td>
<td className="td-mono">{u.ip}</td>
<td>{bytes(u.rx)}</td>
<td>{bytes(u.tx)}</td>
@@ -159,6 +232,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-title">
<span className="user-card-num">#{i + 1}</span>
<span className="user-card-name">{u.name}</span>
{u.key_gen !== serverGen && (
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
)}
</div>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
+10
View File
@@ -1,3 +1,8 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
@@ -64,7 +69,12 @@
.user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; }
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
@media (max-width: 640px) {
.user-cards-list { display: flex; }
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
+3
View File
@@ -1 +1,4 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
/// <reference types="vite/client" />
+4
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
@@ -6,6 +9,7 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080',
'/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.1.3+1",
"version": "0.2.0",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+53 -12
View File
@@ -15,19 +15,31 @@ const LOGS = path.join(ROOT, "logs");
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
const envFile = path.join(__dirname, "..", "cli.env");
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, "utf8").split("\n")) {
// Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
// реальное окружение → cli.env (машинные настройки, пишет install.sh) →
// .env в корне проекта (имя панели и версия, едет в архиве релиза).
function loadEnvFile(file: string) {
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const idx = t.indexOf("=");
if (idx === -1) continue;
const k = t.slice(0, idx).trim();
const v = t.slice(idx + 1).trim();
process.env[k] ??= v;
process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
}
}
const envFile = path.join(__dirname, "..", "cli.env"); // машинные настройки
loadEnvFile(envFile);
loadEnvFile(path.join(ROOT, ".env")); // имя панели и версия
// Имя панели, канал и версия — из .env (или из cli.env, если оператор задал
// своё имя при установке). В коде только запасные значения на случай, когда
// .env не доехал.
const BRAND = process.env.BRAND || "Forgetting";
const CHANNEL = process.env.CHANNEL || "Beta";
const VERSION = process.env.VERSION || "";
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const C = {
@@ -71,11 +83,13 @@ const SERVICES = {
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
// Панель показывает их в вордмарке (GET /ui/brand).
BRAND: BRAND,
CHANNEL: CHANNEL,
VERSION: VERSION,
UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "",
// ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
},
pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"),
@@ -196,7 +210,27 @@ async function restart(name: SvcName) {
await start(name);
}
function status() {
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
// молча даёт null — health-строка в status() тогда просто не печатается.
interface HealthBody {
gen?: string;
awg?: { module?: string; tools?: string };
}
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
return new Promise(resolve => {
const req = http.get(url, res => {
let body = "";
res.on("data", (c: Buffer) => { body += c; });
res.on("end", () => {
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
});
});
req.on("error", () => resolve(null));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
});
}
async function status() {
console.log("");
for (const name of ALL) {
const running = isRunning(name);
@@ -205,6 +239,13 @@ function status() {
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
}
if (isRunning("awgctrl")) {
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
if (h?.gen) {
const gen = h.gen === "3.1" ? "3.1" : "2.0";
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
}
}
console.log("");
}
@@ -287,7 +328,7 @@ async function interactiveMenu() {
const printMenu = () => {
console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.3.1 ──${C.reset}`)}\n`);
console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
for (const name of ALL) {
const running = isRunning(name);
@@ -321,7 +362,7 @@ async function interactiveMenu() {
case "1": await startAll(); break;
case "2": await stopAll(); break;
case "3": await restartAll(); break;
case "4": status(); break;
case "4": await status(); break;
case "5": await setCredentials("user"); break;
case "6": await setCredentials("pass"); break;
case "0": rl.close(); process.exit(0);
@@ -350,7 +391,7 @@ async function main() {
case "start": await startAll(); break;
case "stop": await stopAll(); break;
case "restart": await restartAll(); break;
case "status": status(); break;
case "status": await status(); break;
case "credentials":
if (sub === "user") await setCredentials("user");
else if (sub === "pass") await setCredentials("pass");
+313 -31
View File
@@ -19,9 +19,28 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.3.1"
GH_REPO="maeneko/forgetting"
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
# При бампе версии правь оба места.
VERSION="0.2.0"
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"
AMNEZIA_DIR="/etc/amnezia"
AWG_DIR="$AMNEZIA_DIR/amneziawg"
@@ -29,7 +48,6 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key"
PUB_KEY_FILE="$AWG_DIR/server_public.key"
AWG_CONF="$AWG_DIR/awg1.conf"
DB_FILE="$AWG_DIR/users.db"
UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
@@ -43,7 +61,7 @@ MTU="1376"
TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts"
echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}"
echo -e "${BLD}${BRAND} ${CHANNEL} ${VERSION}${NC}"
echo
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
@@ -84,10 +102,17 @@ fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
if ! curl -sS --connect-timeout 8 -o /dev/null "https://github.com" 2>/dev/null; then
NET_OK=0
NET_WHY="нет доступа к github.com — проверь интернет и DNS"
fi
# Проверяем хосты, с которых реально качаем: хватает любого из двух —
# 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
for h in "${NET_HOSTS[@]}"; do
if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi
done
[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS"
# Чеклист: ✓ — пройдено, ✗ — нет.
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
@@ -118,6 +143,10 @@ echo
# Ограничения, которые обязан соблюсти генератор:
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
# - S1, S2: < ~150, в части версий S1 != S2
# - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них
# не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на
# нарушение отвечает только «Invalid argument», причина видна лишь при
# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`
# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные
# типы сообщений WireGuard), большие uint32 без пересечений
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
@@ -130,6 +159,23 @@ H2="2122053282-2133204808"
H3="2133604274-2140756116"
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}')
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
@@ -170,17 +216,45 @@ start_logging() {
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
# файлы на месте.
download_extract() {
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz"
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
echo " → версия: ${VERSION}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|| fail "Не удалось скачать архив: $url"
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
local tmp="/tmp/${ARCHIVE_NAME}"
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}"
local url got=""
for url in "${urls[@]}"; do
if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi
warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник"
done
[[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника:
$(printf ' %s\n' "${urls[@]}")
Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh"
ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)"
fi
mkdir -p "$PROJECT"
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив"
rm -f "$tmp"
[[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT"
local f
@@ -228,7 +302,7 @@ setup_service() {
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
cat > "$SERVICE_UNIT" <<UNIT
[Unit]
Description=AWG Control — awg-ctrl + awg-ui (Forgetting)
Description=AWG Control — awg-ctrl + awg-ui (${BRAND})
After=network-online.target
Wants=network-online.target
@@ -414,9 +488,18 @@ else
fi
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
echo " → add-apt-repository ppa:amnezia/ppa"
add-apt-repository -y ppa:amnezia/ppa \
|| fail "Не удалось добавить PPA ppa:amnezia/ppa"
# Если репозиторий 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 пропущен"
else
echo " → add-apt-repository ppa:amnezia/ppa"
add-apt-repository -y ppa:amnezia/ppa \
|| fail "Не удалось добавить PPA ppa:amnezia/ppa"
fi
echo " → apt-get update (после PPA)"
apt-get update \
@@ -447,6 +530,99 @@ else
ok "AWG установлен, модуль amneziawg собран и загружается"
fi
# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5.
# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0,
# поэтому сначала пробуем обновиться.
# - ядро: header protection использует библиотечный chacha-API
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
# При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий
# откат подсунул бы более слабую обфускацию незаметно для оператора.
# При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение
# по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно
# как раньше, а перейти на 3.1 можно потом из панели.
kernel_lt_5_5() {
local maj min
maj=${KERNEL%%.*}
min=${KERNEL#*.}; min=${min%%.*}
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
}
# 🛑 Версий у модуля две, и путать их нельзя:
# modinfo — что лежит на диске (после apt/DKMS);
# /sys/module/amneziawg/version — что реально загружено в ядро.
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
if [[ "$AWG_GEN" == "2.0" ]]; then
AWG3="n"
MOD_VER=$(disk_mod_ver)
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
else
AWG3="y"
MOD_VER=$(disk_mod_ver)
if [[ "${MOD_VER%%.*}" != "3" ]]; then
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
# снимаем, иначе --only-upgrade молча не тронет held-версию.
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
apt-get update >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
MOD_VER=$(disk_mod_ver)
fi
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
LOADED_VER=$(loaded_mod_ver)
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
awg-quick down "$IFACE" 2>/dev/null || true
ip link delete dev "$IFACE" 2>/dev/null || true
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
LOADED_VER=$(loaded_mod_ver)
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
else
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
fi
fi
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then
fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)).
Перезагрузи сервер и запусти установку снова — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
[[ "$(disk_mod_ver)" == 3.* ]] && \
warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1."
elif kernel_lt_5_5; then
AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then
fail "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210).
Обнови ядро — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
warn "Ставим сервер на AmneziaWG 2.0."
fi
fi
if [[ "$AWG3" == "y" ]]; then
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
else
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1"
warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна."
fi
step "2/7 Node.js"
if command -v node &>/dev/null; then
@@ -482,15 +658,6 @@ else
mv "$DB_FILE" "$DB_BAK"
warn "Старая база пользователей сохранена: $DB_BAK"
fi
# ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД.
if [[ -f "$UI_DB_FILE" ]]; then
UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
mv "$UI_DB_FILE" "$UI_DB_BAK"
# WAL-сайдкары удаляем — к новой БД они неприменимы.
rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm"
warn "Старая база API-ключей сохранена: $UI_DB_BAK"
fi
PRIV_KEY=$(umask 077 && awg genkey)
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
@@ -518,6 +685,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \
sysctl -qp
ok "IP forwarding включён"
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
# RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем.
# 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с
# J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными
# все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий.
# Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске
# ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига.
PREV_GEN="none"
if [[ -f "$AWG_CONF" ]]; then
if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi
fi
AWG3_LINES=""
if [[ "$AWG3" == "y" ]]; then
HEADER_PROTECTION_KEY=""
if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then
# sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге.
HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1)
[[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига"
fi
[[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk)
AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY}
ContentPaddingAddition = 10-100
RekeyAfterTime = 100-120
RekeyTimeout = 3-7
RejectAfterTime = 150-180
KeepaliveTimeout = 5-15
MaxHandshakeAttempts = 15-20"
fi
cat > "$AWG_CONF" <<CONF
[Interface]
Address = ${SUBNET}.0.1/16
@@ -539,8 +738,18 @@ H3 = ${H3}
H4 = ${H4}
CONF
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF"
chmod 600 "$AWG_CONF"
ok "$AWG_CONF"
NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0)
ok "$AWG_CONF (AmneziaWG $NEW_GEN)"
if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then
warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN."
warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах"
warn "и работать перестанут. После запуска открой панель и нажми"
warn "«Перевыпустить ключи», затем раздай пользователям новые ключи."
fi
step "5/7 Запуск AWG"
@@ -549,7 +758,44 @@ if awg show "$IFACE" &>/dev/null; then
awg-quick down "$IFACE" 2>/dev/null || true
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 show public-key = none сразу после up). Если ключ не
@@ -567,6 +813,37 @@ else
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
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"
npm_install_all
@@ -603,6 +880,11 @@ UI_PASS=${UI_PASS}
JWT_SECRET=${JWT_SECRET}
ENV
# Имя продукта попадает в cli.env, только если оператор задал его явно
# (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, —
# чтобы источник правды оставался один.
[[ -n "$BRAND_OVERRIDE" ]] && echo "BRAND=${BRAND_OVERRIDE}" >> "$PROJECT/cli/cli.env"
chmod 600 "$PROJECT/cli/cli.env"
ok "$PROJECT/cli/cli.env"