Files
forgetting/awg-ctrl/index.ts
T
2026-07-10 00:19:05 +03:00

592 lines
22 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)");
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 };
}
interface 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;
}
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: "",
};
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"];
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 });
return params;
}
const runtimeConfig = initConfig();
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: 25,
awgParams: readAwgParams(),
};
interface UserRow {
name: string;
ip: string;
pub_key: string;
vpn_key: string;
psk_key: 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 (?, ?, ?, ?, ?)"),
delete: db.prepare<[string]>("DELETE FROM users WHERE name = ?"),
ips: db.prepare<[], { ip: string }>("SELECT ip FROM users"),
};
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) → Address → DNS
// ВНИМАНИЕ: пустые I2–I5 должны выводиться как «I2 = » с ОДНИМ хвостовым пробелом
// (так в рабочих ключах Amnezia). Пробел даётся через ${" "}, чтобы его не срезали
// ни IDE (strip trailing whitespace), ни инструменты правки. Не «чистить»!
function buildClientConf(
keys: ReturnType<typeof generateKeys>,
ip: string,
serverPub: string,
): string {
const p = CONFIG.awgParams;
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 =${" "}
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);
const lastConfigObj = {
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
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: {
H1: p.H1, H2: p.H2, H3: p.H3, H4: p.H4,
I1: p.I1, I2: "", I3: "", I4: "", I5: "",
Jc: String(p.Jc),
Jmax: String(p.Jmax),
Jmin: String(p.Jmin),
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",
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(/=+$/, "");
}
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();
}
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);
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 {}
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 };
}
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,
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 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 FROM users WHERE vpn_key != ''").all() as UserRow[];
const peers = getPeersData();
res.json({
users: users.map(u => ({
name: u.name,
ip: u.ip,
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,
})),
});
}));
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();