6 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
18 changed files with 602 additions and 151 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
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}" mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli "awgcontrol-${VERSION}/" cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release - name: Publish release
+54 -35
View File
@@ -9,7 +9,7 @@
| Компонент | Роль | | Компонент | Роль |
|---|---| |---|---|
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. | | **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), интеграция с API MA7, прокси к awg-ctrl. Единственный сервис, доступный по сети. | | **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), прокси к awg-ctrl. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. | | **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным. Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
@@ -28,8 +28,60 @@
На сервере, от root: На сервере, от root:
```bash ```bash
bash <(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh) bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
``` ```
Установщик по умолчанию ставит сервер на AmneziaWG 3.1. Если модуль в ядре
старше 3.x или ядро старше 5.5, установка остановится с диагностикой — поставить
на 2.0 можно так:
```bash
AWG_GEN=auto bash <(curl -Ls https://git.ma7neko.ru/maeneko/forgetting/raw/branch/main/install.sh)
```
Перевести такой сервер на 3.1 позже можно кнопкой «Перейти на AWG 3.1» в панели —
переустановка не нужна, пользователи сохраняются, ключи перевыпускаются
автоматически.
### Свой источник и своё имя
В коде не зашиты ни репозиторий, ни бренд — всё задаётся окружением до запуска:
| Переменная | По умолчанию | Зачем |
|---|---|---|
| `REPO_BASE` | `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 ```bash
@@ -42,38 +94,5 @@ awg-ctrl
systemctl start|stop|restart awg-control systemctl start|stop|restart awg-control
``` ```
## Интеграция с MA7
Этот сервер персональный — принадлежит одному абоненту MA7 (сервиса
VPN-подписки). Панель ничем не управляет в MA7 и не отдаёт наружу свой API:
вкладка «Профиль» только читает баланс своего владельца. Секрет MA7 живёт
только в `awg-ui` (`cli.env`) и в браузер не попадает — фронт ходит на свой
бэкенд, тот читает MA7.
Нужные переменные (задаются при установке или руками в `cli.env`):
| Переменная | Назначение |
|---|---|
| `MA7_API_BASE_URL` | адрес API MA7, например `https://amnesia.ma7neko.ru` |
| `MA7_JWT_SECRET` | секрет, которым панель сама подписывает `X-Bot-Token` (эндпоинта, выдающего токен, в MA7 нет) |
| `MA7_LOGIN` | логин владельца этого сервера в MA7 (`ma7_xxxxxx`) |
Вкладка «Профиль» просто показывает текущий баланс `MA7_LOGIN`. При установке
`MA7_LOGIN` заодно предлагается как логин панели (`UI_USER`) — сервер
персональный, и заказчик входит тем же именем, что знает по MA7; при желании
логин панели можно задать свой. Это разные переменные: `UI_USER` решает, кого
пускать в панель, `MA7_LOGIN` — чей баланс показывать.
`MA7_LOGIN` задаётся один раз при установке и **не редактируется из панели**: у панели
один общий пароль (`UI_USER`/`UI_PASS`) без отдельного клиентского входа, а
`MA7_JWT_SECRET` умеет искать в MA7 любого абонента — редактируемый в браузере
логин превратил бы личный баланс в инструмент подглядывания за чужими.
> [!WARNING]
> `MA7_JWT_SECRET` — мастер-ключ ко всему MA7: с ним можно читать данные любых
> пользователей MA7, не только этого сервера. Токены MA7 без срока действия,
> отзыва нет — «отобрать доступ» значит одновременно сменить `MA7_JWT_SECRET`
> во всех сервисах MA7 и здесь, в `cli.env`.
## Лицензия ## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev [MIT](LICENSE) © 2026 Ivan Vasilev
+164 -1
View File
@@ -3,6 +3,7 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path"; import path from "path";
import os from "os";
import crypto from "crypto"; import crypto from "crypto";
import { execSync, spawnSync } from "child_process"; import { execSync, spawnSync } from "child_process";
import * as zlib from "zlib"; import * as zlib from "zlib";
@@ -222,6 +223,26 @@ function readAwgVersions(): { module: string; tools: string } {
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг. // в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" }; const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
// Значения 3.1-параметров для перевода уже работающего 2.0-сервера (POST
// /awg/upgrade) — те же, что пишет install.sh (дефолты клиента AmneziaVPN,
// protocolConstants.h), в порядке AWG3_KEYS. RandomTrailers/DisableCookies не
// включаем — как и инсталлятор. HeaderProtectionKey сюда не входит: он
// генерируется на месте (awg genpsk) и фиксируется на весь срок жизни сервера.
const AWG31_DEFAULTS: Record<string, string> = {
ContentPaddingAddition: "10-100",
RekeyAfterTime: "100-120",
RekeyTimeout: "3-7",
RejectAfterTime: "150-180",
KeepaliveTimeout: "5-15",
MaxHandshakeAttempts: "15-20",
};
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12
// (HEADER_PROTECTION_NONCE_SIZE). Модуль на нарушение отвечает только
// «Invalid argument»; причина видна лишь при
// `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.
const MIN_S_FOR_HEADER_PROTECTION = 12;
const runtimeConfig = initConfig(); const runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams(); const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS); const AWG_GEN = genOf(AWG_PARAMS);
@@ -601,6 +622,17 @@ function ensureInterfaceUp() {
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`); if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
} }
// Параметры обфускации и поколение живут в awg1.conf, а не в процессе:
// перечитываем их после любой правки конфига. Иначе awg-ctrl продолжит
// собирать vpn:// ключи на старых параметрах — например, на 2.0 уже после
// перехода интерфейса на 3.1.
function reloadAwgParams(): "2" | "3.1" {
CONFIG.awgParams = readAwgParams();
CONFIG.gen = genOf(CONFIG.awgParams);
CONFIG.keepalive = KEEPALIVE_BY_GEN[CONFIG.gen];
return CONFIG.gen;
}
function restartAwg() { function restartAwg() {
logger.info("AWG restart: down"); logger.info("AWG restart: down");
const down = spawnSync("awg-quick", ["down", CONFIG.interface]); const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
@@ -608,11 +640,126 @@ function restartAwg() {
logger.info("AWG restart: up"); logger.info("AWG restart: up");
const up = spawnSync("awg-quick", ["up", CONFIG.interface]); const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`); if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная
// правка awg1.conf) — перечитываем ДО syncPeers: keepalive из CONFIG уходит
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams();
syncPeers(); syncPeers();
const versions = readAwgVersions(); const versions = readAwgVersions();
CONFIG.awgModule = versions.module; CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools; CONFIG.awgTools = versions.tools;
logger.info("AWG restart: done", versions); logger.info("AWG restart: done", { ...versions, gen });
}
// ─────────────────────────── переход 2.0 → 3.1 ───────────────────────────
// 3.1 — основное поколение (install.sh ставит его по умолчанию). Сервера,
// установленные раньше, переводятся отсюда, без переустановки: панель дёргает
// POST /awg/upgrade.
// Header protection использует библиотечный chacha-API (chacha_init/
// chacha20_crypt), которого нет до ядра 5.5 — там модуль 3.x просто не
// соберётся (upstream issue #210).
function kernelSupportsHeaderProtection(): boolean {
const [maj, min] = os.release().split(".").map(n => Number.parseInt(n, 10));
if (!Number.isFinite(maj) || !Number.isFinite(min)) return false;
return maj > 5 || (maj === 5 && min >= 5);
}
// Пред-проверка перед тем, как трогать конфиг: решает ЗАГРУЖЕННЫЙ модуль
// (readAwgVersions читает /sys/module/amneziawg/version), а не тот, что лежит
// на диске после apt — обслуживает интерфейс именно загруженный.
function checkGen31Support(): string | null {
const { module } = readAwgVersions();
if (!module.startsWith("3."))
return `В ядре загружен модуль amneziawg ${module || "неизвестной версии"} — для 3.1 нужна 3.x. `
+ "Обнови пакет (apt) и перезагрузи сервер, затем повтори переход.";
if (!kernelSupportsHeaderProtection())
return `Ядро ${os.release()} старше 5.5 — header protection в нём не работает (upstream issue #210).`;
return null;
}
const AWG3_KEY_LINE = new RegExp(`^\\s*(${AWG3_KEYS.join("|")})\\s*=`);
// Готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и
// дописывает 3.x-блок в конец [Interface]. [Peer]-блоки не трогаются —
// личности пиров переход не меняет.
function confWithGen31(conf: string, headerKey: string): { conf: string; bumpedS: string[] } {
const peerAt = conf.search(/^\[Peer\]/m);
let rest = peerAt >= 0 ? conf.slice(peerAt) : "";
const head = (peerAt >= 0 ? conf.slice(0, peerAt) : conf)
.split("\n")
.filter(l => !AWG3_KEY_LINE.test(l));
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому
// [Peer] (их пишет rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь
// [Interface], а не между комментарием и его пиром.
const tail: string[] = [];
while (head.length && (head[head.length - 1].trim() === "" || head[head.length - 1].trim().startsWith("#")))
tail.unshift(head.pop()!);
rest = tail.join("\n").trim() ? `${tail.join("\n").trim()}\n${rest}` : rest;
let iface = head.join("\n");
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет.
// Правка безопасна ровно потому, что следом идёт перевыпуск ключей: обе
// стороны получают новые значения одновременно.
const bumpedS: string[] = [];
for (const k of ["S1", "S2", "S3", "S4"] as const) {
const re = new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)\\s*$`, "m");
const cur = Number(iface.match(re)?.[1]);
if (Number.isFinite(cur) && cur >= MIN_S_FOR_HEADER_PROTECTION) continue;
const val = Math.max(DEFAULT_AWG_PARAMS[k], MIN_S_FOR_HEADER_PROTECTION);
iface = re.test(iface)
? iface.replace(re, `${k} = ${val}`)
: `${iface.trimEnd()}\n${k} = ${val}`;
bumpedS.push(`${k}: ${Number.isFinite(cur) ? cur : "нет"} → ${val}`);
}
const awg3 = [
`HeaderProtectionKey = ${headerKey}`,
...Object.entries(AWG31_DEFAULTS).map(([k, v]) => `${k} = ${v}`),
].join("\n");
return { conf: `${iface.trimEnd()}\n${awg3}\n${rest ? `\n${rest}` : ""}`, bumpedS };
}
interface UpgradeResult { gen: "2" | "3.1"; backup: string; bumpedS: string[]; reissue: ReissueResult }
// Перевод интерфейса на 3.1. Единственная настоящая проверка, что ядро приняло
// параметры, — подъём интерфейса, поэтому шаги такие: бэкап конфига → запись
// 3.1 → restartAwg() (он же перечитает параметры) → перевыпуск ключей. Если
// ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
function upgradeToGen31(): UpgradeResult {
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
const before = readFileSync(confFile, "utf8");
const backup = `${confFile}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
writeFileSync(backup, before, { mode: 0o600 });
const { conf, bumpedS } = confWithGen31(before, run("awg genpsk"));
writeFileSync(confFile, conf, { mode: 0o600 });
logger.info("upgrade: conf rewritten for 3.1", { backup, bumpedS });
try {
restartAwg();
if (CONFIG.gen !== "3.1") throw new Error("интерфейс поднялся, но конфиг всё ещё 2.0");
} catch (e) {
logger.error("upgrade: kernel rejected 3.1, rolling back", { error: e });
writeFileSync(confFile, before, { mode: 0o600 });
try { restartAwg(); } catch (e2) { logger.error("upgrade: rollback restart failed", { error: e2 }); }
throw new Error(
`Ядро не приняло конфиг 3.1 — вернули прежний (копия: ${backup}). `
+ 'Причина видна в dmesg после `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`.',
);
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся,
// поэтому перевыпускаем сразу — сервер не должен оставаться в состоянии
// «3.1 поднят, у всех нерабочие ключи». Личности пиров сохраняются.
const reissue = reissueAll();
logger.info("upgrade: done", { gen: CONFIG.gen, reissued: reissue.reissued, bumpedS });
return { gen: CONFIG.gen, backup, bumpedS, reissue };
} }
function startInterface() { function startInterface() {
@@ -789,6 +936,22 @@ app.post("/awg/restart", auth, handler((_req, res) => {
res.json({ success: true }); res.json({ success: true });
})); }));
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей.
// Ошибку отдаём текстом: панель показывает её оператору, а причины отказа
// (старый модуль, старое ядро, отказ ядра на подъёме) требуют разных действий.
app.post("/awg/upgrade", auth, handler((_req, res) => {
if (CONFIG.gen === "3.1") {
res.status(409).json({ error: "Сервер уже работает на AmneziaWG 3.1" }); return;
}
const blocker = checkGen31Support();
if (blocker) { res.status(409).json({ error: blocker }); return; }
try {
res.json(upgradeToGen31());
} catch (e) {
res.status(500).json({ error: e instanceof Error ? e.message : "Не удалось перейти на 3.1" });
}
}));
app.get("/awg/status", auth, handler((_req, res) => { app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus()); res.json(getInterfaceStatus());
})); }));
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.1.4+2", "version": "0.2.0",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -9,6 +9,6 @@
"outDir": "dist" "outDir": "dist"
}, },
"include": [ "include": [
"" "index.ts"
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.1.4+2", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+20 -33
View File
@@ -6,7 +6,6 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import crypto from "crypto"; import crypto from "crypto";
import axios from "axios"; import axios from "axios";
import { ma7 } from "./ma7";
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 8080; 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 JWT_SECRET = process.env.JWT_SECRET ?? crypto.randomBytes(32).toString("hex");
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`; const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
// Имя панели, канал и версия. В проде их прокидывает CLI (он читает .env в
// корне проекта и cli.env); при самостоятельном запуске `npm run server`
// читаем тот же .env сами, чтобы дев-режим показывал то же, что прод.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const ROOT_ENV = path.join(__dirname, "..", ".env");
const BRAND = process.env.BRAND || envFromFile(ROOT_ENV, "BRAND") || "Forgetting";
const CHANNEL = process.env.CHANNEL || envFromFile(ROOT_ENV, "CHANNEL") || "Beta";
const VERSION = process.env.VERSION || envFromFile(ROOT_ENV, "VERSION") || "";
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE
?? path.join(__dirname, "internal_auth_private.key"); ?? path.join(__dirname, "internal_auth_private.key");
let internalAuthKey: crypto.KeyObject | null = null; let internalAuthKey: crypto.KeyObject | null = null;
@@ -109,6 +122,12 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// Единственный роут без авторизации: вордмарк нужен уже на экране логина,
// до выдачи JWT. Наружу уходит только имя продукта и версия.
app.get("/ui/brand", (_req: Request, res: Response) => {
res.json({ brand: BRAND, channel: CHANNEL, version: VERSION });
});
async function proxy(req: Request, res: Response) { async function proxy(req: Request, res: Response) {
try { try {
const r = await axios({ const r = await axios({
@@ -134,38 +153,6 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
}); });
} }
// ── Интеграция с MA7 (вкладка «Профиль») ───────────────────────────────────
// Сервер персональный — панель показывает баланс своего единственного
// владельца, не список абонентов. Логин владельца (MA7_LOGIN) — часть
// конфигурации сервера, задаётся один раз при установке (cli.env), а НЕ
// вводится через UI: бот-токен MA7_JWT_SECRET умеет искать любого абонента
// MA7, и если бы логин можно было менять прямо в панели, любой, кто знает
// пароль от панели, мог бы подсматривать баланс чужих абонентов — панель
// защищена одним общим UI_USER/UI_PASS, отдельного «клиентского» входа нет.
const MA7_LOGIN = process.env.MA7_LOGIN ?? "";
app.get("/ui/ma7/profile", requireAuth, async (_req: Request, res: Response) => {
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(MA7_LOGIN)) {
res.status(500).json({ error: "MA7_LOGIN не задан — укажи его в cli.env" }); return;
}
try {
const r = await ma7.get("/api/admin/users", { params: { q: MA7_LOGIN, limit: 1 } });
if (r.status >= 400) {
const msg = r.status === 401 ? "MA7: неверный MA7_JWT_SECRET"
: r.status === 403 ? "MA7: не та роль в токене"
: (r.data as { error?: string })?.error ?? "Ошибка MA7";
res.status(r.status === 401 || r.status === 403 ? 502 : r.status).json({ error: msg });
return;
}
const user = (r.data.users as { login: string; balance: number }[] ?? [])
.find(u => u.login === MA7_LOGIN);
if (!user) { res.status(404).json({ error: "MA7_LOGIN не найден в MA7" }); return; }
res.json({ login: user.login, balance: user.balance });
} catch {
res.status(502).json({ error: "MA7 недоступен" });
}
});
app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist"))); app.use(express.static(path.join(__dirname, "dist")));
+19 -4
View File
@@ -4,7 +4,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import './App.css'; import './App.css';
import { TOKEN_KEY, THEME_KEY, apiFetch, type ServerInfo } from './lib/shared'; import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar'; import ServerBar from './components/ServerBar';
import { TABS } from './tabs'; import { TABS } from './tabs';
@@ -22,6 +22,7 @@ export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id); const [activeTab, setActiveTab] = useState(TABS[0].id);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const [brand, setBrand] = useState<Brand>({ brand: BRAND_FALLBACK, channel: '', version: '' });
const [theme, setTheme] = useState<'light' | 'dark'>(() => const [theme, setTheme] = useState<'light' | 'dark'>(() =>
localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'); localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light');
const touchStartX = useRef(0); const touchStartX = useRef(0);
@@ -110,6 +111,18 @@ export default function App() {
} }
}, [startSession]); }, [startSession]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
axios.get('/ui/brand')
.then(({ data }) => setBrand({
brand: data.brand || BRAND_FALLBACK,
channel: data.channel || '',
version: data.version || '',
}))
.catch(() => { /* остаётся фолбэк */ });
}, []);
// Применять и сохранять тему // Применять и сохранять тему
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
@@ -163,7 +176,7 @@ export default function App() {
{!token ? ( {!token ? (
<div className="login-screen"> <div className="login-screen">
<div className="login-card"> <div className="login-card">
<span className="logo-name">Forgetting</span> <span className="logo-name">{brand.brand}</span>
<p className="login-sub">Войдите, чтобы продолжить</p> <p className="login-sub">Войдите, чтобы продолжить</p>
<input <input
className="field" className="field"
@@ -216,8 +229,10 @@ export default function App() {
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
<div className="sidebar-logo"> <div className="sidebar-logo">
<span className="logo-name">Forgetting</span> <span className="logo-name">{brand.brand}</span>
<span className="sidebar-version">Alpha 0.1.4.2</span> <span className="sidebar-version">
{[brand.channel, brand.version].filter(Boolean).join(' ')}
</span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS.map(t => ( {TABS.map(t => (
+6
View File
@@ -31,6 +31,12 @@ export interface PageProps {
token: string; token: string;
showMsg: (text: string) => void; showMsg: (text: string) => void;
} }
// Имя панели, канал и версия приходят с сервера (GET /ui/brand; значения — из
// .env в корне проекта) — переименование и бамп версии живут в одном файле.
// Эта константа — фолбэк на время загрузки и при недоступности роута.
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token'; export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme'; export const THEME_KEY = 'awg_theme';
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> { export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
+28
View File
@@ -19,6 +19,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом // Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить. // поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2'); const [serverGen, setServerGen] = useState<AwgGen>('2');
const [upgrading, setUpgrading] = useState(false);
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null); const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen); const stale = users.filter(u => u.key_gen !== serverGen);
@@ -62,6 +63,28 @@ export default function UsersPage({ token, showMsg }: PageProps) {
} }
}, [stale.length, serverGen, token, loadUsers, showMsg]); }, [stale.length, serverGen, token, loadUsers, showMsg]);
// Перевод сервера с 2.0 на 3.1 без переустановки: awg-ctrl дописывает
// 3.x-параметры в awg1.conf, перезапускает интерфейс (с откатом, если ядро
// их не примет) и сразу перевыпускает ключи — старые собраны на
// 2.0-параметрах и работать не будут.
const upgradeGen = useCallback(async () => {
if (!confirm(
'Перевести сервер на AmneziaWG 3.1?\n\n' +
'Интерфейс будет перезапущен, а все vpn:// ключи перевыпущены — ' +
'клиентам придётся импортировать ключ заново.',
)) return;
setUpgrading(true);
try {
const r = await apiFetch('POST', '/awg/upgrade', token);
showMsg(`Сервер переведён на ${genLabel(r.gen)}, перевыпущено ключей: ${r.reissue?.reissued ?? 0}`);
await loadUsers(token);
} catch (e: any) {
showMsg(e?.response?.data?.error ?? 'Не удалось перейти на 3.1');
} finally {
setUpgrading(false);
}
}, [token, loadUsers, showMsg]);
const createUser = useCallback(async () => { const createUser = useCallback(async () => {
if (!newName) return; if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName }); const u = await apiFetch('POST', '/api/users', token, { name: newName });
@@ -120,6 +143,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<button className="btn btn--tonal" onClick={() => loadUsers(token)}> <button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить <IcoRefresh /> Обновить
</button> </button>
{serverGen !== '3.1' && (
<button className="btn btn--danger" onClick={upgradeGen} disabled={upgrading}>
<IcoRefresh /> {upgrading ? 'Переход…' : 'Перейти на AWG 3.1'}
</button>
)}
{stale.length > 0 && ( {stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}> <button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length}) <IcoRefresh /> Перевыпустить ключи ({stale.length})
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': 'http://localhost:8080', '/api': 'http://localhost:8080',
'/ui': 'http://localhost:8080',
'/health': 'http://localhost:8080', '/health': 'http://localhost:8080',
'/login': 'http://localhost:8080', '/login': 'http://localhost:8080',
'/logout': 'http://localhost:8080', '/logout': 'http://localhost:8080',
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.1.4+2", "version": "0.2.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+23 -14
View File
@@ -15,19 +15,31 @@ const LOGS = path.join(ROOT, "logs");
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true }); if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
const envFile = path.join(__dirname, "..", "cli.env"); // Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
if (existsSync(envFile)) { // реальное окружение → cli.env (машинные настройки, пишет install.sh) →
for (const line of readFileSync(envFile, "utf8").split("\n")) { // .env в корне проекта (имя панели и версия, едет в архиве релиза).
function loadEnvFile(file: string) {
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split("\n")) {
const t = line.trim(); const t = line.trim();
if (!t || t.startsWith("#")) continue; if (!t || t.startsWith("#")) continue;
const idx = t.indexOf("="); const idx = t.indexOf("=");
if (idx === -1) continue; if (idx === -1) continue;
const k = t.slice(0, idx).trim(); process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
const v = t.slice(idx + 1).trim();
process.env[k] ??= v;
} }
} }
const envFile = path.join(__dirname, "..", "cli.env"); // машинные настройки
loadEnvFile(envFile);
loadEnvFile(path.join(ROOT, ".env")); // имя панели и версия
// Имя панели, канал и версия — из .env (или из cli.env, если оператор задал
// своё имя при установке). В коде только запасные значения на случай, когда
// .env не доехал.
const BRAND = process.env.BRAND || "Forgetting";
const CHANNEL = process.env.CHANNEL || "Beta";
const VERSION = process.env.VERSION || "";
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms)); const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const C = { const C = {
@@ -71,16 +83,13 @@ const SERVICES = {
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl). // Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key", ?? "/etc/amnezia/amneziawg/internal_auth_private.key",
// Панель показывает их в вордмарке (GET /ui/brand).
BRAND: BRAND,
CHANNEL: CHANNEL,
VERSION: VERSION,
UI_USER: process.env.UI_USER ?? "admin", UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "", UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "", JWT_SECRET: process.env.JWT_SECRET ?? "",
// Интеграция с API MA7 (см. awg-ui/ma7.ts) — секрет чужого сервиса,
// не совпадает с UI_* / JWT_SECRET этой панели. MA7_LOGIN — логин
// владельца этого персонального сервера в MA7 ('ma7_xxxxxx'):
// задаётся один раз при установке, в панели не редактируется.
MA7_API_BASE_URL: process.env.MA7_API_BASE_URL ?? "",
MA7_JWT_SECRET: process.env.MA7_JWT_SECRET ?? "",
MA7_LOGIN: process.env.MA7_LOGIN ?? "",
}, },
pidFile: "/tmp/awg-ui.pid", pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"), logFile: path.join(LOGS, "awg-ui.log"),
@@ -319,7 +328,7 @@ async function interactiveMenu() {
const printMenu = () => { const printMenu = () => {
console.clear(); console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.2 ──${C.reset}`)}\n`); console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
+85 -58
View File
@@ -19,9 +19,28 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh" [[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh" [[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.4.2" # ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
GH_REPO="maeneko/forgetting" # баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}" # При бампе версии правь оба места.
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" PROJECT="/opt/awg-control"
AMNEZIA_DIR="/etc/amnezia" AMNEZIA_DIR="/etc/amnezia"
AWG_DIR="$AMNEZIA_DIR/amneziawg" AWG_DIR="$AMNEZIA_DIR/amneziawg"
@@ -42,7 +61,7 @@ MTU="1376"
TSX="$PROJECT/cli/node_modules/.bin/tsx" TSX="$PROJECT/cli/node_modules/.bin/tsx"
CLI="$PROJECT/cli/src/index.ts" CLI="$PROJECT/cli/src/index.ts"
echo -e "${BLD}Forgetting Alpha ${VERSION}${NC}" echo -e "${BLD}${BRAND} ${CHANNEL} ${VERSION}${NC}"
echo echo
# Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой # Запускается ДО вопросов и любого деструктива (rm -rf): на несовместимой
@@ -83,10 +102,17 @@ fi
# 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой # 3) Доступ в интернет: нужен для архива, Node и пакетов AWG. Без -f: любой
# HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS. # HTTP-ответ = связь есть; ненулевой код только при сбое соединения/DNS.
if ! curl -sS --connect-timeout 8 -o /dev/null "https://github.com" 2>/dev/null; then # Проверяем хосты, с которых реально качаем: хватает любого из двух —
# download_extract() умеет откатываться на запасной.
NET_HOSTS=("$(url_host "$REPO_BASE")")
[[ -n "$ARCHIVE_URL" ]] && NET_HOSTS=("$(url_host "$ARCHIVE_URL")")
[[ -z "$ARCHIVE_URL" && "$(url_host "$REPO_FALLBACK")" != "${NET_HOSTS[0]}" ]] \
&& NET_HOSTS+=("$(url_host "$REPO_FALLBACK")")
NET_OK=0 NET_OK=0
NET_WHY="нет доступа к github.com — проверь интернет и DNS" for h in "${NET_HOSTS[@]}"; do
fi if curl -sS --connect-timeout 8 -o /dev/null "https://$h" 2>/dev/null; then NET_OK=1; break; fi
done
[[ "$NET_OK" == 0 ]] && NET_WHY="нет доступа к ${NET_HOSTS[*]} — проверь интернет и DNS"
# Чеклист: ✓ — пройдено, ✗ — нет. # Чеклист: ✓ — пройдено, ✗ — нет.
mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; } mark() { [[ "$1" == 1 ]] && echo -e " ${GRN}✓${NC} $2" || echo -e " ${RED}✗${NC} $2"; }
@@ -134,15 +160,16 @@ H3="2133604274-2140756116"
H4="2143656228-2147444225" H4="2143656228-2147444225"
# Поколение протокола и закрепление версии пакета — управляются переменными # Поколение протокола и закрепление версии пакета — управляются переменными
# окружения, задаются ДО запуска: `AWG_GEN=2.0 bash install.sh`. # окружения, задаются ДО запуска: `AWG_GEN=auto bash install.sh`.
# AWG_GEN=auto (по умолчанию) — как раньше: 3.1, если модуль 3.x и ядро ≥ 5.5, # AWG_GEN=3.1 (по умолчанию) — основное поколение: если модуль в ядре не 3.x
# иначе 2.0, без лишних вопросов. # или ядро старше 5.5, install.sh падает с диагностикой. Тихий откат на 2.0
# здесь нежелателен: он молча выдал бы сервер со слабой обфускацией.
# AWG_GEN=auto — старое поведение: 3.1, если условия выполняются, иначе 2.0
# без вопросов. Для установки на заведомо старое ядро/модуль.
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0. # AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
# AWG_GEN=3.1 — требовать 3.1: если условия не выполняются, install.sh падает # Сервер, установленный на 2.0, переводится на 3.1 позже из панели
# с диагностикой вместо тихого отката (апстрим ещё нестабилен, см. issues # (POST /awg/upgrade) — переустановка для этого не нужна.
# #215/#216/#217 в amneziawg-linux-kernel-module — тихий откат на 2.0 здесь AWG_GEN="${AWG_GEN:-3.1}"
# нежелателен, если оператор явно просил 3.1).
AWG_GEN="${AWG_GEN:-auto}"
[[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)" [[ "$AWG_GEN" =~ ^(auto|2\.0|3\.1)$ ]] || fail "AWG_GEN должен быть auto, 2.0 или 3.1 (задано: $AWG_GEN)"
# AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools} # AWG_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым # через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
@@ -189,13 +216,12 @@ start_logging() {
# Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые # Скачать архив версии VERSION, распаковать в PROJECT и проверить, что ключевые
# файлы на месте. # файлы на месте.
download_extract() { download_extract() {
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz" local tmp="/tmp/${ARCHIVE_NAME}"
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
local keep_tarball=0 local keep_tarball=0
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах GitHub # AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах (или
# (или встать без сети). Архив должен быть той же формы, что делает CI: # встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/, без node_modules. # awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/ + .env, без node_modules.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \ [[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL" || fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
@@ -204,10 +230,25 @@ download_extract() {
echo " → локальный архив: $tmp" echo " → локальный архив: $tmp"
ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)" ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)"
else else
local urls=()
if [[ -n "$ARCHIVE_URL" ]]; then
urls=("$ARCHIVE_URL")
else
urls=("$REPO_BASE/releases/download/v${VERSION}/${ARCHIVE_NAME}")
[[ "$REPO_FALLBACK" != "$REPO_BASE" ]] \
&& urls+=("$REPO_FALLBACK/releases/download/v${VERSION}/${ARCHIVE_NAME}")
fi
echo " → версия: ${VERSION}" echo " → версия: ${VERSION}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ local url got=""
|| fail "Не удалось скачать архив: $url" for url in "${urls[@]}"; do
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" if curl -fsSL --connect-timeout 15 "$url" -o "$tmp"; then got="$url"; break; fi
warn "Архив недоступен на $(url_host "$url") — пробуем следующий источник"
done
[[ -n "$got" ]] || fail "Не удалось скачать архив ни с одного источника:
$(printf ' %s\n' "${urls[@]}")
Укажи свой: ARCHIVE_URL=https://…/${ARCHIVE_NAME} bash install.sh"
ok "Архив скачан с $(url_host "$got"): $(du -sh "$tmp" | cut -f1)"
fi fi
mkdir -p "$PROJECT" mkdir -p "$PROJECT"
@@ -261,7 +302,7 @@ setup_service() {
node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)") node_dir=$(dirname "$(command -v node 2>/dev/null || echo /usr/bin/node)")
cat > "$SERVICE_UNIT" <<UNIT cat > "$SERVICE_UNIT" <<UNIT
[Unit] [Unit]
Description=AWG Control — awg-ctrl + awg-ui (Forgetting) Description=AWG Control — awg-ctrl + awg-ui (${BRAND})
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -357,26 +398,8 @@ AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 ))
read -rp " UI port (Enter — случайный): " UI_PORT read -rp " UI port (Enter — случайный): " UI_PORT
UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}" UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}"
# Интеграция с API MA7 (см. README.md, раздел «Интеграция с MA7») — этот read -rp " UI логин [admin]: " UI_USER
# сервер персональный, панель показывает баланс своего единственного UI_USER="${UI_USER:-admin}"
# владельца. Спрашивается ДО логина панели: логин MA7 по умолчанию становится
# и логином панели (см. ниже). Пустые значения допустимы — вкладку «Профиль»
# можно настроить позже вручную в cli.env.
echo
echo -e "${BLD}Интеграция с MA7 (Enter — оставить пустым и настроить позже):${NC}"
read -rp " MA7 API base URL [https://amnesia.ma7neko.ru]: " MA7_API_BASE_URL
MA7_API_BASE_URL="${MA7_API_BASE_URL:-https://amnesia.ma7neko.ru}"
read -rsp " MA7 JWT_SECRET (мастер-ключ MA7, см. предупреждение в README): " MA7_JWT_SECRET; echo
read -rp " Логин заказчика в MA7 (ma7_xxxxxx, владелец этого сервера): " MA7_LOGIN
echo
# Логин панели по умолчанию = логин заказчика в MA7: сервер персональный, и
# заказчик входит тем же именем, что знает по MA7. Если MA7_LOGIN не задан —
# откатываемся на admin. UI_USER и MA7_LOGIN остаются разными переменными:
# первая решает, кого пускать в панель, вторая — чей баланс показывать.
UI_USER_DEFAULT="${MA7_LOGIN:-admin}"
read -rp " UI логин [$UI_USER_DEFAULT]: " UI_USER
UI_USER="${UI_USER:-$UI_USER_DEFAULT}"
SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6) SUGGESTED_PASS=$(tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c 12 2>/dev/null || openssl rand -hex 6)
read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo
@@ -513,11 +536,11 @@ fi
# - ядро: header protection использует библиотечный chacha-API # - ядро: header protection использует библиотечный chacha-API
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
# Если 3.1 недоступен и AWG_GEN=auto — не падаем, а пишем 2.0-конфиг: awg-ctrl # При AWG_GEN=3.1 (по умолчанию) недоступность — fail с диагностикой: тихий
# определяет поколение по наличию HeaderProtectionKey в конфиге, так что всё # откат подсунул бы более слабую обфускацию незаметно для оператора.
# продолжит работать ровно как раньше. При AWG_GEN=3.1 недоступность — fail # При AWG_GEN=auto не падаем, а пишем 2.0-конфиг: awg-ctrl определяет поколение
# (см. AWG_GEN выше): апстрим модуля ещё нестабилен, тихий откат при явном # по наличию HeaderProtectionKey в конфиге, так что всё продолжит работать ровно
# запросе 3.1 может незаметно подсунуть более слабую обфускацию. # как раньше, а перейти на 3.1 можно потом из панели.
kernel_lt_5_5() { kernel_lt_5_5() {
local maj min local maj min
@@ -576,7 +599,8 @@ else
if [[ "${MOD_VER%%.*}" != "3" ]]; then if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n" AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then if [[ "$AWG_GEN" == "3.1" ]]; then
fail "AWG_GEN=3.1 запрошен явно, но в ядре модуль версии '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)) — нужна 3.x." fail "Нужен модуль amneziawg 3.x, а в ядре версия '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)).
Перезагрузи сервер и запусти установку снова — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi fi
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
[[ "$(disk_mod_ver)" == 3.* ]] && \ [[ "$(disk_mod_ver)" == 3.* ]] && \
@@ -584,7 +608,8 @@ else
elif kernel_lt_5_5; then elif kernel_lt_5_5; then
AWG3="n" AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then if [[ "$AWG_GEN" == "3.1" ]]; then
fail "AWG_GEN=3.1 запрошен явно, но ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." fail "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210).
Обнови ядро — или поставь на 2.0: AWG_GEN=auto bash install.sh"
fi fi
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
warn "Ставим сервер на AmneziaWG 2.0." warn "Ставим сервер на AmneziaWG 2.0."
@@ -594,7 +619,8 @@ fi
if [[ "$AWG3" == "y" ]]; then if [[ "$AWG3" == "y" ]]; then
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)" ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
else else
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль и переустанови, чтобы перейти на 3.1." warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль — и переходи на 3.1"
warn "кнопкой «Перейти на AWG 3.1» в панели, переустановка не нужна."
fi fi
step "2/7 Node.js" step "2/7 Node.js"
@@ -762,7 +788,8 @@ if ! awg-quick up "$IFACE"; then
AWG3="n"; NEW_GEN="2.0" AWG3="n"; NEW_GEN="2.0"
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31" warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
[[ "$AWG_GEN" == "3.1" ]] && \ [[ "$AWG_GEN" == "3.1" ]] && \
warn "Запрошено AWG_GEN=3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше." warn "Модуль и ядро подходят под 3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше."
warn "Разберись с причиной и повтори переход кнопкой «Перейти на AWG 3.1» в панели."
awg-quick up "$IFACE" \ awg-quick up "$IFACE" \
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше" || fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
@@ -851,13 +878,13 @@ UI_PORT=${UI_PORT}
UI_USER=${UI_USER} UI_USER=${UI_USER}
UI_PASS=${UI_PASS} UI_PASS=${UI_PASS}
JWT_SECRET=${JWT_SECRET} JWT_SECRET=${JWT_SECRET}
# ── интеграция с MA7 (секрет чужого сервиса, не путать с JWT_SECRET выше) ──
MA7_API_BASE_URL=${MA7_API_BASE_URL}
MA7_JWT_SECRET=${MA7_JWT_SECRET}
MA7_LOGIN=${MA7_LOGIN}
ENV ENV
# Имя продукта попадает в cli.env, только если оператор задал его явно
# (BRAND=… перед запуском). Иначе оно берётся из .env, приехавшего в архиве, —
# чтобы источник правды оставался один.
[[ -n "$BRAND_OVERRIDE" ]] && echo "BRAND=${BRAND_OVERRIDE}" >> "$PROJECT/cli/cli.env"
chmod 600 "$PROJECT/cli/cli.env" chmod 600 "$PROJECT/cli/cli.env"
ok "$PROJECT/cli/cli.env" ok "$PROJECT/cli/cli.env"