mirror of
https://github.com/maeneko/forgetting.git
synced 2026-08-25 15:24:26 +00:00
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.
805 lines
33 KiB
TypeScript
805 lines
33 KiB
TypeScript
// 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();
|