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

775 lines
31 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";
}
// 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 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,
};
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();
logger.info("AWG restart: done");
}
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 },
});
});
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();