Compare commits
10
Commits
v0.1.4
...
068cab4d24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068cab4d24 | ||
|
|
21c7409017 | ||
|
|
ca1c8e8c68 | ||
|
|
602c0416ba | ||
|
|
18972ed58f | ||
|
|
38e76da84f | ||
|
|
0dbb13f120 | ||
|
|
b097ccd5fb | ||
|
|
c0e19f284d | ||
|
|
6fdacd71c3 |
@@ -0,0 +1,14 @@
|
||||
# Имя панели и версия. Единственное место, где они заданы: отсюда их читают CLI
|
||||
# (баннер меню) и awg-ui (вордмарк в панели, GET /ui/brand). Файл едет в архив
|
||||
# релиза и лежит в /opt/awg-control/.env на сервере.
|
||||
#
|
||||
# Переменные окружения перекрывают эти значения (BRAND=… в cli.env пишет
|
||||
# install.sh, если оператор задал своё имя при установке).
|
||||
#
|
||||
# ⚠️ VERSION и CHANNEL продублированы в install.sh: установщик печатает баннер и
|
||||
# собирает URL релиза ДО того, как архив с этим файлом скачан. При бампе версии
|
||||
# правь оба места (и три package.json).
|
||||
|
||||
BRAND=Forgetting
|
||||
CHANNEL=Beta
|
||||
VERSION=0.2.0
|
||||
@@ -0,0 +1,48 @@
|
||||
# Проверка на каждый пуш и PR (Gitea Actions). Ловит то, из-за чего main однажды уехал
|
||||
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
|
||||
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
|
||||
# Релиз собирается отдельно (release.yml, только на теги v*).
|
||||
#
|
||||
# Требует включённых Actions в настройках репозитория и раннера с меткой
|
||||
# ubuntu-latest — иначе поправь runs-on под свою метку.
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Installer syntax
|
||||
run: bash -n install.sh
|
||||
|
||||
- name: awg-ctrl types
|
||||
working-directory: awg-ctrl
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
|
||||
# пропавший ./ma7.
|
||||
- name: awg-ui types and build
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npm run build
|
||||
|
||||
- name: cli types
|
||||
working-directory: cli
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
@@ -0,0 +1,89 @@
|
||||
# Публикация релиза в Gitea. Зеркало .github/workflows/release.yml: собирает тот
|
||||
# же архив, но заливает его через API Gitea — gh CLI в раннере нет.
|
||||
#
|
||||
# Gitea читает workflow из .gitea/workflows; когда этот каталог есть, файлы из
|
||||
# .github/workflows она не запускает, так что GitHub-версия остаётся только для
|
||||
# GitHub.
|
||||
#
|
||||
# Что нужно на стороне сервера, иначе workflow просто не запустится:
|
||||
# 1. Репозиторий НЕ должен быть pull-зеркалом — в зеркале Actions не
|
||||
# запускаются, а релизы через API не создаются (сейчас
|
||||
# maeneko/forgetting — зеркало GitHub, has_actions: false).
|
||||
# 2. В настройках репозитория включены Actions.
|
||||
# 3. Зарегистрирован act_runner с меткой ubuntu-latest (иначе поправь runs-on
|
||||
# под свою метку — Gitea принимает только `runs-on: метка`).
|
||||
# 4. Токен: по умолчанию берётся встроенный secrets.GITEA_TOKEN. Если ему не
|
||||
# хватает прав, заведи секрет RELEASE_TOKEN с PAT (имя не может начинаться
|
||||
# с GITEA_/GITHUB_ — эти префиксы зарезервированы).
|
||||
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Package archive
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
mkdir "awgcontrol-${VERSION}"
|
||||
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
|
||||
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||
|
||||
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
|
||||
# зависит от версии движка выражений в Gitea.
|
||||
- name: Publish release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TOKEN="${RELEASE_TOKEN:-$GITEA_TOKEN}"
|
||||
[ -n "$TOKEN" ] || { echo "нет токена: ни RELEASE_TOKEN, ни GITEA_TOKEN"; exit 1; }
|
||||
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
ARCHIVE="awgcontrol-${VERSION}.tar.gz"
|
||||
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
# id релиза — первое поле в JSON Gitea, поэтому берём ПЕРВОЕ вхождение
|
||||
# "id" (дальше идут id автора и ассетов; жадный sed схватил бы их).
|
||||
# jq в раннере может не быть, поэтому grep. Если релиз на этот тег уже
|
||||
# есть (повторный запуск), создание вернёт ошибку — берём его по тегу.
|
||||
# `|| true` обязателен: без совпадения grep вернёт 1, а с pipefail
|
||||
# это уронило бы шаг там, где пустой ответ — штатная ветка.
|
||||
release_id() { grep -oE '"id"[[:space:]]*:[[:space:]]*[0-9]+' | head -n1 | grep -oE '[0-9]+$' || true; }
|
||||
|
||||
id=$(curl -sS -X POST "$API/releases" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":true}" \
|
||||
| release_id)
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "релиз ${GITHUB_REF_NAME} уже существует — дополняем его"
|
||||
id=$(curl -sS -H "Authorization: token $TOKEN" \
|
||||
"$API/releases/tags/${GITHUB_REF_NAME}" | release_id)
|
||||
fi
|
||||
[ -n "$id" ] || { echo "не удалось получить id релиза"; exit 1; }
|
||||
|
||||
curl -sS --fail -X POST "$API/releases/${id}/assets?name=${ARCHIVE}" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@${ARCHIVE};type=application/gzip" > /dev/null
|
||||
|
||||
echo "опубликовано: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${GITHUB_REF_NAME}"
|
||||
@@ -0,0 +1,45 @@
|
||||
# Проверка на каждый пуш и PR. Ловит то, из-за чего main однажды уехал
|
||||
# нерабочим: файл, на который есть import, не попал в коммит (tsc по awg-ui
|
||||
# видит это как «Cannot find module»), плюс битый синтаксис установщика.
|
||||
# Релиз собирается отдельно (release.yml, только на теги v*).
|
||||
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Installer syntax
|
||||
run: bash -n install.sh
|
||||
|
||||
- name: awg-ctrl types
|
||||
working-directory: awg-ctrl
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
|
||||
# tsconfig awg-ui включает server.ts — именно этот шаг и поймал бы
|
||||
# пропавший ./ma7.
|
||||
- name: awg-ui types and build
|
||||
working-directory: awg-ui
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
npm run build
|
||||
|
||||
- name: cli types
|
||||
working-directory: cli
|
||||
run: |
|
||||
npm install
|
||||
npx tsc --noEmit -p tsconfig.json
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+195
-2
@@ -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";
|
||||
@@ -196,13 +197,56 @@ function genOf(p: AwgParams): "2" | "3.1" {
|
||||
return p.HeaderProtectionKey ? "3.1" : "2";
|
||||
}
|
||||
|
||||
// Версия модуля/tools для /health — показывает оператору, что реально работает
|
||||
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
|
||||
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
|
||||
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
|
||||
// это единственные моменты, когда модуль может реально смениться в течение
|
||||
// жизни процесса; из обработчика /health не вызывается — этот роут без
|
||||
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
|
||||
function readAwgVersions(): { module: string; tools: string } {
|
||||
let module = "";
|
||||
try {
|
||||
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
|
||||
} catch {
|
||||
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
|
||||
}
|
||||
let tools = "";
|
||||
try {
|
||||
const out = run("awg --version");
|
||||
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
|
||||
} catch { /* awg-tools не найден */ }
|
||||
return { module, tools };
|
||||
}
|
||||
|
||||
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
|
||||
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
|
||||
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
|
||||
|
||||
// Значения 3.1-параметров для перевода уже работающего 2.0-сервера (POST
|
||||
// /awg/upgrade) — те же, что пишет install.sh (дефолты клиента AmneziaVPN,
|
||||
// protocolConstants.h), в порядке AWG3_KEYS. RandomTrailers/DisableCookies не
|
||||
// включаем — как и инсталлятор. HeaderProtectionKey сюда не входит: он
|
||||
// генерируется на месте (awg genpsk) и фиксируется на весь срок жизни сервера.
|
||||
const AWG31_DEFAULTS: Record<string, string> = {
|
||||
ContentPaddingAddition: "10-100",
|
||||
RekeyAfterTime: "100-120",
|
||||
RekeyTimeout: "3-7",
|
||||
RejectAfterTime: "150-180",
|
||||
KeepaliveTimeout: "5-15",
|
||||
MaxHandshakeAttempts: "15-20",
|
||||
};
|
||||
|
||||
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12
|
||||
// (HEADER_PROTECTION_NONCE_SIZE). Модуль на нарушение отвечает только
|
||||
// «Invalid argument»; причина видна лишь при
|
||||
// `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.
|
||||
const MIN_S_FOR_HEADER_PROTECTION = 12;
|
||||
|
||||
const runtimeConfig = initConfig();
|
||||
const AWG_PARAMS = readAwgParams();
|
||||
const AWG_GEN = genOf(AWG_PARAMS);
|
||||
const AWG_VERSIONS = readAwgVersions();
|
||||
const CONFIG = {
|
||||
interface: "awg1",
|
||||
confDir: "/etc/amnezia/amneziawg",
|
||||
@@ -216,6 +260,10 @@ const CONFIG = {
|
||||
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
|
||||
awgParams: AWG_PARAMS,
|
||||
gen: AWG_GEN,
|
||||
// Мутируются в restartAwg() — единственном месте, где модуль может
|
||||
// реально смениться в течение жизни процесса.
|
||||
awgModule: AWG_VERSIONS.module,
|
||||
awgTools: AWG_VERSIONS.tools,
|
||||
};
|
||||
|
||||
interface UserRow {
|
||||
@@ -574,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]);
|
||||
@@ -581,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() {
|
||||
@@ -697,7 +874,7 @@ app.get("/health", (_req, res) => {
|
||||
server: CONFIG.serverName,
|
||||
ip: CONFIG.serverIp,
|
||||
gen: CONFIG.gen,
|
||||
awg: { status: up ? "ok" : "down", peers },
|
||||
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -759,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,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.1.4",
|
||||
"version": "0.2.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
""
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.1.4",
|
||||
"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
-109
@@ -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,40 +153,6 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key
|
||||
// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ.
|
||||
interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
|
||||
|
||||
const ext = express.Router();
|
||||
ext.use(requireApiKey);
|
||||
|
||||
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 ExtUser;
|
||||
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
|
||||
});
|
||||
|
||||
ext.get("/users", async (_req: Request, res: Response) => {
|
||||
const r = await ctrl("GET", "/api/users/stats");
|
||||
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 ExtUser;
|
||||
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
|
||||
});
|
||||
|
||||
ext.delete("/users/:name", async (req: Request, res: Response) => {
|
||||
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
|
||||
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")));
|
||||
|
||||
+8
-19
@@ -406,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);
|
||||
@@ -466,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;
|
||||
@@ -666,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; }
|
||||
|
||||
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
|
||||
/* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
|
||||
.awg-restart { display: none; }
|
||||
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
|
||||
|
||||
|
||||
+23
-6
@@ -4,7 +4,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import 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, gen: h.gen || '2' });
|
||||
setServerInfo({
|
||||
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
|
||||
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
|
||||
});
|
||||
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
|
||||
}, []);
|
||||
|
||||
@@ -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.4</span>
|
||||
<span className="logo-name">{brand.brand}</span>
|
||||
<span className="sidebar-version">
|
||||
{[brand.channel, brand.version].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<hr className="drawer-divider" />
|
||||
{TABS.map(t => (
|
||||
@@ -243,7 +261,6 @@ export default function App() {
|
||||
serverOnline={isOnline}
|
||||
onRestartAwg={restartAwg}
|
||||
restarting={restarting}
|
||||
showAddServer={chrome.addServer !== false}
|
||||
showRestart={showRestart}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,23 +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 } from '../lib/shared';
|
||||
import { IcoPlus, IcoRefresh } from './icons';
|
||||
import { type ServerInfo, genLabel } from '../lib/shared';
|
||||
import { 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 (
|
||||
@@ -38,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
|
||||
|
||||
@@ -21,19 +21,22 @@ export interface ServerInfo {
|
||||
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> {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Copyright (c) 2026 Ivan Vasilev
|
||||
This source code is licensed under the MIT license found in the
|
||||
LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
|
||||
|
||||
/* Открытый API-ключ (показывается один раз при создании) */
|
||||
.apikey-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--on-surface);
|
||||
background: var(--surface-container);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
width: 100%;
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
|
||||
|
||||
.apikey-count { font-size: 12px; color: var(--neutral-text); }
|
||||
.apikey-empty {
|
||||
font-size: 14px;
|
||||
color: var(--neutral-text);
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.apikey-card {
|
||||
background: var(--surface-bright);
|
||||
border: 1px solid var(--outline-variant);
|
||||
border-radius: var(--r-card);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.apikey-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.apikey-card-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
|
||||
.apikey-card-label {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--on-surface);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.apikey-card-prefix {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--neutral-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.apikey-card-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.apikey-cards-list { display: flex; }
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
|
||||
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||
import './apikeys.css';
|
||||
|
||||
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
|
||||
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
|
||||
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
|
||||
// серверов — ключ будет привязываться к выбранному в сервер-баре.
|
||||
export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiFetch('GET', '/ui/apikeys', token);
|
||||
setKeys(data.keys ?? []);
|
||||
} catch {
|
||||
showMsg('Ошибка загрузки ключей');
|
||||
}
|
||||
}, [token, showMsg]);
|
||||
|
||||
const createKey = useCallback(async () => {
|
||||
if (!label.trim()) return;
|
||||
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
|
||||
if (r.error) { showMsg(r.error); return; }
|
||||
setLabel('');
|
||||
setCreated({ label: r.label, key: r.key });
|
||||
await load();
|
||||
}, [label, token, load, showMsg]);
|
||||
|
||||
const deleteKey = useCallback(async (id: number, lbl: string) => {
|
||||
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
|
||||
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
|
||||
showMsg('Ключ удалён: ' + lbl);
|
||||
await load();
|
||||
}, [token, load, showMsg]);
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
|
||||
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
placeholder="Метка ключа (напр. ci-bot)"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && createKey()}
|
||||
/>
|
||||
<div className="toolbar-btns">
|
||||
<button className="btn btn--primary" onClick={createKey}>
|
||||
<IcoPlus /> Создать ключ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
|
||||
<div className="apikey-cards-list">
|
||||
<p className="apikey-count">{keys.length} ключей</p>
|
||||
{keys.length === 0 ? (
|
||||
<p className="apikey-empty">Нет ключей</p>
|
||||
) : keys.map((k, i) => (
|
||||
<div className="apikey-card" key={k.id}>
|
||||
<div className="apikey-card-header">
|
||||
<div className="apikey-card-title">
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{created && (
|
||||
<div className="qr-backdrop" onClick={() => setCreated(null)}>
|
||||
<div className="qr-card" onClick={e => e.stopPropagation()}>
|
||||
<p className="qr-name">Ключ «{created.label}» создан</p>
|
||||
<p className="qr-hint">
|
||||
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
|
||||
</p>
|
||||
<code className="apikey-value">{created.key}</code>
|
||||
<div className="qr-actions">
|
||||
<button className="btn btn--primary" onClick={async () => {
|
||||
await copyText(created.key);
|
||||
showMsg('Ключ скопирован');
|
||||
}}><IcoCopy /> Скопировать</button>
|
||||
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"id": "apikeys",
|
||||
"label": "API-ключи",
|
||||
"icon": "key",
|
||||
"order": 2
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { ICONS } from '../components/icons';
|
||||
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
|
||||
export interface TabChrome {
|
||||
serverBar?: boolean;
|
||||
addServer?: boolean;
|
||||
restart?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
|
||||
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
|
||||
type User, type PageProps, type AwgGen,
|
||||
} from '../../lib/shared';
|
||||
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
|
||||
@@ -19,6 +19,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
|
||||
// поколении, уже не подключатся — их надо перевыпустить.
|
||||
const [serverGen, setServerGen] = useState<AwgGen>('2');
|
||||
const [upgrading, setUpgrading] = useState(false);
|
||||
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const stale = users.filter(u => u.key_gen !== serverGen);
|
||||
|
||||
@@ -50,7 +51,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
|
||||
const reissueKeys = useCallback(async () => {
|
||||
if (!confirm(
|
||||
`Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` +
|
||||
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
|
||||
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
|
||||
)) return;
|
||||
try {
|
||||
@@ -62,6 +63,28 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
}
|
||||
}, [stale.length, serverGen, token, loadUsers, showMsg]);
|
||||
|
||||
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
|
||||
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
|
||||
// их не примет) и сразу перевыпускает ключи — старые собраны на
|
||||
// 2.0-параметрах и работать не будут.
|
||||
const upgradeGen = useCallback(async () => {
|
||||
if (!confirm(
|
||||
'Перевести сервер на AmneziaWG 3.1?\n\n' +
|
||||
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
|
||||
'клиентам придётся импортировать ключ заново.',
|
||||
)) return;
|
||||
setUpgrading(true);
|
||||
try {
|
||||
const r = await apiFetch('POST', '/awg/upgrade', token);
|
||||
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
|
||||
await loadUsers(token);
|
||||
} catch (e: any) {
|
||||
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
|
||||
} finally {
|
||||
setUpgrading(false);
|
||||
}
|
||||
}, [token, loadUsers, showMsg]);
|
||||
|
||||
const createUser = useCallback(async () => {
|
||||
if (!newName) return;
|
||||
const u = await apiFetch('POST', '/api/users', token, { name: newName });
|
||||
@@ -120,6 +143,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
|
||||
<IcoRefresh /> Обновить
|
||||
</button>
|
||||
{serverGen !== '3.1' && (
|
||||
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
|
||||
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
|
||||
</button>
|
||||
)}
|
||||
{stale.length > 0 && (
|
||||
<button className="btn btn--danger" onClick={reissueKeys}>
|
||||
<IcoRefresh /> Перевыпустить ключи ({stale.length})
|
||||
@@ -162,11 +190,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
{u.key_gen !== serverGen && (
|
||||
<span
|
||||
className="tip-wrap user-gen"
|
||||
data-tip={`Ключ выдан на AWG ${u.key_gen}, сервер работает на ${serverGen}`}
|
||||
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
|
||||
>
|
||||
<span className="chip chip--error">
|
||||
<span className="chip-dot" />
|
||||
AWG {u.key_gen}
|
||||
{genLabel(u.key_gen)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -207,7 +235,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
|
||||
{u.key_gen !== serverGen && (
|
||||
<span className="chip chip--error">
|
||||
<span className="chip-dot" />
|
||||
AWG {u.key_gen}
|
||||
{genLabel(u.key_gen)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/ui': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/login': 'http://localhost:8080',
|
||||
'/logout': 'http://localhost:8080',
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.1.4",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+53
-12
@@ -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.4 ──${C.reset}`)}\n`);
|
||||
console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
|
||||
|
||||
for (const name of ALL) {
|
||||
const running = isRunning(name);
|
||||
@@ -321,7 +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");
|
||||
|
||||
+232
-38
@@ -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.4"
|
||||
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"; }
|
||||
@@ -134,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 "Не могу определить сетевой интерфейс"
|
||||
|
||||
@@ -174,17 +216,45 @@ start_logging() {
|
||||
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
|
||||
# файлы на месте.
|
||||
download_extract() {
|
||||
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz"
|
||||
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
|
||||
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}"
|
||||
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|
||||
|| fail "Не удалось скачать архив: $url"
|
||||
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
|
||||
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
|
||||
@@ -232,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
|
||||
|
||||
@@ -418,9 +488,18 @@ else
|
||||
fi
|
||||
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
|
||||
|
||||
# Если репозиторий amnezia уже настроен (например, ключ и источник заведены
|
||||
# руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически
|
||||
# отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно.
|
||||
# Проверяем именно файлы источников, а не `apt-cache policy`: сразу после
|
||||
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
|
||||
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
|
||||
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 \
|
||||
@@ -457,10 +536,11 @@ fi
|
||||
# - ядро: header protection использует библиотечный chacha-API
|
||||
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
|
||||
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
|
||||
# Если 3.1 недоступен — не падаем, а пишем 2.0-конфиг: awg-ctrl определяет
|
||||
# поколение по наличию HeaderProtectionKey в конфиге, так что всё продолжит
|
||||
# работать ровно как раньше.
|
||||
AWG3="y"
|
||||
# При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий
|
||||
# откат подсунул бы более слабую обфускацию незаметно для оператора.
|
||||
# При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение
|
||||
# по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно
|
||||
# как раньше, а перейти на 3.1 можно потом из панели.
|
||||
|
||||
kernel_lt_5_5() {
|
||||
local maj min
|
||||
@@ -469,28 +549,78 @@ kernel_lt_5_5() {
|
||||
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
|
||||
}
|
||||
|
||||
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "")
|
||||
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||
# 🛑 Версий у модуля две, и путать их нельзя:
|
||||
# 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=$(modinfo -F version amneziawg 2>/dev/null || echo "")
|
||||
fi
|
||||
MOD_VER=$(disk_mod_ver)
|
||||
fi
|
||||
|
||||
if [[ "${MOD_VER%%.*}" != "3" ]]; then
|
||||
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
|
||||
# поэтому сначала опускаем его; 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"
|
||||
warn "Модуль остался на версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
|
||||
elif kernel_lt_5_5; then
|
||||
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 "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1"
|
||||
warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна."
|
||||
fi
|
||||
|
||||
step "2/7 Node.js"
|
||||
@@ -528,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)
|
||||
|
||||
@@ -637,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). Если ключ не
|
||||
@@ -655,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
|
||||
@@ -691,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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user