Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
068cab4d24 | ||
|
|
21c7409017 | ||
|
|
ca1c8e8c68 | ||
|
|
602c0416ba | ||
|
|
18972ed58f | ||
|
|
38e76da84f |
@@ -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: |
|
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
|
||||||
|
|||||||
@@ -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
@@ -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,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": {
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
"outDir": "dist"
|
"outDir": "dist"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
""
|
"index.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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 => (
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user