Files
forgetting/cli/src/index.ts
T
maeneko 2054122110 Serve a master key's devices from several servers
A master key now has a set of servers (the panel's own and/or nodes). A
device's single WireGuard key becomes a peer on every server of its key,
each with its own IP and PSK, and config.servers[] lists them all.

- ui.db: master_servers (the key's set, never empty), device_peers (which
  key is on which server, with the ip/psk that server issued),
  peer_tombstones (peers to delete) and server_profiles (last known
  profile of each server).
- Nothing is pushed blindly: reconcile() brings one server to the desired
  state - deletes tombstones, adds peers, rekeys with PUT, fills ip/psk,
  rotates a pending PSK - one pass per server at a time. It runs right
  after every action, when a node connects and once a minute, so a node
  that was offline catches up on rekeys, PSK changes and removals.
- config is built from ui.db, so an offline node stays in it and rev does
  not flap; a server is listed once its peer carries the device's current
  key. An empty config is never sent (502 instead); register and rekey
  roll back if no server accepted the key.
- Existing keys and devices migrate on start. With one server the config
  and its rev are byte-identical to 0.3.1 (checked by running both
  versions on the same ui.db), so existing clients notice nothing.
- Panel: server checkboxes in the key settings instead of the placeholder
  (the last one cannot be removed), "на N из M серверов" on devices that
  have not reached every server yet, device stats summed over servers.
- SUB_HOST: the panel's public address for sen:// links and endpoints;
  install.sh writes it. Without it a panel with no VPN of its own used
  the address it was opened at.
- A node referenced by a key's server set cannot be deleted.
2026-09-26 21:43:58 +03:00

525 lines
25 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 { spawn, spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync,
mkdirSync, openSync, closeSync,
unlinkSync } from "fs";
import * as readline from "readline";
import * as crypto from "crypto";
import * as http from "http";
import path from "path";
const ROOT = path.resolve(__dirname, "../..");
const LOGS = path.join(ROOT, "logs");
if (!existsSync(LOGS)) mkdirSync(LOGS, { recursive: true });
// Уже заданное окружение не перетираем (??=), поэтому порядок = приоритет:
// реальное окружение → cli.env (машинные настройки, пишет install.sh) →
// .env в корне проекта (имя панели и версия, едет в архиве релиза).
function loadEnvFile(file: string) {
if (!existsSync(file)) return;
for (const line of readFileSync(file, "utf8").split("\n")) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
const idx = t.indexOf("=");
if (idx === -1) continue;
process.env[t.slice(0, idx).trim()] ??= t.slice(idx + 1).trim();
}
}
const envFile = path.join(__dirname, "..", "cli.env"); // машинные настройки
loadEnvFile(envFile);
loadEnvFile(path.join(ROOT, ".env")); // имя панели и версия
// Имя панели, канал и версия — из .env (или из cli.env, если оператор задал
// своё имя при установке). В коде только запасные значения на случай, когда
// .env не доехал.
const BRAND = process.env.BRAND || "Forgetting";
const CHANNEL = process.env.CHANNEL || "Beta";
const VERSION = process.env.VERSION || "";
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
const C = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
green: "\x1b[32m",
grey: "\x1b[90m",
blue: "\x1b[34m",
};
const bold = (s: string) => `${C.bold}${s}${C.reset}`;
const green = (s: string) => `${C.green}${s}${C.reset}`;
const grey = (s: string) => `${C.grey}${s}${C.reset}`;
const dim = (s: string) => `${C.dim}${s}${C.reset}`;
const SERVICES = {
awgctrl: {
label: "awg-ctrl",
cwd: path.join(ROOT, "awg-ctrl"),
entry: "index.ts",
env: {
PORT: process.env.AWGCTRL_PORT ?? "3005",
SERVER_IP: process.env.SERVER_IP ?? "",
SERVER_PORT: process.env.SERVER_PORT ?? "47619",
SERVER_NAME: process.env.SERVER_NAME ?? "VPN",
// Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui).
INTERNAL_AUTH_PUB_FILE: process.env.INTERNAL_AUTH_PUB_FILE
?? "/etc/amnezia/amneziawg/internal_auth_public.key",
},
pidFile: "/tmp/awg-ctrl.pid",
logFile: path.join(LOGS, "awg-ctrl.log"),
},
ui: {
label: "awg-ui",
cwd: path.join(ROOT, "awg-ui"),
entry: "server.ts",
env: {
PORT: process.env.UI_PORT ?? "8080",
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
// Приватный ключ внутренней авторизации (awg-ui подписывает им токены к awg-ctrl).
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
// Панель показывает их в вордмарке (GET /ui/brand).
BRAND: BRAND,
CHANNEL: CHANNEL,
VERSION: VERSION,
UI_USER: process.env.UI_USER ?? "admin",
UI_PASS: process.env.UI_PASS ?? "",
JWT_SECRET: process.env.JWT_SECRET ?? "",
// ui.db (ключи внешнего API) в каталоге данных AWG (вне PROJECT) —
// переживает переустановку.
UI_DB_FILE: process.env.UI_DB_FILE ?? "/etc/amnezia/amneziawg/ui.db",
// Подписка sen:// (мастер-ключи SenAWG): порт, режим TLS, ключ подписи и
// сертификат. Без SUB_PORT или ключа подписи awg-ui подписку не поднимает.
SUB_PORT: process.env.SUB_PORT ?? "",
SUB_TLS: process.env.SUB_TLS ?? "off",
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
SUB_HOST: process.env.SUB_HOST ?? "",
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
},
pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"),
},
// Агент ноды: сам звонит в core (CORE_HOST/PORT/PIN из join-строки) и исполняет запросы
// на локальном awg-ctrl. Запускается вместо awg-ui — см. ROLE.
agent: {
label: "awg-agent",
cwd: path.join(ROOT, "awg-agent"),
entry: "index.ts",
env: {
CORE_HOST: process.env.CORE_HOST ?? "",
CORE_PORT: process.env.CORE_PORT ?? "",
CORE_PIN: process.env.CORE_PIN ?? "",
NODE_ID: process.env.NODE_ID ?? "",
NODE_KEY_FILE: process.env.NODE_KEY_FILE ?? "/etc/amnezia/amneziawg/node.key",
JOIN_SECRET: process.env.JOIN_SECRET ?? "",
CLI_ENV_FILE: envFile,
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
},
pidFile: "/tmp/awg-agent.pid",
logFile: path.join(LOGS, "awg-agent.log"),
},
} as const;
type SvcName = keyof typeof SERVICES;
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
const IS_NODE = !!process.env.CORE_HOST;
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
// панели нет (UI_PASS не задан).
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
function readPid(name: SvcName): number | null {
const { pidFile } = SERVICES[name];
if (!existsSync(pidFile)) return null;
const n = parseInt(readFileSync(pidFile, "utf8").trim());
return isNaN(n) ? null : n;
}
function isAlive(pid: number): boolean {
// EPERM → процесс существует, просто не наш (трактуем как «жив»).
try { process.kill(pid, 0); return true; }
catch (e: any) { return e?.code === "EPERM"; }
}
// Защита от переиспользования PID: на Linux сверяем cmdline процесса
// с entry-файлом сервиса. Если procfs недоступен — проверить нечем.
function pidMatchesService(name: SvcName, pid: number): boolean {
const cmdlinePath = `/proc/${pid}/cmdline`;
if (!existsSync(cmdlinePath)) return true;
try {
const cmdline = readFileSync(cmdlinePath, "utf8");
return cmdline.includes(SERVICES[name].entry);
} catch { return true; }
}
function isRunning(name: SvcName): boolean {
const pid = readPid(name);
return pid !== null && isAlive(pid) && pidMatchesService(name, pid);
}
async function start(name: SvcName) {
const svc = SERVICES[name];
if (isRunning(name)) {
console.log(` ${svc.label}: already running pid=${readPid(name)}`);
return;
}
if (name === "awgctrl" && "INTERNAL_AUTH_PUB_FILE" in svc.env && !existsSync(svc.env.INTERNAL_AUTH_PUB_FILE)) {
console.error(" awgctrl: публичный ключ внутренней авторизации не найден: " + svc.env.INTERNAL_AUTH_PUB_FILE);
process.exit(1);
}
// npx завершается сразу после передачи управления node → его PID мёртв.
// Запускаем tsx напрямую из node_modules/.bin/, чтобы child.pid был
// реальным PID сервера. Ищем бинарник и в самом сервисе, и в корне
// (на случай hoisting зависимостей в общий node_modules).
const candidates = [
path.join(svc.cwd, "node_modules/.bin/tsx"),
path.join(ROOT, "node_modules/.bin/tsx"),
];
const tsxBin = candidates.find(existsSync);
const bin = tsxBin ?? "npx";
const args = tsxBin ? [svc.entry] : ["tsx", svc.entry];
const logFd = openSync(svc.logFile, "a");
const child = spawn(bin, args, {
cwd: svc.cwd,
env: { ...process.env, ...svc.env },
detached: true,
stdio: ["ignore", logFd, logFd],
});
let spawnError: Error | null = null;
child.on("error", e => { spawnError = e; });
// Даём spawn шанс упасть (ENOENT и т.п.) до записи pid-файла.
await sleep(50);
closeSync(logFd); // fd унаследован ребёнком — родителю не нужен
if (spawnError || !child.pid) {
console.error(` ${svc.label}: не удалось запустить — ` +
`${spawnError ? (spawnError as Error).message : "нет PID"}`);
try { unlinkSync(svc.pidFile); } catch {}
return;
}
child.unref();
writeFileSync(svc.pidFile, String(child.pid));
console.log(` ${green("▶")} ${svc.label}: started pid=${child.pid}`);
}
async function stop(name: SvcName) {
const svc = SERVICES[name];
const pid = readPid(name);
if (pid === null || !isAlive(pid) || !pidMatchesService(name, pid)) {
console.log(` ${svc.label}: not running`);
try { unlinkSync(svc.pidFile); } catch {}
return;
}
try { process.kill(pid, "SIGTERM"); } catch {}
// Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL.
const deadline = Date.now() + 5000;
while (isAlive(pid) && Date.now() < deadline) await sleep(100);
if (isAlive(pid)) {
try { process.kill(pid, "SIGKILL"); } catch {}
await sleep(200);
}
try { unlinkSync(svc.pidFile); } catch {}
console.log(` ${grey("■")} ${svc.label}: stopped pid=${pid}`);
}
async function restart(name: SvcName) {
await stop(name);
await start(name);
}
// GET /health и разбор JSON-тела. Любая ошибка (не запущен, таймаут, не JSON)
// молча даёт null — health-строка в status() тогда просто не печатается.
interface HealthBody {
gen?: string;
awg?: { module?: string; tools?: string };
}
function fetchHealth(url: string, timeoutMs = 1000): Promise<HealthBody | null> {
return new Promise(resolve => {
const req = http.get(url, res => {
let body = "";
res.on("data", (c: Buffer) => { body += c; });
res.on("end", () => {
try { resolve(JSON.parse(body) as HealthBody); } catch { resolve(null); }
});
});
req.on("error", () => resolve(null));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
});
}
async function status() {
console.log("");
for (const name of ALL) {
const running = isRunning(name);
const pid = readPid(name);
const dot = running ? green("●") : grey("○");
const info = running ? green("running") + ` pid ${pid}` : grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
}
if (isRunning("awgctrl")) {
const h = await fetchHealth(`http://127.0.0.1:${SERVICES.awgctrl.env.PORT}/health`);
if (h?.gen) {
const gen = h.gen === "3.1" ? "3.1" : "2.0";
console.log(` ${dim(`AmneziaWG ${gen} · модуль ${h.awg?.module || "?"} · tools ${h.awg?.tools || "?"}`)}`);
}
}
console.log("");
}
// HTTP-проба: любой ответ (даже 401/503) означает, что порт слушается.
function probe(url: string, timeoutMs = 1000): Promise<boolean> {
return new Promise(resolve => {
const req = http.get(url, res => { res.resume(); resolve(true); });
req.on("error", () => resolve(false));
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(false); });
});
}
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
// ensureInterfaceUp() бросил исключение) — сразу выходим.
async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
for (let i = 0; i < attempts; i++) {
if (!isRunning(name)) return false;
if (await probe(url)) return true;
await sleep(300);
}
return false;
}
async function startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
await start("awgctrl");
const rest = ALL.filter(n => n !== "awgctrl");
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
for (const n of rest) await start(n);
}
async function stopAll() { for (const n of ALL) await stop(n); }
async function restartAll() { await stopAll(); await startAll(); }
function updateEnvVar(content: string, key: string, value: string): string {
const re = new RegExp(`^${key}=.*$`, "m");
return re.test(content)
? content.replace(re, `${key}=${value}`)
: content.trimEnd() + `\n${key}=${value}\n`;
}
function removeEnvVar(content: string, key: string): string {
return content.split("\n").filter(l => !l.startsWith(`${key}=`)).join("\n");
}
// Роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск делаем свежим процессом CLI, а не в этом же.
function restartFresh(): void {
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
}
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
// работать, если она здесь есть, — с --no-panel она останавливается.
async function joinCore(link?: string, flag?: string) {
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
process.exit(1);
}
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
try { j = JSON.parse(Buffer.from(link.slice("awgjoin://".length), "base64url").toString("utf8")); }
catch { console.error(" Ссылка повреждена"); process.exit(1); }
const ok = j.v === 1 && typeof j.h === "string" && /^[A-Za-z0-9.:_-]{1,253}$/.test(j.h)
&& Number.isInteger(j.p) && j.p! > 0 && j.p! < 65536
&& typeof j.pin === "string" && /^[A-Za-z0-9_-]{43}$/.test(j.pin)
&& Number.isInteger(j.n) && j.n! > 0 && typeof j.s === "string" && /^[A-Za-z0-9_-]{22}$/.test(j.s);
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const set: Record<string, string> = {
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
NODE_PANEL: keepPanel ? "on" : "off",
};
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 });
if (!keepPanel) await stop("ui");
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh();
}
async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent");
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
writeFileSync(envFile, content, { mode: 0o600 });
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if (!process.env.UI_PASS)
console.log(` ${dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")}`);
}
async function confirmYes(q: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(`${q} [y/N] `, r));
rl.close();
return /^y/i.test(a.trim());
}
async function setCredentials(field?: "user" | "pass") {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
let user: string | undefined;
let pass: string | undefined;
if (!field || field === "user") {
const cur = process.env.UI_USER ?? "admin";
user = ((await ask(`\n Логин [${cur}]: `)).trim()) || cur;
}
if (!field || field === "pass") {
const generated = crypto.randomBytes(9).toString("base64url").slice(0, 12);
pass = ((await ask(` Пароль [${generated}]: `)).trim()) || generated;
}
rl.close();
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
if (user !== undefined) { content = updateEnvVar(content, "UI_USER", user); process.env.UI_USER = user; }
if (pass !== undefined) { content = updateEnvVar(content, "UI_PASS", pass); process.env.UI_PASS = pass; }
writeFileSync(envFile, content, "utf8");
if (user !== undefined) console.log(`\n ${green("✓")} Логин: ${user}`);
if (pass !== undefined) console.log(` ${green("✓")} Пароль: ${pass}`);
console.log(`\n ${dim("Перезапусти UI чтобы изменения вступили в силу.")}`);
}
async function interactiveMenu() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const ask = (prompt: string): Promise<string> =>
new Promise(resolve => rl.question(prompt, resolve));
const printMenu = () => {
console.clear();
console.log(`\n${bold(`${C.blue}── ${[BRAND, CHANNEL, VERSION].filter(Boolean).join(" ")} ──${C.reset}`)}\n`);
for (const name of ALL) {
const running = isRunning(name);
const pid = readPid(name);
const dot = running ? green("●") : grey("○");
const info = running
? green("running") + dim(` pid ${pid}`)
: grey("stopped");
console.log(` ${dot} ${SERVICES[name].label.padEnd(12)} ${info}`);
}
console.log(`
${bold("1")} Запустить
${bold("2")} Остановить
${bold("3")} Перезапустить
${bold("4")} Статус
${dim("──────────────")}
${bold("5")} Сменить логин UI
${bold("6")} Сменить пароль UI
${dim("──────────────")}
${bold("0")} Выход
`);
};
while (true) {
printMenu();
const choice = (await ask(" › ")).trim();
console.log("");
switch (choice) {
case "1": await startAll(); break;
case "2": await stopAll(); break;
case "3": await restartAll(); break;
case "4": await status(); break;
case "5": await setCredentials("user"); break;
case "6": await setCredentials("pass"); break;
case "0": rl.close(); process.exit(0);
default: console.log(grey(" Неверный выбор")); break;
}
if (choice !== "0") {
await ask(`\n ${dim("Enter для продолжения...")}`);
}
}
}
async function main() {
const [,, cmd] = process.argv;
if (!cmd) {
await interactiveMenu();
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>";
const [,,, sub, arg] = process.argv;
switch (cmd) {
case "start": await startAll(); break;
case "stop": await stopAll(); break;
case "restart": await restartAll(); break;
case "status": await status(); break;
case "credentials":
if (sub === "user") await setCredentials("user");
else if (sub === "pass") await setCredentials("pass");
else await setCredentials();
break;
case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break;
default: console.log(USAGE); process.exit(1);
}
}
main().catch(e => { console.error(e); process.exit(1); });