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

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

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

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

Also add MIT license headers across the awg-ui sources.
2026-08-17 17:58:00 +03:00
23 changed files with 753 additions and 541 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
gh release create "${GITHUB_REF_NAME}" "awgcontrol-${VERSION}.tar.gz" \
--title "${GITHUB_REF_NAME}" --generate-notes
--title "${GITHUB_REF_NAME}" --generate-notes --prerelease
+26 -91
View File
@@ -9,7 +9,7 @@
| Компонент | Роль |
|---|---|
| **awg-ctrl** | Привилегированный REST-бэкенд. Управляет пирами через `awg`/`awg-quick`, ведёт SQLite-базу, генерирует `vpn://` ключи. Слушает только loopback. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), внешний API (`/api/v1`), прокси к awg-ctrl. Единственный сервис, доступный по сети. |
| **awg-ui** | Express + React. Веб-панель, аутентификация (JWT), интеграция с API MA7, прокси к awg-ctrl. Единственный сервис, доступный по сети. |
| **cli** | Менеджер процессов: `start`/`stop`/`restart`/`status`, смена логина/пароля панели, интерактивное TUI-меню. |
Внутренняя связь awg-ui → awg-ctrl подписывается асимметрично (Ed25519): приватный ключ только у awg-ui, awg-ctrl проверяет подпись публичным.
@@ -42,103 +42,38 @@ awg-ctrl
systemctl start|stop|restart awg-control
```
## 1. Публичный API (`/api/v1`)
## Интеграция с MA7
Авторизация по API-ключу. Ответы содержат только публичные поля — приватные
ключи и PSK наружу не отдаются.
Этот сервер персональный — принадлежит одному абоненту MA7 (сервиса
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_` |
| `401 {"error":"Invalid API key"}` | ключ не найден (удалён/неверный) |
| `MA7_API_BASE_URL` | адрес API MA7, например `https://amnesia.ma7neko.ru` |
| `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
+243 -30
View File
@@ -64,6 +64,21 @@ db.exec(`
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)");
// Миграции users: better-sqlite3 синхронный, ALTER TABLE идемпотентным не бывает,
// поэтому смотрим фактический список колонок.
// key_gen — поколение протокола, на параметрах которого выдан vpn_key
// ('2' для всего, что заведено до появления 3.1)
// vpn_key_prev — предыдущий блоб, чтобы перевыпуск можно было откатить
{
const cols = new Set(
(db.prepare("PRAGMA table_info(users)").all() as { name: string }[]).map(c => c.name),
);
if (!cols.has("key_gen"))
db.exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'");
if (!cols.has("vpn_key_prev"))
db.exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''");
}
db.exec(`
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
@@ -106,12 +121,29 @@ function initConfig() {
return { serverIp, serverPort: Number(serverPort), serverName };
}
interface AwgParams {
// Ключи, появившиеся в AmneziaWG 3.0/3.1. Пустая строка = ключ не задан, тогда
// интерфейс работает в режиме 2.0 и его нет ни в conf, ни в vpn:// ключе.
// Порядок массива = порядок строк в клиентском .conf (см. buildClientConf).
const AWG3_KEYS = [
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
] as const;
type Awg3Key = (typeof AWG3_KEYS)[number];
type AwgParams = {
Jc: number; Jmin: number; Jmax: number;
S1: number; S2: number; S3: number; S4: number;
H1: string; H2: string; H3: string; H4: string;
I1: string; I2: string; I3: string; I4: string; I5: string;
}
} & Record<Awg3Key, string>;
const DEFAULT_AWG_PARAMS: AwgParams = {
Jc: 6, Jmin: 10, Jmax: 50,
@@ -122,6 +154,11 @@ const DEFAULT_AWG_PARAMS: AwgParams = {
H4: "2143656228-2147444225",
I1: "<r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>",
I2: "", I3: "", I4: "", I5: "",
// 3.x по умолчанию выключен: без conf-а сервер остаётся на 2.0.
HeaderProtectionKey: "", ContentPaddingAddition: "",
RekeyAfterTime: "", RekeyTimeout: "", RejectAfterTime: "",
KeepaliveTimeout: "", MaxHandshakeAttempts: "",
RandomTrailers: "", DisableCookies: "",
};
function readAwgParams(): AwgParams {
@@ -134,7 +171,9 @@ function readAwgParams(): AwgParams {
const iface = readFileSync(confFile, "utf8").split(/^\[Peer\]/m)[0];
const numKeys: (keyof AwgParams)[] = ["Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"];
const strKeys: (keyof AwgParams)[] = ["H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"];
const strKeys: (keyof AwgParams)[] = [
"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5", ...AWG3_KEYS,
];
for (const k of numKeys) {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
@@ -144,11 +183,49 @@ function readAwgParams(): AwgParams {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"));
if (m) (params[k] as string) = m[1].trim();
}
logger.info("awg params loaded from conf", { Jc: params.Jc, H1: params.H1 });
logger.info("awg params loaded from conf", {
Jc: params.Jc, H1: params.H1, gen: genOf(params),
});
return params;
}
// Поколение протокола выводим из самих параметров, отдельного флага нет: conf
// остаётся единственным источником правды. Header protection — та фича, которая
// ломает совместимость с 2.0, поэтому именно она и определяет поколение.
function genOf(p: AwgParams): "2" | "3.1" {
return p.HeaderProtectionKey ? "3.1" : "2";
}
// Версия модуля/tools для /health — показывает оператору, что реально работает
// в ядре сейчас (см. install.sh: apt может обновить пакет, пока в ядре живёт
// старый загруженный модуль, коммит 6fdacd7). Загруженная версия важнее версии
// на диске ровно по той же причине. Вызывается на старте и при restartAwg() —
// это единственные моменты, когда модуль может реально смениться в течение
// жизни процесса; из обработчика /health не вызывается — этот роут без
// авторизации, спавнить подпроцессы на каждый его опрос нельзя.
function readAwgVersions(): { module: string; tools: string } {
let module = "";
try {
module = readFileSync("/sys/module/amneziawg/version", "utf8").trim();
} catch {
try { module = run("modinfo -F version amneziawg"); } catch { /* модуль не загружен */ }
}
let tools = "";
try {
const out = run("awg --version");
tools = out.split(/\s+/).find(t => /^v?\d/.test(t)) ?? "";
} catch { /* awg-tools не найден */ }
return { module, tools };
}
// PersistentKeepalive в 3.1 задаётся диапазоном (дефолт клиента AmneziaVPN);
// в 2.0 это одно число. Уходит и в серверные [Peer], и в клиентский конфиг.
const KEEPALIVE_BY_GEN: Record<"2" | "3.1", string> = { "2": "25", "3.1": "25-35" };
const runtimeConfig = initConfig();
const AWG_PARAMS = readAwgParams();
const AWG_GEN = genOf(AWG_PARAMS);
const AWG_VERSIONS = readAwgVersions();
const CONFIG = {
interface: "awg1",
confDir: "/etc/amnezia/amneziawg",
@@ -159,8 +236,13 @@ const CONFIG = {
dns1: "1.1.1.1",
dns2: "1.0.0.1",
mtu: 1376,
keepalive: 25,
awgParams: readAwgParams(),
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: AWG_PARAMS,
gen: AWG_GEN,
// Мутируются в restartAwg() — единственном месте, где модуль может
// реально смениться в течение жизни процесса.
awgModule: AWG_VERSIONS.module,
awgTools: AWG_VERSIONS.tools,
};
interface UserRow {
@@ -169,13 +251,21 @@ interface UserRow {
pub_key: string;
vpn_key: string;
psk_key: string;
key_gen: string;
vpn_key_prev: string;
}
const stmts = {
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
insert: db.prepare<[string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key) VALUES (?, ?, ?, ?, ?)"),
all: db.prepare<[], UserRow>("SELECT * FROM users"),
insert: db.prepare<[string, string, string, string, string, string]>("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)"),
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
// Перевыпуск: старый блоб уезжает в vpn_key_prev, pub_key/psk_key могут
// смениться, если исходный ключ не удалось разобрать.
reissue: db.prepare<[string, string, string, string, string]>(
"UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
),
};
function run(cmd: string): string {
@@ -207,7 +297,10 @@ function nextIp(): string {
throw new Error("Подсеть заполнена");
}
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → Address → DNS
// Официальный формат .conf: PrivateKey → AWG params (Jc,S,H,I) → 3.x-ключи →
// Address → DNS. Порядок 3.x-блока взят из client/server_scripts/awg/template.conf
// клиента AmneziaVPN; пустые ключи не выводятся вовсе — тогда конфиг остаётся
// ровно тем же 2.0-конфигом, что и до появления поддержки 3.1.
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
@@ -217,6 +310,7 @@ function buildClientConf(
serverPub: string,
): string {
const p = CONFIG.awgParams;
const awg3 = AWG3_KEYS.filter(k => p[k]).map(k => `${k} = ${p[k]}\n`).join("");
return `[Interface]
PrivateKey = ${keys.privateKey}
Jc = ${p.Jc}
@@ -235,7 +329,7 @@ I2 =${" "}
I3 =${" "}
I4 =${" "}
I5 =${" "}
Address = ${ip}/32
${awg3}Address = ${ip}/32
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
[Peer]
@@ -255,12 +349,29 @@ function encodeVpnKey(
const p = CONFIG.awgParams;
const clientConf = buildClientConf(keys, ip, serverPub);
const lastConfigObj = {
// Ключи в объектах идут в том же ASCII-алфавитном порядке, в каком их
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи
// выбрасываются в dropEmptyAwg3 — на 2.0 объекты остаются прежними байт-в-байт.
const dropEmptyAwg3 = <T extends Record<string, unknown>>(o: T): T => {
for (const k of AWG3_KEYS) if (!p[k]) delete o[k];
return o;
};
const lastConfigObj = dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2), S3: String(p.S3), S4: String(p.S4),
allowed_ips: ["0.0.0.0/0", "::/0"],
clientId: keys.publicKey,
@@ -274,25 +385,34 @@ function encodeVpnKey(
port: CONFIG.serverPort,
psk_key: keys.presharedKey,
server_pub_key: serverPub,
};
});
const json = JSON.stringify({
containers: [{
container: "amnezia-awg2",
awg: {
awg: dropEmptyAwg3({
ContentPaddingAddition: p.ContentPaddingAddition,
DisableCookies: p.DisableCookies,
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
HeaderProtectionKey: p.HeaderProtectionKey,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
KeepaliveTimeout: p.KeepaliveTimeout,
MaxHandshakeAttempts: p.MaxHandshakeAttempts,
RandomTrailers: p.RandomTrailers,
RejectAfterTime: p.RejectAfterTime,
RekeyAfterTime: p.RekeyAfterTime,
RekeyTimeout: p.RekeyTimeout,
S1: String(p.S1), S2: String(p.S2),
S3: String(p.S3), S4: String(p.S4),
last_config: JSON.stringify(lastConfigObj, null, 2),
port: String(CONFIG.serverPort),
protocol_version: "2",
protocol_version: CONFIG.gen,
subnet_address: `${CONFIG.subnet}.0.0`,
transport_proto: "udp",
},
}),
}],
defaultContainer: "amnezia-awg2",
description: CONFIG.serverName,
@@ -311,6 +431,77 @@ function encodeVpnKey(
.replace(/=+$/, "");
}
// Обратная к encodeVpnKey: vpn:// → base64url → снять 4-байтовый BE-заголовок
// длины → inflate → JSON. Приватный ключ клиента больше нигде не хранится, поэтому
// это единственный способ перевыпустить ключ, не меняя личность пира.
function decodeVpnKey(vpnKey: string): any | null {
try {
const buf = Buffer.from(vpnKey.replace(/^vpn:\/\//, ""), "base64url");
if (buf.length <= 4) return null;
return JSON.parse(zlib.inflateSync(buf.subarray(4)).toString("utf8"));
} catch {
return null;
}
}
function clientPrivKeyFrom(vpnKey: string): string | null {
const lastConfig = decodeVpnKey(vpnKey)?.containers?.[0]?.awg?.last_config;
if (typeof lastConfig !== "string") return null;
try {
const priv = JSON.parse(lastConfig).client_priv_key;
return typeof priv === "string" && priv ? priv : null;
} catch {
return null;
}
}
interface ReissueResult { total: number; reissued: number; regenerated: string[]; backup: string }
// Перевыпуск всех vpn:// ключей на текущих параметрах интерфейса. Нужен после
// смены поколения (2.0 → 3.1): старые ключи собраны на старых параметрах и
// перестают работать. IP, pub_key и psk_key сохраняются — на проводе ничего не
// меняется, клиенту достаточно заново импортировать ключ.
function reissueAll(): ReissueResult {
const users = stmts.all.all();
const serverPub = getServerPublicKey();
const backup = `${dbPath}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`;
db.prepare("VACUUM INTO ?").run(backup);
logger.info("reissue: db backed up", { backup, users: users.length });
const regenerated: string[] = [];
let reissued = 0;
for (const u of users) {
const priv = clientPrivKeyFrom(u.vpn_key);
let keys: ReturnType<typeof generateKeys>;
if (priv) {
keys = { privateKey: priv, publicKey: u.pub_key, presharedKey: u.psk_key };
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
logger.warn("reissue: vpn_key не декодируется, генерируем новую пару", { name: u.name });
keys = generateKeys();
spawnSync("awg", ["set", CONFIG.interface, "peer", u.pub_key, "remove"]);
const r = setPeer(keys.publicKey, keys.presharedKey, u.ip);
if (r.status !== 0) {
logger.error("reissue: awg set failed", { name: u.name, stderr: r.stderr?.toString() });
continue;
}
regenerated.push(u.name);
}
stmts.reissue.run(
encodeVpnKey(keys, u.ip, serverPub),
keys.publicKey, keys.presharedKey, CONFIG.gen, u.name,
);
reissued++;
}
syncPeers();
logger.info("reissue: done", { total: users.length, reissued, regenerated: regenerated.length });
return { total: users.length, reissued, regenerated, backup };
}
function getPeersData(): Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> {
try {
const output = run(`awg show ${CONFIG.interface} dump`);
@@ -418,7 +609,10 @@ function restartAwg() {
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
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() {
@@ -430,36 +624,47 @@ function startInterface() {
return getInterfaceStatus();
}
// PSK уходит во временный файл, а не в аргументы: иначе он виден в /proc/<pid>/cmdline.
function setPeer(pubKey: string, psk: string, ip: string) {
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
writeFileSync(tmpPsk, psk, { mode: 0o600 });
try {
return spawnSync("awg", [
"set", CONFIG.interface, "peer", pubKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", CONFIG.keepalive,
]);
} finally {
try { fs.unlinkSync(tmpPsk); } catch {}
}
}
function addUser(username: string): UserRow {
const keys = generateKeys();
const serverPub = getServerPublicKey();
const ip = db.transaction(() => {
const ip = nextIp();
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey);
stmts.insert.run(username, ip, keys.publicKey, "", keys.presharedKey, CONFIG.gen);
return ip;
})();
const vpn_key = encodeVpnKey(keys, ip, serverPub);
db.prepare("UPDATE users SET vpn_key = ? WHERE name = ?").run(vpn_key, username);
const tmpPsk = `/tmp/awg_psk_${Date.now()}.tmp`;
writeFileSync(tmpPsk, keys.presharedKey);
const r = spawnSync("awg", [
"set", CONFIG.interface, "peer", keys.publicKey,
"preshared-key", tmpPsk,
"allowed-ips", `${ip}/32`,
"persistent-keepalive", String(CONFIG.keepalive),
]);
try { fs.unlinkSync(tmpPsk); } catch {}
const r = setPeer(keys.publicKey, keys.presharedKey, ip);
if (r.status !== 0) {
stmts.delete.run(username);
throw new Error(`awg set failed: ${r.stderr?.toString()}`);
}
rebuildConf();
logger.info("user created", { name: username, ip });
return { name: username, ip, pub_key: keys.publicKey, vpn_key, psk_key: keys.presharedKey };
logger.info("user created", { name: username, ip, gen: CONFIG.gen });
return {
name: username, ip, pub_key: keys.publicKey, vpn_key,
psk_key: keys.presharedKey, key_gen: CONFIG.gen, vpn_key_prev: "",
};
}
function removeUser(username: string) {
@@ -521,7 +726,8 @@ app.get("/health", (_req, res) => {
status: up ? "ok" : "degraded",
server: CONFIG.serverName,
ip: CONFIG.serverIp,
awg: { status: up ? "ok" : "down", peers },
gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
});
});
@@ -534,7 +740,7 @@ app.post("/api/users", auth, validateName, handler((req, res) => {
}));
app.get("/api/users", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key, vpn_key FROM users WHERE vpn_key != ''").all() as UserRow[];
const users = db.prepare("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData();
res.json({
users: users.map(u => ({
@@ -546,12 +752,13 @@ app.get("/api/users", auth, handler((_req, res) => {
}));
app.get("/api/users/stats", auth, handler((_req, res) => {
const users = db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[];
const users = db.prepare("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData();
res.json({
users: users.map(u => ({
name: u.name,
ip: u.ip,
key_gen: u.key_gen,
online: peers[u.pub_key]?.online ?? false,
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0,
@@ -560,6 +767,12 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
});
}));
// ⚠️ Должен быть объявлен ДО «/api/users/:name», иначе тот перехватит «reissue»
// как имя пользователя.
app.post("/api/users/reissue", auth, handler((_req, res) => {
res.json(reissueAll());
}));
app.post("/api/users/:name", auth, validateName, handler((req, res) => {
const user = stmts.get.get(req.params.name);
if (!user) { res.status(404).json({ error: "Пользователь не найден" }); return; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "1.0.0",
"version": "0.1.4+2",
"description": "",
"main": "dist/index.js",
"scripts": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.1.3+1",
"version": "0.1.4+2",
"private": true,
"scripts": {
"dev": "vite",
@@ -12,14 +12,14 @@
},
"dependencies": {
"axios": "^1.7.0",
"better-sqlite3": "^12.10.0",
"jsonwebtoken": "^9.0.0",
"qrcode": "^1.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.0",
"@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
+30 -104
View File
@@ -6,7 +6,7 @@ import path from "path";
import fs from "fs";
import crypto from "crypto";
import axios from "axios";
import Database from "better-sqlite3";
import { ma7 } from "./ma7";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -37,43 +37,6 @@ function mintInternalToken(): string {
const revoked = new Set<string>();
interface ApiKeyRow {
id: number; label: string; key_hash: string; prefix: string;
server_id: number; created_at: number; last_used: number | null;
}
const UI_DB_FILE = process.env.UI_DB_FILE || path.join(__dirname, "ui.db");
const uidb = new Database(UI_DB_FILE);
uidb.pragma("journal_mode = WAL");
uidb.exec(`
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
prefix TEXT NOT NULL,
server_id INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const keyStmts = {
list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"),
insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"),
byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"),
touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"),
};
function hashKey(key: string): string {
return crypto.createHash("sha256").update(key).digest("hex");
}
function genApiKey(): { key: string; hash: string; prefix: string } {
const key = "awgk_" + crypto.randomBytes(24).toString("base64url");
return { key, hash: hashKey(key), prefix: key.slice(0, 13) + "…" };
}
function tokenSig(token: string): string { return token.split(".")[2] ?? token; }
function sign(): string {
@@ -146,29 +109,6 @@ app.post("/logout", requireAuth, (req: Request, res: Response) => {
res.json({ ok: true });
});
app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => {
res.json({ keys: keyStmts.list.all() });
});
app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
const { label, server_id } = req.body as { label?: string; server_id?: number };
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; }
keyStmts.delete.run(id);
res.json({ success: true, id });
});
async function proxy(req: Request, res: Response) {
try {
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) {
return axios({
method,
@@ -210,36 +134,38 @@ async function ctrl(method: string, urlPath: string, body?: unknown) {
});
}
const ext = express.Router();
ext.use(requireApiKey);
// ── Интеграция с MA7 (вкладка «Профиль») ───────────────────────────────────
// Сервер персональный — панель показывает баланс своего единственного
// владельца, не список абонентов. Логин владельца (MA7_LOGIN) — часть
// конфигурации сервера, задаётся один раз при установке (cli.env), а НЕ
// вводится через UI: бот-токен MA7_JWT_SECRET умеет искать любого абонента
// MA7, и если бы логин можно было менять прямо в панели, любой, кто знает
// пароль от панели, мог бы подсматривать баланс чужих абонентов — панель
// защищена одним общим UI_USER/UI_PASS, отдельного «клиентского» входа нет.
const MA7_LOGIN = process.env.MA7_LOGIN ?? "";
ext.post("/users", async (req: Request, res: Response) => {
const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string };
res.status(201).json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
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 недоступен" });
}
});
ext.get("/users", async (_req: Request, res: Response) => {
const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data);
});
ext.get("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as { name: string; ip: string; vpn_key: string };
res.json({ name: u.name, ip: u.ip, vpn_key: u.vpn_key });
});
ext.delete("/users/:name", async (req: Request, res: Response) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
});
app.use("/api/v1", ext);
app.use(["/api", "/health", "/awg"], requireAuth, proxy);
app.use(express.static(path.join(__dirname, "dist")));
+10 -19
View File
@@ -352,6 +352,8 @@ body {
.chip--online .chip-dot { background: var(--success-dot); }
.chip--offline { background: var(--neutral-bg); color: var(--neutral-text); }
.chip--offline .chip-dot { background: var(--neutral-dot); }
.chip--error { background: var(--error-bg); color: var(--error-text); }
.chip--error .chip-dot { background: var(--error-dot); }
.main {
flex: 1;
@@ -404,6 +406,13 @@ body {
color: var(--neutral-text);
line-height: 1.2;
}
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
.server-gen-chip {
align-self: flex-start;
margin-top: 2px;
font-size: 11px;
padding: 2px 8px;
}
.server-card-peers {
font-size: 12px;
color: var(--neutral-text);
@@ -464,22 +473,6 @@ body {
}
.tip-wrap:hover::after { opacity: 1; }
.server-add {
display: inline-flex;
align-items: center;
gap: 6px;
height: 44px;
padding: 0 16px;
border-radius: var(--r-pill);
border: 1.5px dashed var(--outline);
background: transparent;
font-family: var(--font);
font-size: 13px;
color: var(--neutral-dot);
cursor: not-allowed;
white-space: nowrap;
}
.toolbar {
display: flex;
align-items: center;
@@ -664,11 +657,9 @@ tbody td:last-child { padding-right: 20px; }
.server-toggle { display: flex; }
.server-online-chip { display: none; }
/* Кнопка «Добавить сервер» — полная ширина */
.tip-wrap { width: 100%; }
.server-add { width: 100%; justify-content: center; }
/* На мобильной: угловую кнопку прячем, перезапуск — под «Добавить сервер» */
/* На мобильной: угловую кнопку прячем, показываем перезапуск в сервер-баре */
.awg-restart { display: none; }
.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 { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({ name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0 });
setServerInfo({
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
}, []);
@@ -214,7 +217,7 @@ export default function App() {
>
<div className="sidebar-logo">
<span className="logo-name">Forgetting</span>
<span className="sidebar-version">Alpha 0.1.3.1</span>
<span className="sidebar-version">Alpha 0.1.4.2</span>
</div>
<hr className="drawer-divider" />
{TABS.map(t => (
@@ -243,7 +246,6 @@ export default function App() {
serverOnline={isOnline}
onRestartAwg={restartAwg}
restarting={restarting}
showAddServer={chrome.addServer !== false}
showRestart={showRestart}
/>
)}
+19 -17
View File
@@ -1,21 +1,20 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoRefresh } from './icons';
import { type ServerInfo } from '../lib/shared';
import { IcoPlus, IcoRefresh } from './icons';
// Сервер-бар: карточка активного сервера + «Добавить сервер» + (на мобильной)
// перезапуск AWG под ними. Общий для вкладок — показывает (и в будущем выбирает)
// сервер, к которому относится содержимое вкладки (пиры / API-ключи).
// TODO multi-server: сейчас сервер один и всегда активен. Когда серверов
// станет несколько — карточки станут кликабельными, активная = выбранная.
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting,
showAddServer = true, showRestart = true,
showRestart = true,
}: {
serverInfo: ServerInfo | null;
serverOnline: boolean;
onRestartAwg: () => void;
restarting: boolean;
showAddServer?: boolean;
showRestart?: boolean;
}) {
return (
@@ -36,17 +35,20 @@ export default function ServerBar({
<div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span>
{(serverInfo.mod || serverInfo.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
)}
</div>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
{showAddServer && (
<span className="tip-wrap" data-tip="Недоступно в альфа-версии">
<button className="server-add" disabled>
<IcoPlus /> Добавить сервер
</button>
</span>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && (
<button
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Иконки интерфейса (inline SVG, currentColor). Добавляя вкладку — добавь сюда
// её иконку и зарегистрируй имя в ICONS (его указывают в tabs/<name>/metadata.json).
+12 -8
View File
@@ -1,10 +1,16 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Общие типы, константы и утилиты для всех вкладок панели.
import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1';
export interface User {
name: string;
ip: string;
pub_key: string;
vpn_key: string;
key_gen: AwgGen;
online: boolean;
lastHandshake: number;
rx?: number;
@@ -14,15 +20,13 @@ export interface ServerInfo {
name: string;
ip: string;
peers: number;
gen: AwgGen;
mod: string; // версия загруженного модуля amneziawg, из /health awg.module
tools: string; // версия awg-tools, из /health awg.tools
}
export interface ApiKey {
id: number;
label: string;
prefix: string;
server_id: number;
created_at: number;
last_used: number | null;
}
// На проводе (users.key_gen, /health gen) поколение — '2', человеку показываем '2.0'.
export const genLabel = (g: AwgGen): string => (g === '3.1' ? 'AWG 3.1' : 'AWG 2.0');
export interface PageProps {
token: string;
showMsg: (text: string) => void;
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
-72
View File
@@ -1,72 +0,0 @@
/* Стили вкладки «API-ключи». Общая дизайн-система — в App.css. */
/* Открытый API-ключ (показывается один раз при создании) */
.apikey-value {
font-family: var(--mono);
font-size: 13px;
color: var(--on-surface);
background: var(--surface-container);
border: 1px solid var(--outline-variant);
border-radius: 12px;
padding: 12px 14px;
width: 100%;
word-break: break-all;
user-select: all;
}
.apikey-cards-list { display: none; flex-direction: column; gap: 12px; }
.apikey-count { font-size: 12px; color: var(--neutral-text); }
.apikey-empty {
font-size: 14px;
color: var(--neutral-text);
text-align: center;
padding: 48px 0;
}
.apikey-card {
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.apikey-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.apikey-card-title {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.apikey-card-num { font-size: 12px; color: var(--neutral-dot); flex-shrink: 0; }
.apikey-card-label {
font-size: 16px;
font-weight: 500;
color: var(--on-surface);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.apikey-card-prefix {
font-family: var(--mono);
font-size: 13px;
color: var(--neutral-text);
word-break: break-all;
}
.apikey-card-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--neutral-text);
}
@media (max-width: 640px) {
.apikey-cards-list { display: flex; }
}
-145
View File
@@ -1,145 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
// Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается
// один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App.
// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список
// серверов — ключ будет привязываться к выбранному в сервер-баре.
export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const load = useCallback(async () => {
try {
const data = await apiFetch('GET', '/ui/apikeys', token);
setKeys(data.keys ?? []);
} catch {
showMsg('Ошибка загрузки ключей');
}
}, [token, showMsg]);
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
await load();
}, [label, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
await apiFetch('DELETE', '/ui/apikeys/' + id, token);
showMsg('Ключ удалён: ' + lbl);
await load();
}, [token, load, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
return (
<>
{/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ.
TODO multi-server: server_id берётся из выбранной карточки (пока 0). */}
<div className="toolbar">
<input
className="field"
placeholder="Метка ключа (напр. ci-bot)"
value={label}
onChange={e => setLabel(e.target.value)}
onKeyDown={e => e.key === 'Enter' && createKey()}
/>
<div className="toolbar-btns">
<button className="btn btn--primary" onClick={createKey}>
<IcoPlus /> Создать ключ
</button>
</div>
</div>
<div className="table-card">
<table>
<thead>
<tr>
<th>#</th>
<th>Метка</th>
<th>Ключ</th>
<th>Создан</th>
<th>Последний раз</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
) : keys.map((k, i) => (
<tr key={k.id}>
<td className="td-num">{i + 1}</td>
<td>{k.label}</td>
<td className="td-mono">{k.prefix}</td>
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
<td className="td-actions">
<div className="actions">
<button
className="btn-icon btn-icon--danger"
title="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Мобильный вид: таблица скрыта (.table-card), ключи — карточками */}
<div className="apikey-cards-list">
<p className="apikey-count">{keys.length} ключей</p>
{keys.length === 0 ? (
<p className="apikey-empty">Нет ключей</p>
) : keys.map((k, i) => (
<div className="apikey-card" key={k.id}>
<div className="apikey-card-header">
<div className="apikey-card-title">
<span className="apikey-card-num">#{i + 1}</span>
<span className="apikey-card-label">{k.label}</span>
</div>
<button
className="btn btn--danger btn--sq"
aria-label="Удалить ключ"
onClick={() => deleteKey(k.id, k.label)}
><IcoTrash /></button>
</div>
<code className="apikey-card-prefix">{k.prefix}</code>
<div className="apikey-card-meta">
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
</div>
</div>
))}
</div>
{created && (
<div className="qr-backdrop" onClick={() => setCreated(null)}>
<div className="qr-card" onClick={e => e.stopPropagation()}>
<p className="qr-name">Ключ «{created.label}» создан</p>
<p className="qr-hint">
Скопируй ключ сейчас — он показывается один раз и больше не будет доступен.
</p>
<code className="apikey-value">{created.key}</code>
<div className="qr-actions">
<button className="btn btn--primary" onClick={async () => {
await copyText(created.key);
showMsg('Ключ скопирован');
}}><IcoCopy /> Скопировать</button>
<button className="btn btn--tonal" onClick={() => setCreated(null)}>Закрыть</button>
</div>
</div>
</div>
)}
</>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"id": "apikeys",
"label": "API-ключи",
"icon": "key",
"order": 2
}
+3 -1
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент)
@@ -15,7 +18,6 @@ import { ICONS } from '../components/icons';
// Видимость общего хрома (сервер-бар) для вкладки. Любой флаг по умолчанию true.
export interface TabChrome {
serverBar?: boolean;
addServer?: boolean;
restart?: boolean;
}
+55 -4
View File
@@ -1,8 +1,11 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useRef, useCallback } from 'react';
import QRCode from 'qrcode';
import {
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo,
type User, type PageProps,
apiFetch, vpnKeyToConf, downloadFile, copyText, bytes, timeAgo, genLabel,
type User, type PageProps, type AwgGen,
} from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import './users.css';
@@ -13,7 +16,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const [users, setUsers] = useState<User[]>([]);
const [newName, setNewName] = useState('');
const [qrModal, setQrModal] = useState<{ name: string; dataUrl: string; vpnKey: string } | null>(null);
// Поколение, на котором сейчас работает интерфейс. Ключи, выданные на другом
// поколении, уже не подключатся — их надо перевыпустить.
const [serverGen, setServerGen] = useState<AwgGen>('2');
const statsRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stale = users.filter(u => u.key_gen !== serverGen);
const loadStats = useCallback(async (tok: string) => {
try {
@@ -27,14 +34,34 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const loadUsers = useCallback(async (tok: string) => {
try {
const data = await apiFetch('GET', '/api/users', tok);
const [data, health] = await Promise.all([
apiFetch('GET', '/api/users', tok),
apiFetch('GET', '/health', tok),
]);
setUsers(data.users ?? []);
setServerGen(health.gen ?? '2');
await loadStats(tok);
} catch {
showMsg('Ошибка загрузки');
}
}, [loadStats, showMsg]);
// Пересобирает vpn:// ключи всех пользователей на текущих параметрах интерфейса.
// IP и ключевая пара сохраняются, но клиентам нужно заново импортировать ключ.
const reissueKeys = useCallback(async () => {
if (!confirm(
`Перевыпустить ключи (${stale.length} шт.) на поколении ${genLabel(serverGen)}?\n\n` +
'Старые ключи перестанут работать — всем придётся импортировать ключ заново.',
)) return;
try {
const r = await apiFetch('POST', '/api/users/reissue', token);
showMsg(`Перевыпущено: ${r.reissued} из ${r.total}`);
await loadUsers(token);
} catch {
showMsg('Не удалось перевыпустить ключи');
}
}, [stale.length, serverGen, token, loadUsers, showMsg]);
const createUser = useCallback(async () => {
if (!newName) return;
const u = await apiFetch('POST', '/api/users', token, { name: newName });
@@ -93,6 +120,11 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<button className="btn btn--tonal" onClick={() => loadUsers(token)}>
<IcoRefresh /> Обновить
</button>
{stale.length > 0 && (
<button className="btn btn--danger" onClick={reissueKeys}>
<IcoRefresh /> Перевыпустить ключи ({stale.length})
</button>
)}
</div>
</div>
@@ -125,7 +157,20 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'}
</span>
</td>
<td>{u.name}</td>
<td>
{u.name}
{u.key_gen !== serverGen && (
<span
className="tip-wrap user-gen"
data-tip={`Ключ выдан на ${genLabel(u.key_gen)}, сервер работает на ${genLabel(serverGen)}`}
>
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
</span>
)}
</td>
<td className="td-mono">{u.ip}</td>
<td>{bytes(u.rx)}</td>
<td>{bytes(u.tx)}</td>
@@ -159,6 +204,12 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-title">
<span className="user-card-num">#{i + 1}</span>
<span className="user-card-name">{u.name}</span>
{u.key_gen !== serverGen && (
<span className="chip chip--error">
<span className="chip-dot" />
{genLabel(u.key_gen)}
</span>
)}
</div>
<span className={`chip chip--${u.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />
+10
View File
@@ -1,3 +1,8 @@
/*
Copyright (c) 2026 Ivan Vasilev
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/
/* Стили вкладки «Пользователи». Общая дизайн-система (палитра, btn, chip, field,
table-card, toolbar, layout) остаётся в App.css — здесь только уникальное вкладки. */
@@ -64,7 +69,12 @@
.user-card-actions { display: flex; gap: 8px; }
.user-card-actions .btn--outline { flex: 1; }
/* Метка устаревшего ключа рядом с именем: ключ выдан на другом поколении AWG. */
.user-gen { margin-left: 8px; vertical-align: middle; }
/* На мобильной таблица скрывается (.table-card в App.css), карточки показываются. */
@media (max-width: 640px) {
.user-cards-list { display: flex; }
/* .tip-wrap на мобильной растягивается на всю ширину — метке это не нужно. */
.user-gen { width: auto; }
}
+3
View File
@@ -1 +1,4 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
/// <reference types="vite/client" />
+3
View File
@@ -1,3 +1,6 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.1.3+1",
"version": "0.1.4+2",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+38 -6
View File
@@ -74,8 +74,13 @@ const SERVICES = {
UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "",
// ui.db в каталоге данных AWG (вне PROJECT) — переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
// Интеграция с 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",
logFile: path.join(LOGS, "awg-ui.log"),
@@ -196,7 +201,27 @@ async function restart(name: SvcName) {
await start(name);
}
function status() {
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
// молча даёт null — health-строка в status() тогда просто не печатается.
interface HealthBody {
gen?: string;
awg?: { module?: string; tools?: string };
}
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
return new Promise(resolve => {
const req = http.get(url, res => {
let body = "";
res.on("data", (c: Buffer) => { body += c; });
res.on("end", () => {
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
});
});
req.on("error", () => resolve(null));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
});
}
async function status() {
console.log("");
for (const name of ALL) {
const running = isRunning(name);
@@ -205,6 +230,13 @@ function status() {
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
}
if (isRunning("awgctrl")) {
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
if (h?.gen) {
const gen = h.gen === "3.1" ? "3.1" : "2.0";
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
}
}
console.log("");
}
@@ -287,7 +319,7 @@ async function interactiveMenu() {
const printMenu = () => {
console.clear();
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.3.1 ──${C.reset}`)}\n`);
console.log(`\n${bold(`${C.blue}── Forgetting Alpha 0.1.4.2 ──${C.reset}`)}\n`);
for (const name of ALL) {
const running = isRunning(name);
@@ -321,7 +353,7 @@ async function interactiveMenu() {
case "1": await startAll(); break;
case "2": await stopAll(); break;
case "3": await restartAll(); break;
case "4": status(); break;
case "4": await status(); break;
case "5": await setCredentials("user"); break;
case "6": await setCredentials("pass"); break;
case "0": rl.close(); process.exit(0);
@@ -350,7 +382,7 @@ async function main() {
case "start": await startAll(); break;
case "stop": await stopAll(); break;
case "restart": await restartAll(); break;
case "status": status(); break;
case "status": await status(); break;
case "credentials":
if (sub === "user") await setCredentials("user");
else if (sub === "pass") await setCredentials("pass");
+271 -16
View File
@@ -19,7 +19,7 @@ trap 'rc=$?; echo -e "\n ${RED}✗ НЕОЖИДАННАЯ ОШИБКА${NC} с
[[ $EUID -ne 0 ]] && fail "Запусти от root: sudo bash install.sh"
[[ -z "${BASH_VERSION:-}" ]] && fail "Нужен bash: bash install.sh"
VERSION="0.1.3.1"
VERSION="0.1.4.2"
GH_REPO="maeneko/forgetting"
BASE_URL="https://github.com/${GH_REPO}/releases/download/v${VERSION}"
PROJECT="/opt/awg-control"
@@ -29,7 +29,6 @@ PRIV_KEY_FILE="$AMNEZIA_DIR/server_private.key"
PUB_KEY_FILE="$AWG_DIR/server_public.key"
AWG_CONF="$AWG_DIR/awg1.conf"
DB_FILE="$AWG_DIR/users.db"
UI_DB_FILE="$AWG_DIR/ui.db" # своя БД awg-ui (API-ключи); вне PROJECT — переживает переустановку
# Внутренняя авторизация awg-ui → awg-ctrl (Ed25519): приватный → awg-ui, публичный → awg-ctrl.
INTERNAL_AUTH_PRIV="$AWG_DIR/internal_auth_private.key"
INTERNAL_AUTH_PUB="$AWG_DIR/internal_auth_public.key"
@@ -118,6 +117,10 @@ echo
# Ограничения, которые обязан соблюсти генератор:
# - Jc: 3–10 (больше — лишний трафик); Jmin < Jmax, оба < MTU
# - S1, S2: < ~150, в части версий S1 != S2
# - S1–S4: при заданном HeaderProtectionKey (AmneziaWG 3.x) НИ ОДНО из них
# не может быть меньше 12 (HEADER_PROTECTION_NONCE_SIZE). Модуль на
# нарушение отвечает только «Invalid argument», причина видна лишь при
# `echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control`
# - H1–H4: уникальны между собой, НЕ равны 1/2/3/4 (зарезервированные
# типы сообщений WireGuard), большие uint32 без пересечений
# - I1–I5 НЕ трогать: сейчас уходят в vpn:// ключ пустыми плейсхолдерами
@@ -130,6 +133,22 @@ H2="2122053282-2133204808"
H3="2133604274-2140756116"
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}')
[[ -z "$NET_IFACE" ]] && fail "Не могу определить сетевой интерфейс"
@@ -172,15 +191,29 @@ start_logging() {
download_extract() {
local url="$BASE_URL/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}"
curl -fsSL --connect-timeout 15 "$url" -o "$tmp" \
|| fail "Не удалось скачать архив: $url"
ok "Архив скачан: $(du -sh "$tmp" | cut -f1)"
fi
mkdir -p "$PROJECT"
tar -xzf "$tmp" -C "$PROJECT" --strip-components=1 \
|| fail "Не удалось распаковать архив"
rm -f "$tmp"
[[ "$keep_tarball" == 1 ]] || rm -f "$tmp"
ok "Распакован → $PROJECT"
local f
@@ -324,8 +357,26 @@ AWGCTRL_PORT=$(( (RANDOM % 22768) + 32768 ))
read -rp " UI port (Enter — случайный): " UI_PORT
UI_PORT="${UI_PORT:-$(( (RANDOM % 22768) + 32768 ))}"
read -rp " UI логин [admin]: " UI_USER
UI_USER="${UI_USER:-admin}"
# Интеграция с API MA7 (см. README.md, раздел «Интеграция с MA7») — этот
# сервер персональный, панель показывает баланс своего единственного
# владельца. Спрашивается ДО логина панели: логин 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)
read -rsp " UI пароль [$SUGGESTED_PASS]: " UI_PASS; echo
@@ -414,9 +465,18 @@ else
fi
ok "Заголовки ядра на месте: /lib/modules/$KERNEL/build"
# Если репозиторий amnezia уже настроен (например, ключ и источник заведены
# руками — add-apt-repository ходит за ключом в Launchpad, а тот периодически
# отвечает GPGKeyTemporarilyNotFoundError), второй раз его добавлять не нужно.
# Проверяем именно файлы источников, а не `apt-cache policy`: сразу после
# apt-get install кэш apt бывает в переходном состоянии и отдаёт пустоту.
if grep -rqs 'amnezia/ppa' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
ok "Репозиторий amnezia уже настроен — add-apt-repository пропущен"
else
echo " → add-apt-repository ppa:amnezia/ppa"
add-apt-repository -y ppa:amnezia/ppa \
|| fail "Не удалось добавить PPA ppa:amnezia/ppa"
fi
echo " → apt-get update (после PPA)"
apt-get update \
@@ -447,6 +507,96 @@ else
ok "AWG установлен, модуль amneziawg собран и загружается"
fi
# Поколение протокола: 3.1 требует модуль 3.x и ядро ≥ 5.5.
# - модуль: PPA с 30.07.2026 отдаёт 3.x; на старых установках он может быть 2.0,
# поэтому сначала пробуем обновиться.
# - ядро: header protection использует библиотечный chacha-API
# (chacha_init/chacha20_crypt), которого нет до 5.5 — модуль там не соберётся
# (upstream issue #210). Проблема с nla_put_uint на ядрах < 6.7 уже исправлена.
# Если 3.1 недоступен и AWG_GEN=auto — не падаем, а пишем 2.0-конфиг: awg-ctrl
# определяет поколение по наличию HeaderProtectionKey в конфиге, так что всё
# продолжит работать ровно как раньше. При AWG_GEN=3.1 недоступность — fail
# (см. AWG_GEN выше): апстрим модуля ещё нестабилен, тихий откат при явном
# запросе 3.1 может незаметно подсунуть более слабую обфускацию.
kernel_lt_5_5() {
local maj min
maj=${KERNEL%%.*}
min=${KERNEL#*.}; min=${min%%.*}
[[ "$maj" -lt 5 || ( "$maj" -eq 5 && "$min" -lt 5 ) ]]
}
# 🛑 Версий у модуля две, и путать их нельзя:
# modinfo — что лежит на диске (после apt/DKMS);
# /sys/module/amneziawg/version — что реально загружено в ядро.
# `apt --only-upgrade` пересобирает модуль, но НЕ переставляет уже загруженный.
# Ориентироваться на modinfo — значит написать 3.1-конфиг, который старый
# загруженный модуль отвергнет: `awg setconf` вернёт «Unable to modify
# interface: Invalid argument», и awg-quick up упадёт. Решает перезагрузка модуля.
disk_mod_ver() { modinfo -F version amneziawg 2>/dev/null || echo ""; }
loaded_mod_ver() { cat /sys/module/amneziawg/version 2>/dev/null || echo ""; }
if [[ "$AWG_GEN" == "2.0" ]]; then
AWG3="n"
MOD_VER=$(disk_mod_ver)
ok "Поколение задано вручную (AWG_GEN=2.0) — модуль не трогаем, ставим 2.0."
else
AWG3="y"
MOD_VER=$(disk_mod_ver)
if [[ "${MOD_VER%%.*}" != "3" ]]; then
warn "Модуль amneziawg версии '${MOD_VER:-неизвестно}' — для AmneziaWG 3.1 нужна 3.x. Пробуем обновить."
# Прошлая установка могла закрепить пакет (apt-mark hold, см. ниже) —
# снимаем, иначе --only-upgrade молча не тронет held-версию.
apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
apt-get update >/dev/null 2>&1 || true
DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a \
apt-get install -y --only-upgrade amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
MOD_VER=$(disk_mod_ver)
fi
# Загруженный отстал от дискового — перезагружаем. Интерфейс держит модуль,
# поэтому сначала опускаем его; awg1 на этом шаге ещё не нужен.
LOADED_VER=$(loaded_mod_ver)
if [[ -n "$LOADED_VER" && "$LOADED_VER" != "$MOD_VER" ]]; then
warn "В ядре загружен модуль $LOADED_VER, на диске $MOD_VER — перезагружаем модуль."
awg-quick down "$IFACE" 2>/dev/null || true
ip link delete dev "$IFACE" 2>/dev/null || true
if modprobe -r amneziawg 2>/dev/null && modprobe amneziawg 2>/dev/null; then
LOADED_VER=$(loaded_mod_ver)
ok "Модуль перезагружен: в ядре теперь $LOADED_VER"
else
warn "Не удалось перезагрузить модуль (занят?). Нужна перезагрузка сервера."
fi
fi
# Дальше решает ТОЛЬКО загруженная версия — именно она обслуживает интерфейс.
[[ -n "$LOADED_VER" ]] && MOD_VER="$LOADED_VER"
if [[ "${MOD_VER%%.*}" != "3" ]]; then
AWG3="n"
if [[ "$AWG_GEN" == "3.1" ]]; then
fail "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"
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 "Ставим сервер на AmneziaWG 2.0."
fi
fi
if [[ "$AWG3" == "y" ]]; then
ok "AmneziaWG 3.1 доступен (модуль $MOD_VER, ядро $KERNEL)"
else
warn "Сервер будет работать на AmneziaWG 2.0. Обнови ядро/модуль и переустанови, чтобы перейти на 3.1."
fi
step "2/7 Node.js"
if command -v node &>/dev/null; then
@@ -482,15 +632,6 @@ else
mv "$DB_FILE" "$DB_BAK"
warn "Старая база пользователей сохранена: $DB_BAK"
fi
# ui.db (API-ключи awg-ui) — тоже в бэкап, чтобы awg-ui создал чистую БД.
if [[ -f "$UI_DB_FILE" ]]; then
UI_DB_BAK="${UI_DB_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
mv "$UI_DB_FILE" "$UI_DB_BAK"
# WAL-сайдкары удаляем — к новой БД они неприменимы.
rm -f "${UI_DB_FILE}-wal" "${UI_DB_FILE}-shm"
warn "Старая база API-ключей сохранена: $UI_DB_BAK"
fi
PRIV_KEY=$(umask 077 && awg genkey)
PUB_KEY=$(printf '%s' "$PRIV_KEY" | awg pubkey)
@@ -518,6 +659,38 @@ grep -qxF 'net.ipv6.conf.all.forwarding=1' /etc/sysctl.conf \
sysctl -qp
ok "IP forwarding включён"
# Параметры AmneziaWG 3.1. Значения-диапазоны взяты из дефолтов клиента
# AmneziaVPN (protocolConstants.h), чтобы сервер и клиент не расходились.
# RandomTrailers/DisableCookies (фичи 3.1 от 12.08.2026) пока не включаем.
# 🛑 HeaderProtectionKey фиксируется на весь срок жизни сервера наравне с
# J/S/H: он обязан совпадать на обоих концах, и его смена делает невалидными
# все ранее выданные vpn:// ключи. При KEEP_DATA берём существующий.
# Поколение предыдущей установки — нужно, чтобы предупредить о перевыпуске
# ключей при KEEP_DATA. Считать обязательно ДО перезаписи конфига.
PREV_GEN="none"
if [[ -f "$AWG_CONF" ]]; then
if grep -q '^HeaderProtectionKey *=' "$AWG_CONF"; then PREV_GEN="3.1"; else PREV_GEN="2.0"; fi
fi
AWG3_LINES=""
if [[ "$AWG3" == "y" ]]; then
HEADER_PROTECTION_KEY=""
if [[ "$KEEP_DATA" == "y" && -f "$AWG_CONF" ]]; then
# sed, а не awk -F'=': base64-ключ сам содержит '=' в паддинге.
HEADER_PROTECTION_KEY=$(sed -n 's/^HeaderProtectionKey *= *//p' "$AWG_CONF" | head -1)
[[ -n "$HEADER_PROTECTION_KEY" ]] && ok "HeaderProtectionKey взят из существующего конфига"
fi
[[ -z "$HEADER_PROTECTION_KEY" ]] && HEADER_PROTECTION_KEY=$(awg genpsk)
AWG3_LINES="HeaderProtectionKey = ${HEADER_PROTECTION_KEY}
ContentPaddingAddition = 10-100
RekeyAfterTime = 100-120
RekeyTimeout = 3-7
RejectAfterTime = 150-180
KeepaliveTimeout = 5-15
MaxHandshakeAttempts = 15-20"
fi
cat > "$AWG_CONF" <<CONF
[Interface]
Address = ${SUBNET}.0.1/16
@@ -539,8 +712,18 @@ H3 = ${H3}
H4 = ${H4}
CONF
[[ -n "$AWG3_LINES" ]] && printf '%s\n' "$AWG3_LINES" >> "$AWG_CONF"
chmod 600 "$AWG_CONF"
ok "$AWG_CONF"
NEW_GEN=$([[ "$AWG3" == "y" ]] && echo 3.1 || echo 2.0)
ok "$AWG_CONF (AmneziaWG $NEW_GEN)"
if [[ "$KEEP_DATA" == "y" && "$PREV_GEN" != "none" && "$PREV_GEN" != "$NEW_GEN" ]]; then
warn "Поколение протокола изменилось: $PREV_GEN → $NEW_GEN."
warn "Пользователи сохранены, но их vpn:// ключи собраны на старых параметрах"
warn "и работать перестанут. После запуска открой панель и нажми"
warn "«Перевыпустить ключи», затем раздай пользователям новые ключи."
fi
step "5/7 Запуск AWG"
@@ -549,7 +732,43 @@ if awg show "$IFACE" &>/dev/null; then
awg-quick down "$IFACE" 2>/dev/null || true
fi
awg-quick up "$IFACE" || fail "awg-quick up $IFACE завершился с ошибкой"
# Подъём интерфейса — единственная настоящая проверка, что ядро приняло
# 3.1-параметры. Модуль на отказ отвечает лишь «Invalid argument», причину
# печатает только в dmesg и только при включённом dynamic debug. Поэтому:
# упали → включаем debug, пробуем ещё раз, показываем причину, и если и это не
# помогло — снимаем 3.x-ключи и поднимаемся на 2.0, а не валим установку.
AWG3_KEY_RE='^(HeaderProtectionKey|ContentPaddingAddition|RekeyAfterTime|RekeyTimeout|RejectAfterTime|KeepaliveTimeout|MaxHandshakeAttempts|RandomTrailers|DisableCookies) *='
if ! awg-quick up "$IFACE"; then
ip link delete dev "$IFACE" 2>/dev/null || true
if [[ "$AWG3" != "y" ]]; then
fail "awg-quick up $IFACE завершился с ошибкой"
fi
warn "Ядро отвергло конфиг AmneziaWG 3.1. Выясняем причину."
echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control 2>/dev/null || true
if awg-quick up "$IFACE"; then
ok "Со второй попытки интерфейс поднялся"
else
ip link delete dev "$IFACE" 2>/dev/null || true
echo " ── dmesg (последние 15 строк) ─────────────────"
dmesg 2>/dev/null | grep -i amneziawg | tail -n 15 | sed 's/^/ /' || true
echo " ───────────────────────────────────────────────"
cp "$AWG_CONF" "${AWG_CONF}.awg31"
sed -i -E "/$AWG3_KEY_RE/d" "$AWG_CONF"
AWG3="n"; NEW_GEN="2.0"
warn "Откатываемся на AmneziaWG 2.0. Конфиг 3.1 сохранён: ${AWG_CONF}.awg31"
[[ "$AWG_GEN" == "3.1" ]] && \
warn "Запрошено 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 show public-key = none сразу после up). Если ключ не
@@ -567,6 +786,37 @@ else
fail "Ключ на $IFACE не совпал: ${RUNNING_PUB:-none} ≠ $PUB_KEY"
fi
TOOLS_VER=$(awg --version 2>/dev/null | awk '{print $2; exit}')
# Закрепляем версию пакета, чтобы unattended-upgrades не подменил модуль под
# живым сервером (issue #215: между двумя dkms-сборками словили регрессию —
# хендшейк проходит, данные не идут). Unhold перед апгрейдом уже сделан выше;
# hold ставим только теперь, когда версия проверена и интерфейс реально поднялся.
if [[ "$AWG_PIN" == "y" ]]; then
apt-mark hold amneziawg amneziawg-dkms amneziawg-tools >/dev/null 2>&1 || true
ok "Версия пакета закреплена (apt-mark hold) — снять: apt-mark unhold amneziawg amneziawg-dkms amneziawg-tools"
fi
GEN_LABEL=$([[ "$AWG3" == "y" ]] && echo "3.1" || echo "2.0")
ok "AmneziaWG $GEN_LABEL · модуль ${MOD_VER:-?} · tools ${TOOLS_VER:-?}$([[ "$AWG_PIN" == "y" ]] && echo " · версия закреплена")"
# UFW. PostUp делает `iptables -A FORWARD` — правило ДОПИСЫВАЕТСЯ в конец
# цепочки, а UFW вставляет свои переходы в начало, поэтому до нашего ACCEPT
# дело не доходит и трафик клиентов наружу режется: в dmesg сыплется
# «[UFW BLOCK] IN=awg1 OUT=eth0». Симптом — «VPN подключается, интернета нет».
# Лечится штатным `ufw route allow`, менять DEFAULT_FORWARD_POLICY не нужно.
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -qi '^Status: active'; then
echo " → UFW активен, открываем маршрут и порты"
ufw route allow in on "$IFACE" out on "$NET_IFACE" >/dev/null 2>&1 \
|| warn "ufw route allow не сработал — трафик клиентов может резаться"
ufw allow "${AWG_PORT}/udp" >/dev/null 2>&1 || warn "не удалось открыть ${AWG_PORT}/udp"
ufw allow "${UI_PORT}/tcp" >/dev/null 2>&1 || warn "не удалось открыть ${UI_PORT}/tcp"
ufw reload >/dev/null 2>&1 || true
ok "UFW: форвардинг $IFACE → $NET_IFACE, порты ${AWG_PORT}/udp и ${UI_PORT}/tcp"
else
echo " → UFW не активен, правила фаервола не трогаем"
fi
step "6/7 npm install"
npm_install_all
@@ -601,6 +851,11 @@ UI_PORT=${UI_PORT}
UI_USER=${UI_USER}
UI_PASS=${UI_PASS}
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
chmod 600 "$PROJECT/cli/cli.env"