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

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

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

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

Version 0.1.4.1.
2026-08-17 18:48:38 +03:00
17 changed files with 380 additions and 538 deletions
+26 -91
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 (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. | | **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), интеграция с API MA7, прокси к 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 проверяет подпись публичным.
@@ -42,103 +42,38 @@ awg-ctrl
systemctl start|stop|restart awg-control systemctl start|stop|restart awg-control
``` ```
## 1. Публичный API (`/api/v1`) ## Интеграция с MA7
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные Этот сервер персональный — принадлежит одному абоненту MA7 (сервиса
ключи и PSK наружу не отдаются. VPN-подписки). Панель ничем не управляет в MA7 и не отдаёт наружу свой API:
вкладка «Профиль» только читает баланс своего владельца. Секрет MA7 живёт
только в `awg-ui` (`cli.env`) и в браузер не попадает — фронт ходит на свой
бэкенд, тот читает MA7.
### Авторизация Нужные переменные (задаются при установке или руками в `cli.env`):
Каждый запрос обязан содержать заголовок: | Переменная | Назначение |
```
X-Api-Key: awgk_<...>
```
Ключ создаётся в панели (вкладка «API-ключи») или через `POST /ui/apikeys`
(раздел 3). Открытое значение ключа показывается **один раз** при создании —
сохрани его сразу, дальше доступен только хэш.
Ответы при проблемах с ключом:
| Код | Когда |
|---|---| |---|---|
| `401 {"error":"API key required"}` | заголовок отсутствует или не начинается с `awgk_` | | `MA7_API_BASE_URL` | адрес API MA7, например `https://amnesia.ma7neko.ru` |
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) | | `MA7_JWT_SECRET` | секрет, которым панель сама подписывает `X-Bot-Token` (эндпоинта, выдающего токен, в MA7 нет) |
| `MA7_LOGIN` | логин владельца этого сервера в MA7 (`ma7_xxxxxx`) |
> Ключ привязан к серверу (`server_id`). Сейчас сервер один — поле всегда `0` Вкладка «Профиль» просто показывает текущий баланс `MA7_LOGIN`. При установке
`MA7_LOGIN` заодно предлагается как логин панели (`UI_USER`) — сервер
персональный, и заказчик входит тем же именем, что знает по MA7; при желании
логин панели можно задать свой. Это разные переменные: `UI_USER` решает, кого
пускать в панель, `MA7_LOGIN` — чей баланс показывать.
### `POST /api/v1/users` — создать пользователя `MA7_LOGIN` задаётся один раз при установке и **не редактируется из панели**: у панели
один общий пароль (`UI_USER`/`UI_PASS`) без отдельного клиентского входа, а
`MA7_JWT_SECRET` умеет искать в MA7 любого абонента — редактируемый в браузере
логин превратил бы личный баланс в инструмент подглядывания за чужими.
Создаёт VPN-пира и возвращает готовый `vpn://` ключ. > [!WARNING]
> `MA7_JWT_SECRET` — мастер-ключ ко всему MA7: с ним можно читать данные любых
> пользователей MA7, не только этого сервера. Токены MA7 без срока действия,
> отзыва нет — «отобрать доступ» значит одновременно сменить `MA7_JWT_SECRET`
> во всех сервисах MA7 и здесь, в `cli.env`.
**Тело:** `{ "name": "<имя>" }`
Имя: `a–z A–Z 0–9 _ -`, 1–32 символа.
**`201 Created`:**
```json
{
"name": "alice",
"ip": "10.9.0.2",
"vpn_key": "vpn://AAAI..."
}
```
| Код ошибки | Причина |
|---|---|
| `400` | имя не прошло валидацию |
| `409` | пользователь с таким именем уже есть |
```bash
curl -X POST http://HOST:PORT/api/v1/users \
-H "X-Api-Key: awgk_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"alice"}'
```
### `GET /api/v1/users` — список пользователей
Пиры с трафиком и статусом онлайн.
**`200 OK`:**
```json
{
"users": [
{ "name": "alice", "ip": "10.9.0.2", "online": true, "lastHandshake": 1718800000, "rx": 142400000, "tx": 9800000 },
{ "name": "bob", "ip": "10.9.0.3", "online": false, "lastHandshake": 0, "rx": 0, "tx": 0 }
]
}
```
- `lastHandshake` — unix-секунды последнего рукопожатия (`0` — не было).
- `rx`/`tx` — байты принято/отдано.
```bash
curl http://HOST:PORT/api/v1/users -H "X-Api-Key: awgk_xxx"
```
### `GET /api/v1/users/:name` — получить ключ пользователя
**`200 OK`:**
```json
{ "name": "alice", "ip": "10.9.0.2", "vpn_key": "vpn://AAAI..." }
```
| Код ошибки | Причина |
|---|---|
| `404` | пользователь не найден |
```bash
curl http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
> Имея `vpn_key`, конфиг `.conf` можно получить локально: убрать префикс
> `vpn://`, base64url-декодировать, отбросить первые 4 байта (длина), распаковать
> `zlib inflate` → JSON; текст конфига лежит в `last_config.config`.
### `DELETE /api/v1/users/:name` — удалить пользователя
**`200 OK`:**
```json
{ "success": true, "name": "alice" }
```
```bash
curl -X DELETE http://HOST:PORT/api/v1/users/alice -H "X-Api-Key: awgk_xxx"
```
## Лицензия ## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev [MIT](LICENSE) © 2026 Ivan Vasilev
+32 -2
View File
@@ -196,6 +196,28 @@ function genOf(p: AwgParams): "2" | "3.1" {
return p.HeaderProtectionKey ? "3.1" : "2"; return p.HeaderProtectionKey ? "3.1" : "2";
} }
// Версия модуля/tools для /health — показывает оператору, что реально работает
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
// это единственные моменты, когда модуль может реально смениться в течение
// жизни процесса; из обработчика /health не вызывается — этот роут без
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
function readAwgVersions(): { module: string; tools: string } {
let module = "";
try {
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
} catch {
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
}
let tools = "";
try {
const out = run("awg --version");
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
} catch { /* awg-tools не найден */ }
return { module, tools };
}
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN); // PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг. // в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" }; const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
@@ -203,6 +225,7 @@ const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35
const runtimeConfig = initConfig(); const runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams(); const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS); const AWG_GEN = genOf(AWG_PARAMS);
const AWG_VERSIONS = readAwgVersions();
const CONFIG = { const CONFIG = {
interface: "awg1", interface: "awg1",
confDir: "/etc/amnezia/amneziawg", confDir: "/etc/amnezia/amneziawg",
@@ -216,6 +239,10 @@ const CONFIG = {
keepalive: KEEPALIVE_BY_GEN[AWG_GEN], keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: AWG_PARAMS, awgParams: AWG_PARAMS,
gen: AWG_GEN, gen: AWG_GEN,
// Мутируются в restartAwg() — единственном месте, где модуль может
// реально смениться в течение жизни процесса.
awgModule: AWG_VERSIONS.module,
awgTools: AWG_VERSIONS.tools,
}; };
interface UserRow { interface UserRow {
@@ -582,7 +609,10 @@ function restartAwg() {
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()}`);
syncPeers(); syncPeers();
logger.info("AWG restart: done"); const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
logger.info("AWG restart: done", versions);
} }
function startInterface() { function startInterface() {
@@ -697,7 +727,7 @@ app.get("/health", (_req, res) => {
server: CONFIG.serverName, server: CONFIG.serverName,
ip: CONFIG.serverIp, ip: CONFIG.serverIp,
gen: CONFIG.gen, gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers }, awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
}); });
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-control", "name": "awg-control",
"version": "0.1.4", "version": "0.1.4+2",
"description": "", "description": "",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-ui", "name": "awg-ui",
"version": "0.1.4", "version": "0.1.4+2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,14 +12,14 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.0", "axios": "^1.7.0",
"better-sqlite3": "^12.10.0", "jsonwebtoken": "^9.0.0",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
"react": "^18.3.0", "react": "^18.3.0",
"react-dom": "^18.3.0" "react-dom": "^18.3.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.0",
"@types/qrcode": "^1.5.0", "@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0", "@types/express": "^4.17.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+30 -108
View File
@@ -6,7 +6,7 @@ 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 Database from "better-sqlite3"; import { ma7 } from "./ma7";
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 8080; const PORT = Number(process.env.PORT) || 8080;
@@ -37,43 +37,6 @@ function mintInternalToken(): string {
const revoked = new Set<string>(); const revoked = new Set<string>();
interface ApiKeyRow {
id: number; label: string; key_hash: string; prefix: string;
server_id: number; created_at: number; last_used: number | null;
}
const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db");
const uidb = new Database(UI_DB_FILE);
uidb.pragma("journal_mode = WAL");
uidb.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
function genApiKey(): { key: string; hash: string; prefix: string } {
const key = "awgk_" + crypto.randomBytes(24).toString("base64url");
return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" };
}
function tokenSig(token: string): string { return token.split(".")[2] ?? token; } function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
function sign(): string { function sign(): string {
@@ -146,29 +109,6 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() });
});
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id);
res.json({ success: true, id });
});
async function proxy(req: Request, res: Response) { async function proxy(req: Request, res: Response) {
try { try {
const r = await axios({ const r = await axios({
@@ -184,22 +124,6 @@ async function proxy(req: Request, res: Response) {
} }
} }
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
const key = (req.headers["x-api-key"] as string) ?? "";
if (!key.startsWith("awgk_")) { res.status(401).json({ error: "API key required" }); return; }
const row = keyStmts.byHash.get(hashKey(key)) as ApiKeyRow | undefined;
if (!row) { res.status(401).json({ error: "Invalid API key" }); return; }
keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id);
(req as ExtRequest).apiKey = row;
next();
}
async function ctrl(method: string, urlPath: string, body?: unknown) { async function ctrl(method: string, urlPath: string, body?: unknown) {
return axios({ return axios({
method, method,
@@ -210,40 +134,38 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
}); });
} }
// Внешний контракт: наружу отдаём только name/ip/gen/vpn_key — psk_key и pub_key // ── Интеграция с MA7 (вкладка «Профиль») ───────────────────────────────────
// остаются внутри. gen — поколение AmneziaWG, на параметрах которого выдан ключ. // Сервер персональный — панель показывает баланс своего единственного
interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string } // владельца, не список абонентов. Логин владельца (MA7_LOGIN) — часть
// конфигурации сервера, задаётся один раз при установке (cli.env), а НЕ
// вводится через UI: бот-токен MA7_JWT_SECRET умеет искать любого абонента
// MA7, и если бы логин можно было менять прямо в панели, любой, кто знает
// пароль от панели, мог бы подсматривать баланс чужих абонентов — панель
// защищена одним общим UI_USER/UI_PASS, отдельного «клиентского» входа нет.
const MA7_LOGIN = process.env.MA7_LOGIN ?? "";
const ext = express.Router(); app.get("/ui/ma7/profile", requireAuth, async (_req: Request, res: Response) => {
ext.use(requireApiKey); if (!/^[a-zA-Z0-9_-]{1,32}$/.test(MA7_LOGIN)) {
res.status(500).json({ error: "MA7_LOGIN не задан — укажи его в cli.env" }); return;
ext.post("/users", async (req: Request, res: Response) => { }
const { name } = (req.body ?? {}) as { name?: string }; try {
const r = await ctrl("POST", "/api/users", { name }); const r = await ma7.get("/api/admin/users", { params: { q: MA7_LOGIN, limit: 1 } });
if (r.status >= 400) { res.status(r.status).json(r.data); return; } if (r.status >= 400) {
const u = r.data as ExtUser; const msg = r.status === 401 ? "MA7: неверный MA7_JWT_SECRET"
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); : 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 недоступен" });
}
}); });
ext.get("/users", async (_req: Request, res: Response) => {
const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data);
});
ext.get("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
});
ext.delete("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
});
app.use("/api/v1", ext);
app.use(["/api", "/health", "/awg"], requireAuth, proxy); app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist"))); app.use(express.static(path.join(__dirname, "dist")));
+8 -19
View File
@@ -406,6 +406,13 @@ body {
color: var(--neutral-text); color: var(--neutral-text);
line-height: 1.2; line-height: 1.2;
} }
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-gen-chip {
align-self: flex-start;
margin-top: 2px;
font-size: 11px;
padding: 2px 8px;
}
.server-card-peers { .server-card-peers {
font-size: 12px; font-size: 12px;
color: var(--neutral-text); color: var(--neutral-text);
@@ -466,22 +473,6 @@ body {
} }
.tip-wrap:hover::after { opacity: 1; } .tip-wrap:hover::after { opacity: 1; }
.server-add {
display: inline-flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 16px;
border-radius: var(--r-pill);
border: 1.5px dashed var(--outline);
background: transparent;
font-family: var(--font);
font-size: 13px;
color: var(--neutral-dot);
cursor: not-allowed;
white-space: nowrap;
}
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -666,11 +657,9 @@ tbody td:last-child { padding-right: 20px; }
.server-toggle { display: flex; } .server-toggle { display: flex; }
.server-online-chip { display: none; } .server-online-chip { display: none; }
/* Кнопка «Добавить сервер» — полная ширина */
.tip-wrap { width: 100%; } .tip-wrap { width: 100%; }
.server-add { width: 100%; justify-content: center; }
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */ /* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
.awg-restart { display: none; } .awg-restart { display: none; }
.awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; } .awg-restart-mobile { display: inline-flex; width: 100%; justify-content: center; }
+5 -3
View File
@@ -36,7 +36,10 @@ export default function App() {
// вкладок (юзеры, ключи) грузят сами вкладки. // вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => { const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2' }); setServerInfo({
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
}, []); }, []);
@@ -214,7 +217,7 @@ export default function App() {
> >
<div className="sidebar-logo"> <div className="sidebar-logo">
<span className="logo-name">Forgetting</span> <span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.4</span> <span className="sidebar-version">Alpha 0.1.4.2</span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS.map(t => ( {TABS.map(t => (
@@ -243,7 +246,6 @@ export default function App() {
serverOnline={isOnline} serverOnline={isOnline}
onRestartAwg={restartAwg} onRestartAwg={restartAwg}
restarting={restarting} restarting={restarting}
showAddServer={chrome.addServer !== false}
showRestart={showRestart} showRestart={showRestart}
/> />
)} )}
+16 -16
View File
@@ -1,23 +1,20 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { type ServerInfo } from '../lib/shared'; import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoPlus, IcoRefresh } from './icons'; import { IcoRefresh } from './icons';
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной) // Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает) // ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// сервер, к которому относится содержимое вкладки (пиры / API-ключи). // вкладки (пиры / абоненты).
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
// станет несколько — карточки станут кликабельными, активная = выбранная.
export default function ServerBar({ export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting, serverInfo, serverOnline, onRestartAwg, restarting,
showAddServer = true, showRestart = true, showRestart = true,
}: { }: {
serverInfo: ServerInfo | null; serverInfo: ServerInfo | null;
serverOnline: boolean; serverOnline: boolean;
onRestartAwg: () => void; onRestartAwg: () => void;
restarting: boolean; restarting: boolean;
showAddServer?: boolean;
showRestart?: boolean; showRestart?: boolean;
}) { }) {
return ( return (
@@ -38,17 +35,20 @@ export default function ServerBar({
<div className="server-card-info"> <div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span> <span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span> <span className="server-card-ip">{serverInfo.ip}</span>
{(serverInfo.mod || serverInfo.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
)}
</div> </div>
<span className="server-card-peers">{serverInfo.peers} peers</span> <span className="server-card-peers">{serverInfo.peers} peers</span>
</div> </div>
)} )}
{showAddServer && (
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
<button className="server-add" disabled>
<IcoPlus /> Добавить сервер
</button>
</span>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && ( {showRestart && (
<button <button
+5 -8
View File
@@ -21,15 +21,12 @@ export interface ServerInfo {
ip: string; ip: string;
peers: number; peers: number;
gen: AwgGen; gen: AwgGen;
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
tools: string; // версия awg-tools, из /health awg.tools
} }
export interface ApiKey { // На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
id: number; export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
label: string;
prefix: string;
server_id: number;
created_at: number;
last_used: number | null;
}
export interface PageProps { export interface PageProps {
token: string; token: string;
showMsg: (text: string) => void; showMsg: (text: string) => void;
-77
View File
@@ -1,77 +0,0 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
-148
View File
@@ -1,148 +0,0 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
-1
View File
@@ -18,7 +18,6 @@ import { ICONS } from '../components/icons';
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true. // Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
export interface TabChrome { export interface TabChrome {
serverBar?: boolean; serverBar?: boolean;
addServer?: boolean;
restart?: boolean; restart?: boolean;
} }
+5 -5
View File
@@ -4,7 +4,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen, type User, type PageProps, type AwgGen,
} from '../../lib/shared'; } from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
@@ -50,7 +50,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ. // IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => { const reissueKeys = useCallback(async () => {
if (!confirm( if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${serverGen}?\n\n` + `Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.', 'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return; )) return;
try { try {
@@ -162,11 +162,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
{u.key_gen !== serverGen && ( {u.key_gen !== serverGen && (
<span <span
className="tip-wrap user-gen" className="tip-wrap user-gen"
data-tip={`Ключ выдан на AWG ${u.key_gen}, сервер работает на ${serverGen}`} data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
> >
<span className="chip chip--error"> <span className="chip chip--error">
<span className="chip-dot" /> <span className="chip-dot" />
AWG {u.key_gen} {genLabel(u.key_gen)}
</span> </span>
</span> </span>
)} )}
@@ -207,7 +207,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
{u.key_gen !== serverGen && ( {u.key_gen !== serverGen && (
<span className="chip chip--error"> <span className="chip chip--error">
<span className="chip-dot" /> <span className="chip-dot" />
AWG {u.key_gen} {genLabel(u.key_gen)}
</span> </span>
)} )}
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "awg-cli", "name": "awg-cli",
"version": "0.1.4", "version": "0.1.4+2",
"private": true, "private": true,
"dependencies": { "dependencies": {
"tsx": "^4.7.0", "tsx": "^4.7.0",
+38 -6
View File
@@ -74,8 +74,13 @@ const SERVICES = {
UI_USER: process.env.UI_USER ?? "admin", UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "", UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "", JWT_SECRET: process.env.JWT_SECRET ?? "",
// ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку. // Интеграция с API MA7 (см. awg-ui/ma7.ts) — секрет чужого сервиса,
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db", // не совпадает с 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"),
@@ -196,7 +201,27 @@ async function restart(name: SvcName) {
await start(name); await start(name);
} }
function status() { // GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
// молча даёт null — health-строка в status() тогда просто не печатается.
interface HealthBody {
gen?: string;
awg?: { module?: string; tools?: string };
}
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
return new Promise(resolve => {
const req = http.get(url, res => {
let body = "";
res.on("data", (c: Buffer) => { body += c; });
res.on("end", () => {
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
});
});
req.on("error", () => resolve(null));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
});
}
async function status() {
console.log(""); console.log("");
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
@@ -205,6 +230,13 @@ function status() {
const info = running ? green("running") + ` pid ${pid}` : grey("stopped"); const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`); console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
} }
if (isRunning("awgctrl")) {
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
if (h?.gen) {
const gen = h.gen === "3.1" ? "3.1" : "2.0";
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
}
}
console.log(""); console.log("");
} }
@@ -287,7 +319,7 @@ async function interactiveMenu() {
const printMenu = () => { const printMenu = () => {
console.clear(); console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4 ──${C.reset}`)}\n`); console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.2 ──${C.reset}`)}\n`);
for (const name of ALL) { for (const name of ALL) {
const running = isRunning(name); const running = isRunning(name);
@@ -321,7 +353,7 @@ async function interactiveMenu() {
case "1": await startAll(); break; case "1": await startAll(); break;
case "2": await stopAll(); break; case "2": await stopAll(); break;
case "3": await restartAll(); break; case "3": await restartAll(); break;
case "4": status(); break; case "4": await status(); break;
case "5": await setCredentials("user"); break; case "5": await setCredentials("user"); break;
case "6": await setCredentials("pass"); break; case "6": await setCredentials("pass"); break;
case "0": rl.close(); process.exit(0); case "0": rl.close(); process.exit(0);
@@ -350,7 +382,7 @@ async function main() {
case "start": await startAll(); break; case "start": await startAll(); break;
case "stop": await stopAll(); break; case "stop": await stopAll(); break;
case "restart": await restartAll(); break; case "restart": await restartAll(); break;
case "status": status(); break; case "status": await status(); break;
case "credentials": case "credentials":
if (sub === "user") await setCredentials("user"); if (sub === "user") await setCredentials("user");
else if (sub === "pass") await setCredentials("pass"); else if (sub === "pass") await setCredentials("pass");
+193 -26
View File
@@ -19,7 +19,7 @@ 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" VERSION="0.1.4.2"
GH_REPO="maeneko/forgetting" GH_REPO="maeneko/forgetting"
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}" BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
PROJECT="/opt/awg-control" PROJECT="/opt/awg-control"
@@ -29,7 +29,6 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key"
PUB_KEY_FILE="$AWG_DIR/server_public.key" PUB_KEY_FILE="$AWG_DIR/server_public.key"
AWG_CONF="$AWG_DIR/awg1.conf" AWG_CONF="$AWG_DIR/awg1.conf"
DB_FILE="$AWG_DIR/users.db" DB_FILE="$AWG_DIR/users.db"
UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl. # Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key" INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key" INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
@@ -134,6 +133,22 @@ H2="2122053282-2133204808"
H3="2133604274-2140756116" H3="2133604274-2140756116"
H4="2143656228-2147444225" H4="2143656228-2147444225"
# Поколение протокола и закрепление версии пакета — управляются переменными
# окружения, задаются ДО запуска: `AWG_GEN=2.0 bash install.sh`.
# AWG_GEN=auto (по умолчанию) — как раньше: 3.1, если модуль 3.x и ядро ≥ 5.5,
# иначе 2.0, без лишних вопросов.
# AWG_GEN=2.0 — не трогать модуль (ни апгрейд, ни перезагрузка), сразу 2.0.
# AWG_GEN=3.1 — требовать 3.1: если условия не выполняются, install.sh падает
# с диагностикой вместо тихого отката (апстрим ещё нестабилен, см. issues
# #215/#216/#217 в amneziawg-linux-kernel-module — тихий откат на 2.0 здесь
# нежелателен, если оператор явно просил 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_PIN=y (по умолчанию) — после успешной установки держит amneziawg{,-dkms,-tools}
# через `apt-mark hold`: unattended-upgrades не должен подменять модуль под живым
# сервером (issue #215 — именно так словили регрессию между dkms-сборками).
AWG_PIN="${AWG_PIN:-y}"
NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}') NET_IFACE=$(ip route show default 2>/dev/null | awk '/default/{print $5; exit}')
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс" [[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
@@ -176,15 +191,29 @@ start_logging() {
download_extract() { download_extract() {
local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz" local url="$BASE_URL/awgcontrol-${VERSION}.tar.gz"
local tmp="/tmp/awgcontrol-${VERSION}.tar.gz" local tmp="/tmp/awgcontrol-${VERSION}.tar.gz"
local keep_tarball=0
# AWG_LOCAL_TARBALL — поставить сборку, которой ещё нет в релизах GitHub
# (или встать без сети). Архив должен быть той же формы, что делает CI:
# awg-ctrl/ + awg-ui/ (с готовым dist/) + cli/, без node_modules.
if [[ -n "${AWG_LOCAL_TARBALL:-}" ]]; then
[[ -f "$AWG_LOCAL_TARBALL" ]] \
|| fail "AWG_LOCAL_TARBALL указан, но файл не найден: $AWG_LOCAL_TARBALL"
tmp="$AWG_LOCAL_TARBALL"
keep_tarball=1 # чужой файл — после распаковки не удаляем
echo " → локальный архив: $tmp"
ok "Архив взят локально: $(du -sh "$tmp" | cut -f1)"
else
echo " → версия: ${VERSION}" echo " → версия: ${VERSION}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \ curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|| fail "Не удалось скачать архив: $url" || fail "Не удалось скачать архив: $url"
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)" ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
fi
mkdir -p "$PROJECT" mkdir -p "$PROJECT"
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \ tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив" || fail "Не удалось распаковать архив"
rm -f "$tmp" [[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT" ok "Распакован → $PROJECT"
local f local f
@@ -328,8 +357,26 @@ 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 ))}"
read -rp " UI логин [admin]: " UI_USER # Интеграция с API MA7 (см. README.md, раздел «Интеграция с MA7») — этот
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
@@ -418,9 +465,18 @@ else
fi fi
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build" ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
# Если репозиторий amnezia уже настроен (например, ключ и источник заведены
# руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически
# отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно.
# Проверяем именно файлы источников, а не `apt-cache policy`: сразу после
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
else
echo " → add-apt-repository ppa:amnezia/ppa" echo " → add-apt-repository ppa:amnezia/ppa"
add-apt-repository -y ppa:amnezia/ppa \ add-apt-repository -y ppa:amnezia/ppa \
|| fail "Не удалось добавить PPA ppa:amnezia/ppa" || fail "Не удалось добавить PPA ppa:amnezia/ppa"
fi
echo " → apt-get update (после PPA)" echo " → apt-get update (после PPA)"
apt-get update \ apt-get update \
@@ -457,10 +513,11 @@ fi
# - ядро: header protection использует библиотечный chacha-API # - ядро: header protection использует библиотечный chacha-API
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся # (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена. # (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
# Если 3.1 недоступен — не падаем, а пишем 2.0-конфиг: awg-ctrl определяет # Если 3.1 недоступен и AWG_GEN=auto — не падаем, а пишем 2.0-конфиг: awg-ctrl
# поколение по наличию HeaderProtectionKey в конфиге, так что всё продолжит # определяет поколение по наличию HeaderProtectionKey в конфиге, так что всё
# работать ровно как раньше. # продолжит работать ровно как раньше. При AWG_GEN=3.1 недоступность — fail
AWG3="y" # (см. AWG_GEN выше): апстрим модуля ещё нестабилен, тихий откат при явном
# запросе 3.1 может незаметно подсунуть более слабую обфускацию.
kernel_lt_5_5() { kernel_lt_5_5() {
local maj min local maj min
@@ -469,22 +526,69 @@ kernel_lt_5_5() {
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]] [[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
} }
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") # 🛑 Версий у модуля две, и путать их нельзя:
if [[ "${MOD_VER%%.*}" != "3" ]]; then # modinfo — что лежит на диске (после apt/DKMS);
# /sys/module/amneziawg/version — что реально загружено в ядро.
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
if [[ "$AWG_GEN" == "2.0" ]]; then
AWG3="n"
MOD_VER=$(disk_mod_ver)
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
else
AWG3="y"
MOD_VER=$(disk_mod_ver)
if [[ "${MOD_VER%%.*}" != "3" ]]; then
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить." warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
# снимаем, иначе --only-upgrade молча не тронет held-версию.
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
apt-get update >/dev/null 2>&1 || true apt-get update >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \ DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
MOD_VER=$(modinfo -F version amneziawg 2>/dev/null || echo "") MOD_VER=$(disk_mod_ver)
fi fi
if [[ "${MOD_VER%%.*}" != "3" ]]; then # Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
LOADED_VER=$(loaded_mod_ver)
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
awg-quick down "$IFACE" 2>/dev/null || true
ip link delete dev "$IFACE" 2>/dev/null || true
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
LOADED_VER=$(loaded_mod_ver)
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
else
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
fi
fi
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n" AWG3="n"
warn "Модуль остался на версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0." if [[ "$AWG_GEN" == "3.1" ]]; then
elif kernel_lt_5_5; then fail "AWG_GEN=3.1 запрошен явно, но в ядре модуль версии '${MOD_VER:-неизвестно}' (на диске $(disk_mod_ver)) — нужна 3.x."
fi
warn "В ядре модуль версии '${MOD_VER:-неизвестно}' — ставим сервер на AmneziaWG 2.0."
[[ "$(disk_mod_ver)" == 3.* ]] && \
warn "На диске уже 3.x — после перезагрузки сервера можно переустановить и получить 3.1."
elif kernel_lt_5_5; then
AWG3="n" AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then
fail "AWG_GEN=3.1 запрошен явно, но ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
fi
warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)." warn "Ядро $KERNEL старше 5.5 — header protection не соберётся (upstream issue #210)."
warn "Ставим сервер на AmneziaWG 2.0." warn "Ставим сервер на AmneziaWG 2.0."
fi
fi fi
if [[ "$AWG3" == "y" ]]; then if [[ "$AWG3" == "y" ]]; then
@@ -528,15 +632,6 @@ else
mv "$DB_FILE" "$DB_BAK" mv "$DB_FILE" "$DB_BAK"
warn "Старая база пользователей сохранена: $DB_BAK" warn "Старая база пользователей сохранена: $DB_BAK"
fi fi
# ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД.
if [[ -f "$UI_DB_FILE" ]]; then
UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
mv "$UI_DB_FILE" "$UI_DB_BAK"
# WAL-сайдкары удаляем — к новой БД они неприменимы.
rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm"
warn "Старая база API-ключей сохранена: $UI_DB_BAK"
fi
PRIV_KEY=$(umask 077 && awg genkey) PRIV_KEY=$(umask 077 && awg genkey)
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey) PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
@@ -637,7 +732,43 @@ if awg show "$IFACE" &>/dev/null; then
awg-quick down "$IFACE" 2>/dev/null || true awg-quick down "$IFACE" 2>/dev/null || true
fi fi
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой" # Подъём интерфейса — единственная настоящая проверка, что ядро приняло
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
if ! awg-quick up "$IFACE"; then
ip link delete dev "$IFACE" 2>/dev/null || true
if [[ "$AWG3" != "y" ]]; then
fail "awg-quick up $IFACE завершился с ошибкой"
fi
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
if awg-quick up "$IFACE"; then
ok "Со второй попытки интерфейс поднялся"
else
ip link delete dev "$IFACE" 2>/dev/null || true
echo " ── dmesg (последние 15 строк) ─────────────────"
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
echo " ───────────────────────────────────────────────"
cp "$AWG_CONF" "${AWG_CONF}.awg31"
sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF"
AWG3="n"; NEW_GEN="2.0"
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
[[ "$AWG_GEN" == "3.1" ]] && \
warn "Запрошено AWG_GEN=3.1, но интерфейс поднялся на 2.0 — см. ${AWG_CONF}.awg31 и dmesg выше."
awg-quick up "$IFACE" \
|| fail "awg-quick up $IFACE не работает даже на 2.0 — смотри dmesg выше"
ok "Интерфейс поднят на AmneziaWG 2.0"
fi
fi
# awg-quick не всегда применяет приватный ключ из [Interface]/PostUp # awg-quick не всегда применяет приватный ключ из [Interface]/PostUp
# (наблюдалось: awg show public-key = none сразу после up). Если ключ не # (наблюдалось: awg show public-key = none сразу после up). Если ключ не
@@ -655,6 +786,37 @@ else
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY" fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
fi fi
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
if [[ "$AWG_PIN" == "y" ]]; then
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
fi
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
echo " → UFW активен, открываем маршрут и порты"
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
ufw reload >/dev/null 2>&1 || true
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
else
echo " → UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install" step "6/7 npm install"
npm_install_all npm_install_all
@@ -689,6 +851,11 @@ 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
chmod 600 "$PROJECT/cli/cli.env" chmod 600 "$PROJECT/cli/cli.env"