Files
forgetting/awg-ctrl/index.ts
T
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

805 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import crypto from "crypto";
import { execSync, spawnSync } from "child_process";
import * as zlib from "zlib";
import express, { Request, Response, NextFunction } from "express";
import Database from "better-sqlite3";
import winston from "winston";
const logsDir = path.join(process.cwd(), "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
const logger = winston.createLogger({
level: "debug",
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ timestamp, level, message }) =>
`${timestamp} ${level}: ${message}`
),
),
}),
new winston.transports.File({
filename: path.join(logsDir, "app.log"),
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json(),
),
}),
],
});
const INTERNAL_AUTH_PUB_FILE = process.env.INTERNAL_AUTH_PUB_FILE
?? "/etc/amnezia/amneziawg/internal_auth_public.key";
let internalAuthPubKey: crypto.KeyObject;
try {
internalAuthPubKey = crypto.createPublicKey(readFileSync(INTERNAL_AUTH_PUB_FILE));
} catch {
logger.error("FATAL: публичный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_PUB_FILE);
process.exit(1);
}
const SERVER = {
port: Number(process.env.PORT) || 3005,
};
const dbPath = path.join("/etc/amnezia/amneziawg", "users.db");
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS users (
name TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
pub_key TEXT NOT NULL,
vpn_key TEXT NOT NULL,
psk_key TEXT NOT NULL DEFAULT ''
)
`);
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,
value TEXT NOT NULL
)
`);
const cfgStmts = {
get: db.prepare<[string], { value: string }>("SELECT value FROM config WHERE key = ?"),
set: db.prepare<[string, string]>("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)"),
};
function getCfg(key: string, fallback: string): string {
return cfgStmts.get.get(key)?.value ?? fallback;
}
function setCfg(key: string, value: string) {
cfgStmts.set.run(key, value);
}
function getLocalIp(): string {
const route = spawnSync("ip", ["route", "show", "default"]);
const iface = route.stdout.toString().match(/dev\s+(\S+)/)?.[1];
if (!iface) return "";
const addr = spawnSync("ip", ["addr", "show", iface]);
return addr.stdout.toString().match(/inet\s+([\d.]+)/)?.[1] ?? "";
}
function initConfig() {
const serverIp = getCfg("serverIp", process.env.SERVER_IP ?? getLocalIp());
const serverPort = getCfg("serverPort", process.env.SERVER_PORT ?? "51820");
const serverName = getCfg("serverName", process.env.SERVER_NAME ?? "VPN");
if (!serverIp) throw new Error("SERVER_IP не задан — передай через env при первом запуске");
setCfg("serverIp", serverIp);
setCfg("serverPort", serverPort);
setCfg("serverName", serverName);
return { serverIp, serverPort: Number(serverPort), serverName };
}
// Ключи, появившиеся в 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,
S1: 90, S2: 45, S3: 37, S4: 14,
H1: "1224800044-2116730834",
H2: "2122053282-2133204808",
H3: "2133604274-2140756116",
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 {
const confFile = path.join("/etc/amnezia/amneziawg", "awg1.conf");
const params: AwgParams = { ...DEFAULT_AWG_PARAMS };
if (!existsSync(confFile)) {
logger.warn("awg1.conf не найден — параметры обфускации по умолчанию");
return params;
}
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", ...AWG3_KEYS,
];
for (const k of numKeys) {
const m = iface.match(new RegExp(`^\\s*${k}\\s*=\\s*(\\d+)`, "m"));
if (m) (params[k] as number) = Number(m[1]);
}
for (const k of strKeys) {
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, 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",
subnet: "10.9",
serverIp: runtimeConfig.serverIp,
serverPort: runtimeConfig.serverPort,
serverName: runtimeConfig.serverName,
dns1: "1.1.1.1",
dns2: "1.0.0.1",
mtu: 1376,
keepalive: KEEPALIVE_BY_GEN[AWG_GEN],
awgParams: AWG_PARAMS,
gen: AWG_GEN,
// Мутируются в restartAwg() — единственном месте, где модуль может
// реально смениться в течение жизни процесса.
awgModule: AWG_VERSIONS.module,
awgTools: AWG_VERSIONS.tools,
};
interface UserRow {
name: string;
ip: string;
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 = ?"),
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 {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function generateKeys() {
const privateKey = run("umask 077 && awg genkey");
const r = spawnSync("awg", ["pubkey"], { input: privateKey, encoding: "utf8" });
if (r.status !== 0) throw new Error("awg pubkey завершился с ошибкой");
const publicKey = (r.stdout as string).trim();
const presharedKey = run("awg genpsk");
return { privateKey, publicKey, presharedKey };
}
function getServerPublicKey(): string {
const f = path.join(CONFIG.confDir, "server_public.key");
if (!existsSync(f)) throw new Error("server_public.key не найден");
return readFileSync(f, "utf8").trim();
}
function nextIp(): string {
const usedIps = new Set(stmts.ips.all().map((r: { ip: string }) => r.ip));
for (let c = 0; c <= 255; c++)
for (let d = 2; d <= 254; d++) {
const ip = `${CONFIG.subnet}.${c}.${d}`;
if (!usedIps.has(ip)) return ip;
}
throw new Error("Подсеть заполнена");
}
// Официальный формат .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), ни инструменты правки. Не «чистить»!
function buildClientConf(
keys: ReturnType<typeof generateKeys>,
ip: string,
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}
Jmin = ${p.Jmin}
Jmax = ${p.Jmax}
S1 = ${p.S1}
S2 = ${p.S2}
S3 = ${p.S3}
S4 = ${p.S4}
H1 = ${p.H1}
H2 = ${p.H2}
H3 = ${p.H3}
H4 = ${p.H4}
I1 = ${p.I1}
I2 =${" "}
I3 =${" "}
I4 =${" "}
I5 =${" "}
${awg3}Address = ${ip}/32
DNS = ${CONFIG.dns1}, ${CONFIG.dns2}
[Peer]
PublicKey = ${serverPub}
PresharedKey = ${keys.presharedKey}
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = ${CONFIG.serverIp}:${CONFIG.serverPort}
PersistentKeepalive = ${CONFIG.keepalive}
`;
}
function encodeVpnKey(
keys: ReturnType<typeof generateKeys>,
ip: string,
serverPub: string,
): string {
const p = CONFIG.awgParams;
const clientConf = buildClientConf(keys, ip, serverPub);
// Ключи в объектах идут в том же 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,
client_ip: ip,
client_priv_key: keys.privateKey,
client_pub_key: keys.publicKey,
config: clientConf,
hostName: CONFIG.serverIp,
mtu: String(CONFIG.mtu),
persistent_keep_alive: String(CONFIG.keepalive),
port: CONFIG.serverPort,
psk_key: keys.presharedKey,
server_pub_key: serverPub,
});
const json = JSON.stringify({
containers: [{
container: "amnezia-awg2",
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: CONFIG.gen,
subnet_address: `${CONFIG.subnet}.0.0`,
transport_proto: "udp",
}),
}],
defaultContainer: "amnezia-awg2",
description: CONFIG.serverName,
dns1: CONFIG.dns1,
dns2: CONFIG.dns2,
hostName: CONFIG.serverIp,
nameOverriddenByUser: true,
});
const jsonBuf = Buffer.from(json, "utf8");
const compressed = zlib.deflateSync(jsonBuf);
const header = Buffer.alloc(4);
header.writeUInt32BE(jsonBuf.length, 0);
return "vpn://" + Buffer.concat([header, compressed])
.toString("base64url")
.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`);
const result: Record<string, { online: boolean; lastHandshake: number; rx: number; tx: number }> = {};
const now = Math.floor(Date.now() / 1000);
const lines = output.split("\n");
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split("\t");
const pubKey = parts[0];
if (!pubKey) continue;
const lastHandshake = Number(parts[4]);
const rx = Number(parts[5]);
const tx = Number(parts[6]);
result[pubKey] = {
online: lastHandshake > 0 && (now - lastHandshake) < 180,
lastHandshake, rx, tx,
};
}
return result;
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
}
function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
if (!existsSync(confFile)) return;
const conf = readFileSync(confFile, "utf8");
const iface = conf.split(/^\[Peer\]/m)[0].trimEnd();
const peers = (users as any[]).map(u =>
`\n# ${u.name}\n[Peer]\nPublicKey = ${u.pub_key}\nPresharedKey = ${u.psk_key}\nAllowedIPs = ${u.ip}/32\nPersistentKeepalive = ${CONFIG.keepalive}`
).join("\n");
writeFileSync(confFile, iface + "\n" + peers + "\n");
logger.info("conf rebuilt", { peers: users.length });
}
const SERVER_PRIV_KEY_FILE = "/etc/amnezia/server_private.key";
const AWGQUICK_ONLY_KEY = /^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=/i;
// Готовит «stripped»-конфиг для `awg syncconf`: берёт awg1.conf, выкидывает
// awg-quick-ключи и оставляет [Interface] (PrivateKey + Jc/S/H + ListenPort) и
// [Peer]-блоки.
// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Раньше
// syncPeers отдавал в syncconf только [Peer]-блоки — и AmneziaWG обнулял
// приватный ключ интерфейса и параметры обфускации, после чего сервер
// поднимался с public-key=(none) и ВСЕ клиенты отваливались.
// PrivateKey подставляем из server_private.key — это та же идентичность, что в
// server_public.key (его зашивают в vpn:// ключи клиентов) и в PostUp.
function buildSyncConf(): string {
const confFile = path.join(CONFIG.confDir, `${CONFIG.interface}.conf`);
const priv = readFileSync(SERVER_PRIV_KEY_FILE, "utf8").trim();
const out: string[] = [];
let privReplaced = false;
for (const line of readFileSync(confFile, "utf8").split("\n")) {
if (AWGQUICK_ONLY_KEY.test(line)) continue;
if (/^\s*PrivateKey\s*=/.test(line)) {
out.push(`PrivateKey = ${priv}`);
privReplaced = true;
continue;
}
out.push(line);
}
if (!privReplaced) {
const idx = out.findIndex(l => /^\s*\[Interface\]/.test(l));
if (idx >= 0) out.splice(idx + 1, 0, `PrivateKey = ${priv}`);
}
return out.join("\n");
}
function syncPeers() {
rebuildConf();
const tmpFile = `/tmp/awg_sync_${Date.now()}.conf`;
try {
writeFileSync(tmpFile, buildSyncConf(), { mode: 0o600 });
const r = spawnSync("awg", ["syncconf", CONFIG.interface, tmpFile]);
if (r.status !== 0) logger.warn("syncPeers syncconf failed", { stderr: r.stderr?.toString() });
} finally {
try { fs.unlinkSync(tmpFile); } catch {}
}
const n = (db.prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number }).n;
logger.info("peers synced", { count: n });
}
function getInterfaceStatus(): { up: boolean; peers: number; publicKey: string | null } {
const r = spawnSync("awg", ["show", CONFIG.interface]);
if (r.status !== 0) return { up: false, peers: 0, publicKey: null };
const output = r.stdout.toString();
const peers = (output.match(/^peer:/gm) ?? []).length;
const publicKey = output.match(/public key:\s*(.+)/)?.[1]?.trim() ?? null;
return { up: true, peers, publicKey };
}
function ensureInterfaceUp() {
const { up } = getInterfaceStatus();
if (!up) throw new Error(`Interface ${CONFIG.interface} is not up. Run: awg-quick up ${CONFIG.interface}`);
}
function restartAwg() {
logger.info("AWG restart: down");
const down = spawnSync("awg-quick", ["down", CONFIG.interface]);
if (down.status !== 0) logger.warn("awg-quick down failed", { stderr: down.stderr?.toString() });
logger.info("AWG restart: up");
const up = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (up.status !== 0) throw new Error(`awg-quick up failed: ${up.stderr?.toString()}`);
syncPeers();
const versions = readAwgVersions();
CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools;
logger.info("AWG restart: done", versions);
}
function startInterface() {
const status = getInterfaceStatus();
if (status.up) { logger.info("AWG already up", { peers: status.peers }); return status; }
const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
syncPeers();
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, 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 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, 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) {
const user = stmts.get.get(username);
if (!user) throw new Error("Пользователь не найден");
stmts.delete.run(username);
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
rebuildConf();
for (const ext of [".conf", ".key"]) {
const f = path.join(CONFIG.confDir, "clients", username + ext);
if (existsSync(f)) fs.unlinkSync(f);
}
logger.info("user removed", { name: username });
}
const app = express();
app.use(express.json({ limit: "1kb" }));
function verifyInternalToken(token: string): boolean {
try {
const [h, p, s] = token.split(".");
if (!h || !p || !s) return false;
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`), internalAuthPubKey, Buffer.from(s, "base64url"));
if (!ok) return false;
const { exp } = JSON.parse(Buffer.from(p, "base64url").toString()) as { exp: number };
return exp > Math.floor(Date.now() / 1000);
} catch { return false; }
}
function auth(req: Request, res: Response, next: NextFunction) {
const header = (req.headers["authorization"] ?? "") as string;
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token || !verifyInternalToken(token)) {
res.status(401).json({ error: "Неверная авторизация" }); return;
}
next();
}
function validateName(req: Request, res: Response, next: NextFunction) {
const name = req.params.name ?? (req.body as { name?: string }).name;
if (!name || !/^[a-zA-Z0-9_-]{1,32}$/.test(name)) {
res.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" }); return;
}
next();
}
function handler(fn: (req: Request, res: Response) => void | Promise<void>) {
return async (req: Request, res: Response) => {
try { await fn(req, res); }
catch (e) { logger.error("handler error", { error: e }); res.status(500).json({ error: "Internal server error" }); }
};
}
app.get("/health", (_req, res) => {
const { up, peers } = getInterfaceStatus();
res.status(up ? 200 : 503).json({
status: up ? "ok" : "degraded",
server: CONFIG.serverName,
ip: CONFIG.serverIp,
gen: CONFIG.gen,
awg: { status: up ? "ok" : "down", peers, module: CONFIG.awgModule, tools: CONFIG.awgTools },
});
});
app.post("/api/users", auth, validateName, handler((req, res) => {
const { name } = req.body as { name: string };
if (stmts.get.get(name)) {
res.status(409).json({ error: "Пользователь уже существует" }); return;
}
res.status(201).json(addUser(name));
}));
app.get("/api/users", auth, handler((_req, res) => {
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 => ({
...u,
online: peers[u.pub_key]?.online ?? false,
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
})),
});
}));
app.get("/api/users/stats", auth, handler((_req, res) => {
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,
tx: peers[u.pub_key]?.tx ?? 0,
})),
});
}));
// ⚠️ Должен быть объявлен ДО «/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; }
res.json(user);
}));
app.delete("/api/users/:name", auth, validateName, handler((req, res) => {
removeUser(req.params.name);
res.json({ success: true, name: req.params.name });
}));
app.post("/awg/restart", auth, handler((_req, res) => {
restartAwg();
res.json({ success: true });
}));
app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus());
}));
app.post("/awg/start", auth, handler((_req, res) => {
res.json(startInterface());
}));
app.listen(SERVER.port, "127.0.0.1", () => {
logger.info("Server started", { port: SERVER.port, host: "127.0.0.1", serverName: CONFIG.serverName, serverIp: CONFIG.serverIp });
});
ensureInterfaceUp();
syncPeers();