Files
forgetting/cli/src/index.ts
T
maeneko 6e3959d16c Add sen:// subscription with master keys
A master key is a sen:// link with a device limit. Each device
generates its own WireGuard keypair and registers only the public key,
then polls a signed /sub/v1/config, so params and generation changes
reach users without re-importing a key.

- awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and
  rebuildConf take peers into account (output unchanged without them)
- awg-ui: link codec and signatures (sen.ts), registry, panel routes
  and the public listener on SUB_PORT (sub.ts)
- responses are always Ed25519-signed; TLS with a pinned self-signed
  certificate is optional
- devices report a client version, shown as platform-version
- "Мастер-ключи" tab: a table with the devices nested under each key,
  a device-limit meter, per-key settings and a placeholder for the
  servers a key serves; cards on mobile
- install.sh/CLI: subscription signing key, optional certificate, port;
  kept across reinstalls with KEEP_DATA
- add the missing mock-server.ts used by npm run dev:mock
2026-09-25 17:24:52 +03:00

414 lines
17 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 } 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",
},
pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"),
},
} as const;
type SvcName = keyof typeof SERVICES;
const ALL: SvcName[] = ["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: SvcName, 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() {
await start("awgctrl");
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
await start("ui");
}
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`;
}
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]>";
const [,,, sub] = 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;
default: console.log(USAGE); process.exit(1);
}
}
main().catch(e => { console.error(e); process.exit(1); });