2 Commits
Author SHA1 Message Date
maenekoandClaude Sonnet 5.5 18106115f7 Start the Go rewrite: jsjson, awg-ctrl port, differential bench
ci / check (push) Successful in 6m6s
- internal/jsjson: JSON.stringify-compatible encoder and order-preserving decoder,
  tested against fixtures produced by Node itself
- internal/ctrl: awg-ctrl in Go (params, vpn:// keys, users, peers, sync, 3.1
  upgrade, CAKE shaping, metrics, monitor job, Express-compatible HTTP layer)
- internal/iauth, internal/logx: internal Ed25519 tokens and logging
- tools/diffbench: Docker bench with stubbed awg/ip/tc/nft that runs one scenario
  against the TS and Go implementations for comparison

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:27:05 +03:00
maenekoandClaude Sonnet 5.5 e9df363dd2 Add shaping, Prometheus, monitor tab and peer audit
Baseline TypeScript version before the Go rewrite: CAKE fair sharing with
automatic deprioritisation of heavy devices, per-key priority, /metrics with
a metrics token, the built-in Prometheus install/remove, the Monitor tab,
grouped sidebar tabs and the dead-peer audit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:27:05 +03:00
61 changed files with 7668 additions and 49 deletions
+3
View File
@@ -21,3 +21,6 @@ yarn-error.log*
# Все markdown, кроме README.md # Все markdown, кроме README.md
*.md *.md
!README.md !README.md
# Go: бинарники и прогоны дифференциального стенда
tools/diffbench/bin/
tools/diffbench/out/
+240 -17
View File
@@ -1,15 +1,16 @@
// Copyright (c) 2026 Ivan Vasilev // Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import crypto from "crypto"; import crypto from "crypto";
import { execSync, spawnSync } from "child_process"; import { execSync, spawnSync, execFile, spawn } from "child_process";
import * as zlib from "zlib"; import * as zlib from "zlib";
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response, NextFunction } from "express";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import winston from "winston"; import winston from "winston";
import { createShaping } from "./shaping";
const logsDir = path.join(process.cwd(), "logs"); const logsDir = path.join(process.cwd(), "logs");
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
@@ -575,6 +576,26 @@ function getPeersData(): Record<string, { online: boolean; lastHandshake: number
} catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; } } catch (e) { logger.warn("getPeersData failed", { error: e }); return {}; }
} }
// Шейпинг (CAKE) и сэмплер тяжёлых устройств. Счётчики читаем асинхронно: цикл
// не должен блокировать event loop, как это делают остальные вызовы awg.
const shaping = createShaping({
iface: CONFIG.interface,
getCfg, setCfg, logger,
// CAKE видит только внутренний IP-пакет, на проводе к нему добавляются
// IPv4 (20) + UDP (8) + заголовок WG (32) = 60 байт и случайный префикс S4.
// Формулу сверять iperf3-замером на стенде (SHAPE_OVERHEAD перекрывает её).
overhead: () => 60 + (Number(CONFIG.awgParams.S4) || 0),
peers: () => new Promise((resolve, reject) => {
execFile("awg", ["show", CONFIG.interface, "dump"], { encoding: "utf8", timeout: 10_000 }, (err, out) => {
if (err) { reject(err); return; }
const rows = out.split("\n").slice(1).map(l => l.split("\t"))
.filter(f => f[0] && f[3])
.map(f => ({ ip: f[3].split(",")[0].split("/")[0], rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 }));
resolve(rows);
});
}),
});
function rebuildConf() { function rebuildConf() {
const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[]; const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[];
const devices = stmts.peersAll.all(); const devices = stmts.peersAll.all();
@@ -680,6 +701,7 @@ function restartAwg() {
// в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас. // в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас.
const gen = reloadAwgParams(); const gen = reloadAwgParams();
syncPeers(); syncPeers();
void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали
const versions = readAwgVersions(); const versions = readAwgVersions();
CONFIG.awgModule = versions.module; CONFIG.awgModule = versions.module;
CONFIG.awgTools = versions.tools; CONFIG.awgTools = versions.tools;
@@ -803,6 +825,7 @@ function startInterface() {
const r = spawnSync("awg-quick", ["up", CONFIG.interface]); const r = spawnSync("awg-quick", ["up", CONFIG.interface]);
if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`); if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`);
syncPeers(); syncPeers();
void shaping.apply();
return getInterfaceStatus(); return getInterfaceStatus();
} }
@@ -849,12 +872,30 @@ function addUser(username: string): UserRow {
}; };
} }
// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove`
// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не
// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и
// повторная попытка (надгробие панели, следующий sync) найдёт пира.
function kernelPeers(): Set<string> | null {
const r = spawnSync("awg", ["show", CONFIG.interface, "peers"]);
return r.status === 0 ? new Set(r.stdout.toString().split("\n").map(l => l.trim()).filter(Boolean)) : null;
}
function dropFromKernel(pub: string): boolean {
const r = spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
if (r.status === 0) return true;
const left = kernelPeers();
if (left && !left.has(pub)) return true; // ответ странный, но пира уже нет
logger.error("peer remove from kernel failed", { pub, stderr: r.stderr?.toString() });
return false;
}
function removeUser(username: string) { function removeUser(username: string) {
const user = stmts.get.get(username); const user = stmts.get.get(username);
if (!user) throw new Error("Пользователь не найден"); if (!user) throw new Error("Пользователь не найден");
if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса");
stmts.delete.run(username); stmts.delete.run(username);
spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]);
rebuildConf(); rebuildConf();
for (const ext of [".conf", ".key"]) { for (const ext of [".conf", ".key"]) {
@@ -920,7 +961,9 @@ function replacePeer(oldPub: string, newPub: string): { ip: string; psk_key: str
stmts.peerRekey.run(oldPub, newPub); stmts.peerRekey.run(oldPub, newPub);
throw new Error(`awg set failed: ${r.stderr?.toString()}`); throw new Error(`awg set failed: ${r.stderr?.toString()}`);
} }
spawnSync("awg", ["set", CONFIG.interface, "peer", oldPub, "remove"]); // Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа не откатываем:
// новый уже стоит, а клиент к этому моменту получил бы ошибку.
if (!dropFromKernel(oldPub)) logger.error("peer rekey: старый ключ остался в ядре", { owner: old.owner });
rebuildConf(); rebuildConf();
logger.info("peer rekeyed", { owner: old.owner, ip: old.ip }); logger.info("peer rekeyed", { owner: old.owner, ip: old.ip });
return { ip: old.ip, psk_key: old.psk_key }; return { ip: old.ip, psk_key: old.psk_key };
@@ -941,8 +984,8 @@ function rotatePeerPsk(pub: string): { ip: string; psk_key: string } {
function removePeer(pub: string) { function removePeer(pub: string) {
const peer = stmts.peerGet.get(pub); const peer = stmts.peerGet.get(pub);
if (!peer) throw new HttpError(404, "Пир не найден"); if (!peer) throw new HttpError(404, "Пир не найден");
if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса");
stmts.peerDelete.run(pub); stmts.peerDelete.run(pub);
spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]);
rebuildConf(); rebuildConf();
logger.info("peer removed", { owner: peer.owner }); logger.info("peer removed", { owner: peer.owner });
} }
@@ -964,18 +1007,15 @@ function syncOwnedPeers(panel: string, keep: string[]): {
const prefix = panel + "/"; const prefix = panel + "/";
const removed: PeerRow[] = []; const removed: PeerRow[] = [];
let claimed = 0; let claimed = 0;
db.transaction(() => { for (const p of stmts.peersAll.all()) {
for (const p of stmts.peersAll.all()) { if (p.owner.startsWith(prefix)) {
if (p.owner.startsWith(prefix)) { // Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); } if (!want.has(p.pub_key) && dropFromKernel(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) { } else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
stmts.peerOwner.run(prefix + p.owner, p.pub_key); stmts.peerOwner.run(prefix + p.owner, p.pub_key);
claimed++; claimed++;
}
} }
})(); }
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
if (removed.length || claimed) rebuildConf(); if (removed.length || claimed) rebuildConf();
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) }); if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
@@ -992,7 +1032,9 @@ const app = express();
const SYNC_PATH = "/api/peers/sync"; const SYNC_PATH = "/api/peers/sync";
const jsonSmall = express.json({ limit: "1kb" }); const jsonSmall = express.json({ limit: "1kb" });
const jsonSync = express.json({ limit: "512kb" }); const jsonSync = express.json({ limit: "512kb" });
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next)); // /api/audit/clean тоже несёт до SYNC_MAX ключей — под 1kb он принимал бы около двадцати.
const BIG_BODY_PATHS = new Set([SYNC_PATH, "/api/audit/clean"]);
app.use((req, res, next) => (BIG_BODY_PATHS.has(req.path) ? jsonSync : jsonSmall)(req, res, next));
function verifyInternalToken(token: string): boolean { function verifyInternalToken(token: string): boolean {
try { try {
@@ -1075,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => {
lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0,
rx: peers[u.pub_key]?.rx ?? 0, rx: peers[u.pub_key]?.rx ?? 0,
tx: peers[u.pub_key]?.tx ?? 0, tx: peers[u.pub_key]?.tx ?? 0,
// «normal» | «background»: фоновый — пока канал занят, а устройство давно качает больше своей доли
priority: shaping.priorityOf(u.ip),
})), })),
}); });
})); }));
@@ -1111,10 +1155,43 @@ app.get("/api/peers", auth, handler((_req, res) => {
lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0, lastHandshake: stats[p.pub_key]?.lastHandshake ?? 0,
rx: stats[p.pub_key]?.rx ?? 0, rx: stats[p.pub_key]?.rx ?? 0,
tx: stats[p.pub_key]?.tx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})), })),
}); });
})); }));
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять их
// после сбоя `awg set … remove` некому. Только чтение — панель (core) сама решает, что делать.
app.get("/api/audit", auth, handler((_req, res) => {
const r = spawnSync("awg", ["show", CONFIG.interface, "dump"]);
if (r.status !== 0) throw new HttpError(503, "awg show не ответил");
const known = new Set<string>([
...(db.prepare("SELECT pub_key FROM users").all() as { pub_key: string }[]).map(u => u.pub_key),
...stmts.peersAll.all().map(p => p.pub_key),
]);
const now = Math.floor(Date.now() / 1000);
const ghosts = r.stdout.toString().split("\n").slice(1).map(l => l.split("\t")).filter(f => f[0] && !known.has(f[0])).map(f => {
const lastHandshake = Number(f[4]) || 0;
return { pub_key: f[0], lastHandshake, online: lastHandshake > 0 && now - lastHandshake < 180, rx: Number(f[5]) || 0, tx: Number(f[6]) || 0 };
});
res.json({ ghosts });
}));
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется: пир, который
// успел появиться в users/peers, не трогаем. Строк в базе у «призраков» нет — снимается только ядро.
app.post("/api/audit/clean", auth, handler((req, res) => {
const { pub_keys } = (req.body ?? {}) as { pub_keys?: unknown };
if (!Array.isArray(pub_keys) || pub_keys.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
const removed: string[] = [], skipped: string[] = [];
for (const k of pub_keys.map(checkPubKey)) {
const known = stmts.peerGet.get(k) || (db.prepare("SELECT 1 FROM users WHERE pub_key = ?").get(k));
if (known || k === getServerPublicKey() || !kernelPeers()?.has(k)) { skipped.push(k); continue; }
(dropFromKernel(k) ? removed : skipped).push(k);
}
if (removed.length) logger.warn("ghost peers removed from kernel", { count: removed.length, removed });
res.json({ removed, skipped });
}));
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ. // Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
app.put(SYNC_PATH, auth, handler((req, res) => { app.put(SYNC_PATH, auth, handler((req, res) => {
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown }; const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
@@ -1175,6 +1252,148 @@ app.post("/awg/upgrade", auth, handler((_req, res) => {
} }
})); }));
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не пропустит
// (и без PATCH: агент отвечает на него 400).
app.get("/api/shaping", auth, handler(async (_req, res) => {
res.json(await shaping.status());
}));
app.put("/api/shaping", auth, handler(async (req, res) => {
const b = (req.body ?? {}) as Record<string, unknown>;
const mbit = (v: unknown, what: string): number => {
const n = Number(v);
if (!Number.isInteger(n) || n < 0 || n > 100_000) throw new HttpError(400, `${what}: целое число Мбит/с от 1 до 100000`);
return n;
};
if (typeof b.enabled !== "boolean" || typeof b.auto !== "boolean")
throw new HttpError(400, "enabled и auto должны быть true/false");
const downMbit = mbit(b.downMbit ?? 0, "Загрузка");
const upMbit = mbit(b.upMbit ?? 0, "Отдача");
if (b.enabled && (downMbit < 1 || upMbit < 1))
throw new HttpError(400, "Для включения укажите ширину канала в обе стороны");
try {
await shaping.update({ enabled: b.enabled, auto: b.auto, downMbit, upMbit });
} catch (e) {
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
throw new HttpError(500, e instanceof Error ? e.message : "Не удалось применить шейпинг");
}
res.json(await shaping.status());
}));
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const METRICS_TTL_MS = 5_000;
let metricsCache: { at: number; data: Promise<unknown> } | null = null;
function linkCounter(name: string): number {
try { return Number(readFileSync(`/sys/class/net/${CONFIG.interface}/statistics/${name}`, "utf8")) || 0; }
catch { return 0; }
}
// Нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у node_exporter): долю считает
// Prometheus через rate(), здесь мгновенного «процента» нет. Память — MemAvailable, а не MemFree: кэш ядра свободен.
const CPU_MODES = ["user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"] as const;
function readHost() {
try {
const first = readFileSync("/proc/stat", "utf8").split("\n")[0].trim().split(/\s+/).slice(1).map(Number);
const cpu: Record<string, number> = {};
CPU_MODES.forEach((m, i) => { cpu[m] = (first[i] || 0) / 100; }); // USER_HZ = 100 на всех обычных ядрах
const mem = readFileSync("/proc/meminfo", "utf8");
const kb = (k: string) => Number(mem.match(new RegExp(`^${k}:\\s+(\\d+) kB`, "m"))?.[1] ?? 0) * 1024;
return {
cpu, cores: os.cpus().length, load: os.loadavg(),
mem: { total: kb("MemTotal"), available: kb("MemAvailable") },
};
} catch { return null; }
}
async function collectMetrics() {
const st = getInterfaceStatus();
const stats = st.up ? getPeersData() : {};
const list: { kind: "user" | "device"; name: string; ip: string; online: boolean; rx: number; tx: number; priority: string }[] = [
...(db.prepare("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''").all() as UserRow[]).map(u => ({
kind: "user" as const, name: u.name, ip: u.ip,
online: stats[u.pub_key]?.online ?? false, rx: stats[u.pub_key]?.rx ?? 0, tx: stats[u.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(u.ip),
})),
...stmts.peersAll.all().map(p => ({
kind: "device" as const, name: "", ip: p.ip,
online: stats[p.pub_key]?.online ?? false, rx: stats[p.pub_key]?.rx ?? 0, tx: stats[p.pub_key]?.tx ?? 0,
priority: shaping.priorityOf(p.ip),
})),
];
const all = Object.values(stats);
return {
up: st.up,
gen: CONFIG.gen,
peers: { total: st.peers, online: all.filter(p => p.online).length },
// С точки зрения сервера: rx — принято от клиентов (их отдача), tx — отправлено клиентам (их загрузка).
rx_bytes: all.reduce((a, p) => a + p.rx, 0),
tx_bytes: all.reduce((a, p) => a + p.tx, 0),
link: {
rx_bytes: linkCounter("rx_bytes"), tx_bytes: linkCounter("tx_bytes"),
rx_packets: linkCounter("rx_packets"), tx_packets: linkCounter("tx_packets"),
rx_dropped: linkCounter("rx_dropped"), tx_dropped: linkCounter("tx_dropped"),
rx_errors: linkCounter("rx_errors"), tx_errors: linkCounter("tx_errors"),
},
shaping: await shaping.status(),
host: readHost(),
list,
};
}
app.get("/api/metrics", auth, handler(async (_req, res) => {
if (!metricsCache || Date.now() - metricsCache.at > METRICS_TTL_MS) {
const data = collectMetrics();
metricsCache = { at: Date.now(), data };
data.catch(() => { metricsCache = null; }); // ошибку не кэшируем
}
res.json(await metricsCache.data);
}));
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие одно из двух и без
// параметров: awg-ctrl запускает ту же команду, что оператор набрал бы руками (`awg-ctrl monitor install|remove`,
// root), так что код один. Панель сама пакетов не ставит и не удаляет — она только просит об этом здесь.
const MONITOR_CLI = process.env.AWG_CLI ?? "/usr/local/bin/awg-ctrl";
const MONITOR_LOG = process.env.MONITOR_LOG ?? path.resolve(process.cwd(), "..", "logs", "monitor-job.log");
type MonitorAction = "install" | "remove";
let monitorJob: { action: MonitorAction; startedAt: number; exit: number | null } | null = null;
const logTail = (): string => {
try {
const t = readFileSync(MONITOR_LOG, "utf8").slice(-4000);
// apt рисует прогресс возвратом каретки и цветами — в панели это мусор
return t.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\r/g, "\n");
} catch { return ""; }
};
function startMonitorJob(action: MonitorAction, res: Response) {
if (monitorJob && monitorJob.exit === null) { res.status(409).json({ error: "Операция уже идёт" }); return; }
if (!existsSync(MONITOR_CLI)) throw new HttpError(501, `CLI не найден (${MONITOR_CLI}): выполните на сервере awg-ctrl monitor ${action}`);
mkdirSync(path.dirname(MONITOR_LOG), { recursive: true });
const fd = openSync(MONITOR_LOG, "w");
const job = { action, startedAt: Date.now(), exit: null as number | null };
monitorJob = job;
const done = (code: number) => { job.exit = code; try { closeSync(fd); } catch { /* уже закрыт */ } };
const child = spawn(MONITOR_CLI, ["monitor", action], { stdio: ["ignore", fd, fd] });
child.on("error", e => { logger.error("monitor: не запустился", { action, error: e.message }); done(127); });
child.on("exit", code => { logger.info("monitor: завершён", { action, code }); done(code ?? 1); });
res.status(202).json({ running: true, action });
}
app.post("/api/monitor/install", auth, handler((_req, res) => { startMonitorJob("install", res); }));
app.post("/api/monitor/remove", auth, handler((_req, res) => { startMonitorJob("remove", res); }));
app.get("/api/monitor/job", auth, handler((_req, res) => {
res.json({
action: monitorJob?.action ?? null,
running: !!monitorJob && monitorJob.exit === null,
exit: monitorJob?.exit ?? null,
log: monitorJob ? logTail() : "",
});
}));
app.get("/awg/status", auth, handler((_req, res) => { app.get("/awg/status", auth, handler((_req, res) => {
res.json(getInterfaceStatus()); res.json(getInterfaceStatus());
})); }));
@@ -1188,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => {
}); });
ensureInterfaceUp(); ensureInterfaceUp();
syncPeers(); syncPeers();
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
void shaping.apply();
shaping.start();
+338
View File
@@ -0,0 +1,338 @@
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому apply() зовётся
// на старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по
// времени, ни по неактивности (правило проекта: удаление только по явному списку
// от панели).
import { execFile, spawn } from "child_process";
import type { Logger } from "winston";
export interface ShapingSettings {
enabled: boolean;
downMbit: number;
upMbit: number;
auto: boolean;
}
export interface PeerCounters {
ip: string;
rx: number; // принято от клиента (его отдача)
tx: number; // отправлено клиенту (его загрузка)
}
export interface ShapingDeps {
iface: string;
getCfg: (key: string, fallback: string) => string;
setCfg: (key: string, value: string) => void;
logger: Pick<Logger, "info" | "warn" | "error">;
// Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет).
overhead: () => number;
peers: () => Promise<PeerCounters[]>;
}
export interface QdiscStats { packets: number; bytes: number; drops: number; backlog: number }
export interface ShapingStatus {
settings: ShapingSettings;
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств в bulk-классе
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
rateUp: number;
down?: QdiscStats;
up?: QdiscStats;
}
// Окна и пороги. Переопределение через env — только для тестов на стенде.
const num = (v: string | undefined, d: number) => (v && Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
const SAMPLE_S = num(process.env.SHAPE_SAMPLE_S, 10);
const WINDOW_S = num(process.env.SHAPE_WINDOW_S, 600);
const CONGEST_ON = 0.85; // доля ширины, выше которой канал считается перегруженным
const CONGEST_OFF = 0.70; // и ниже которой перегрузка снимается (гистерезис)
const ACTIVE_MBIT = 0.1; // меньше — устройство не считается активным
const HEAVY_MULT = 1.5; // тяжёлое: ≥ 1.5 честных долей…
const HEAVY_MIN_FRAC = 0.05; // …и не меньше 5% ширины канала
const CHECK_EVERY = 3; // страховочная проверка qdisc — раз в N тиков
const IFB = "ifb-awg";
const NFT_TABLE = "awg_shape";
function exec(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { encoding: "utf8", timeout: 15_000 }, (err, stdout, stderr) => {
if (err) reject(new Error(`${cmd} ${args.join(" ")}: ${(stderr || err.message).toString().trim()}`));
else resolve(stdout);
});
});
}
function execStdin(cmd: string, args: string[], input: string): Promise<void> {
return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["pipe", "ignore", "pipe"] });
let err = "";
p.stderr.on("data", d => { err += d; });
p.stdin.on("error", () => undefined); // процесс умер раньше, чем дочитал stdin — причина придёт в close
p.on("error", reject);
p.on("close", code => code === 0 ? resolve() : reject(new Error(`${cmd}: ${err.trim() || `exit ${code}`}`)));
p.stdin.end(input);
});
}
interface Tick { dt: number; bytes: number }
export function createShaping(deps: ShapingDeps) {
const { iface, logger } = deps;
// ───────────── настройки ─────────────
function settings(): ShapingSettings {
return {
enabled: deps.getCfg("shapeEnabled", "0") === "1",
downMbit: Number(deps.getCfg("shapeDownMbit", "0")) || 0,
upMbit: Number(deps.getCfg("shapeUpMbit", "0")) || 0,
auto: deps.getCfg("shapeAuto", "1") === "1",
};
}
function save(s: ShapingSettings) {
deps.setCfg("shapeEnabled", s.enabled ? "1" : "0");
deps.setCfg("shapeDownMbit", String(s.downMbit));
deps.setCfg("shapeUpMbit", String(s.upMbit));
deps.setCfg("shapeAuto", s.auto ? "1" : "0");
}
// ───────────── применение ─────────────
// Все изменения tc/nft идут строго по одному: два параллельных apply() дали бы
// полусобранные qdisc.
let chain: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const r = chain.then(fn, fn);
chain = r.catch(() => undefined);
return r;
};
let lastError = "";
const noteError = (e: unknown) => {
const m = e instanceof Error ? e.message : String(e);
if (m !== lastError) logger.warn("shaping: " + m); // одинаковую ошибку в лог не спамим
lastError = m;
};
const cakeOverhead = () => {
const o = Number(process.env.SHAPE_OVERHEAD);
return Number.isFinite(o) && o >= 0 && process.env.SHAPE_OVERHEAD ? o : deps.overhead();
};
async function nftApply(heavy: string[]) {
const elements = heavy.length ? `\n elements = { ${heavy.join(", ")} }` : "";
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
await execStdin("nft", ["-f", "-"], `
add table inet ${NFT_TABLE}
delete table inet ${NFT_TABLE}
table inet ${NFT_TABLE} {
set heavy {
type ipv4_addr${elements}
}
chain postrouting {
type filter hook postrouting priority -150; policy accept;
oifname "${iface}" ip daddr @heavy ip dscp set cs1
}
}
`);
}
async function hasCake(dev: string): Promise<boolean> {
try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); }
catch { return false; }
}
async function hasIngressRedirect(): Promise<boolean> {
try { return /mirred/.test(await exec("tc", ["filter", "show", "dev", iface, "parent", "ffff:"])); }
catch { return false; }
}
async function applyNow() {
const s = settings();
if (!s.enabled || s.downMbit <= 0 || s.upMbit <= 0) { await removeNow(); return; }
const oh = cakeOverhead();
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
await exec("tc", ["qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", `${s.downMbit}mbit`, "diffserv3", "dual-dsthost", "overhead", String(oh)]);
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
try { await exec("ip", ["link", "show", IFB]); }
catch { await exec("ip", ["link", "add", IFB, "type", "ifb"]); }
await exec("ip", ["link", "set", IFB, "up"]);
await exec("tc", ["qdisc", "replace", "dev", IFB, "root", "cake",
"bandwidth", `${s.upMbit}mbit`, "besteffort", "dual-srchost", "overhead", String(oh)]);
if (!(await hasIngressRedirect())) {
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("tc", ["qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"]);
await exec("tc", ["filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", IFB]);
}
await nftApply([...heavySet]);
lastError = "";
logger.info("shaping applied", { down: s.downMbit, up: s.upMbit, overhead: oh, heavy: heavySet.size });
}
async function removeNow() {
await exec("tc", ["qdisc", "del", "dev", iface, "root"]).catch(() => undefined);
await exec("tc", ["qdisc", "del", "dev", iface, "ingress"]).catch(() => undefined);
await exec("ip", ["link", "del", IFB]).catch(() => undefined);
await exec("nft", ["delete", "table", "inet", NFT_TABLE]).catch(() => undefined);
heavySet.clear(); history.clear(); totals.length = 0; congested = false;
}
// Не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от ensureInterfaceUp).
function apply(): Promise<void> {
return serial(applyNow).catch(noteError);
}
async function update(s: ShapingSettings): Promise<void> {
save(s);
await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn
lastError = "";
}
// ───────────── сэмплер (RFC 6057) ─────────────
const prev = new Map<string, { rx: number; tx: number }>();
const history = new Map<string, Tick[]>(); // загрузка по IP
const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам)
const heavySet = new Set<string>();
let congested = false;
let lastRateDown = 0, lastRateUp = 0;
let lastTickAt = 0;
let tickNo = 0;
const windowTicks = Math.max(3, Math.round(WINDOW_S / SAMPLE_S));
const push = (arr: Tick[], t: Tick) => { arr.push(t); while (arr.length > windowTicks) arr.shift(); };
const mbit = (arr: Tick[]) => {
const dt = arr.reduce((a, t) => a + t.dt, 0);
return dt > 0 ? arr.reduce((a, t) => a + t.bytes, 0) * 8 / dt / 1e6 : 0;
};
async function tick() {
const s = settings();
if (!s.enabled) return;
const now = Date.now();
const dt = lastTickAt ? (now - lastTickAt) / 1000 : SAMPLE_S;
lastTickAt = now;
tickNo++;
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if (tickNo % CHECK_EVERY === 0 && !(await hasCake(iface))) {
logger.warn("shaping: qdisc на интерфейсе пропал, применяю заново");
await apply();
}
let peers: PeerCounters[];
try { peers = await deps.peers(); } catch (e) { noteError(e); return; }
let totalDown = 0, totalUp = 0;
const seen = new Set<string>();
for (const p of peers) {
seen.add(p.ip);
const before = prev.get(p.ip);
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира → отрицательная дельта = 0.
const dTx = before && p.tx >= before.tx ? p.tx - before.tx : 0;
const dRx = before && p.rx >= before.rx ? p.rx - before.rx : 0;
prev.set(p.ip, { rx: p.rx, tx: p.tx });
totalDown += dTx; totalUp += dRx;
let h = history.get(p.ip);
if (!h) history.set(p.ip, h = []);
push(h, { dt, bytes: dTx });
}
for (const ip of [...prev.keys()]) if (!seen.has(ip)) { prev.delete(ip); history.delete(ip); heavySet.delete(ip); }
push(totals, { dt, bytes: totalDown });
lastRateDown = totalDown * 8 / dt / 1e6;
lastRateUp = totalUp * 8 / dt / 1e6;
if (!s.auto) {
if (heavySet.size || congested) { heavySet.clear(); congested = false; await serial(() => nftApply([])).catch(noteError); }
return;
}
if (totals.length < windowTicks) return; // пока окно не набралось, решений не принимаем
const avg = mbit(totals);
const wasCongested = congested;
if (!congested && avg >= CONGEST_ON * s.downMbit) congested = true;
if ( congested && avg < CONGEST_OFF * s.downMbit) congested = false;
const nextHeavy = new Set<string>();
let active = 0;
if (congested) {
const rates = [...history.entries()].filter(([, h]) => h.length >= windowTicks).map(([ip, h]) => [ip, mbit(h)] as const);
active = rates.filter(([, r]) => r >= ACTIVE_MBIT).length;
const share = s.downMbit / Math.max(1, active);
for (const [ip, r] of rates) {
const wasHeavy = heavySet.has(ip);
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if (r >= Math.max(HEAVY_MULT * share, HEAVY_MIN_FRAC * s.downMbit) || (wasHeavy && r >= share)) nextHeavy.add(ip);
}
}
const changed = nextHeavy.size !== heavySet.size || [...nextHeavy].some(ip => !heavySet.has(ip));
if (changed || wasCongested !== congested) {
heavySet.clear(); nextHeavy.forEach(ip => heavySet.add(ip));
logger.info("shaping: класс тяжёлых обновлён", { congested, avgMbit: Math.round(avg), heavy: [...heavySet] });
await serial(() => nftApply([...heavySet])).catch(noteError);
}
}
let timer: NodeJS.Timeout | null = null;
function start() {
if (timer) return;
let busy = false;
timer = setInterval(() => {
if (busy) return; // предыдущий тик ещё идёт — пропускаем, а не копим очередь
busy = true;
tick().catch(noteError).finally(() => { busy = false; });
}, SAMPLE_S * 1000);
timer.unref();
}
// ───────────── состояние ─────────────
async function qdiscStats(dev: string): Promise<QdiscStats | undefined> {
try {
const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record<string, unknown>[];
const q = arr.find(x => x.kind === "cake");
if (!q) return undefined;
return {
packets: Number(q.packets) || 0, bytes: Number(q.bytes) || 0,
drops: Number(q.drops) || 0, backlog: Number(q.backlog) || 0,
};
} catch { return undefined; }
}
async function status(): Promise<ShapingStatus> {
const s = settings();
const [active, down, up] = s.enabled
? await Promise.all([hasCake(iface), qdiscStats(iface), qdiscStats(IFB)])
: [false, undefined, undefined];
return {
settings: s, active, congested, heavy: [...heavySet],
devices: [...history.values()].filter(h => mbit(h) >= ACTIVE_MBIT).length,
rateDown: Math.round(lastRateDown * 10) / 10,
rateUp: Math.round(lastRateUp * 10) / 10,
down, up,
};
}
// Текущий приоритет устройства по его внутреннему IP: «фоновый» — пока он в bulk-классе.
const priorityOf = (ip: string): "normal" | "background" => (heavySet.has(ip) ? "background" : "normal");
return { settings, update, apply, start, status, priorityOf };
}
export type Shaping = ReturnType<typeof createShaping>;
+235
View File
@@ -0,0 +1,235 @@
// 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.
//
// Prometheus: GET /metrics на ядре. Метрики ядра и всех нод собираются здесь же —
// у нод через уже открытый WSS (awg-agent пропускает только /api/*, поэтому на самих
// нодах отдельного порта для скрейпа не нужно). Доступ — отдельный токен «только
// чтение метрик»; он не открывает ни /api/v1, ни панель.
import crypto from "crypto";
import fs from "fs";
import { Router, type Request, type Response, type NextFunction } from "express";
import type Database from "better-sqlite3";
interface ServerInfo {
id: number; name: string; local: boolean; online: boolean; pending: boolean;
version: string | null;
health: { gen: string; module: string; tools: string } | null;
}
interface MetricsDeps {
uidb: Database.Database;
version: string;
servers: {
list: () => ServerInfo[];
isOnline: (id: number) => boolean;
ctrlFor: (id: number) => (method: string, urlPath: string) => Promise<{ status: number; data: any }>;
};
}
const SCRAPE_DEADLINE_MS = 5_000;
const TOUCH_EVERY_S = 60; // last_used пишем не чаще раза в минуту, а не на каждый скрейп
const hashToken = (t: string) => crypto.createHash("sha256").update(t).digest();
// Токен встроенного Prometheus (`awg-ctrl monitor install`): сам токен лежит только у Prometheus,
// здесь — его хэш. Файл читаем при каждой проверке, пересчитывая по mtime: установка не требует перезапуска панели.
const LOCAL_HASH_FILE = process.env.MONITOR_HASH_FILE ?? "/etc/amnezia/amneziawg/monitor_token.sha256";
let localHash: { mtime: number; hash: Buffer | null } = { mtime: -1, hash: null };
function localTokenHash(): Buffer | null {
try {
const mtime = fs.statSync(LOCAL_HASH_FILE).mtimeMs;
if (mtime !== localHash.mtime) {
const hex = fs.readFileSync(LOCAL_HASH_FILE, "utf8").trim();
localHash = { mtime, hash: /^[0-9a-f]{64}$/.test(hex) ? Buffer.from(hex, "hex") : null };
}
return localHash.hash;
} catch { localHash = { mtime: -1, hash: null }; return null; }
}
// Пер-пировые серии (метка peer = внутренний IP, name = имя vpn://-пользователя) выключены:
// это и приватность, и кардинальность. Включается явно.
const PER_PEER = (process.env.METRICS_PEERS ?? "off").toLowerCase() === "on";
type Labels = Record<string, string | number>;
type Sample = [Labels, number];
const esc = (v: string | number) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
const fmtLabels = (l: Labels) => {
const parts = Object.entries(l).map(([k, v]) => `${k}="${esc(v)}"`);
return parts.length ? `{${parts.join(",")}}` : "";
};
class Exposition {
private lines: string[] = [];
private seen = new Set<string>();
add(name: string, type: "gauge" | "counter", help: string, samples: Sample[]) {
if (!samples.length) return;
if (!this.seen.has(name)) {
this.seen.add(name);
this.lines.push(`# HELP ${name} ${help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")}`, `# TYPE ${name} ${type}`);
}
for (const [l, v] of samples) this.lines.push(`${name}${fmtLabels(l)} ${Number.isFinite(v) ? v : 0}`);
}
toString() { return this.lines.join("\n") + "\n"; }
}
export function createMetrics(deps: MetricsDeps) {
const { uidb } = deps;
uidb.exec(`
CREATE TABLE IF NOT EXISTS metrics_token (
id INTEGER PRIMARY KEY CHECK (id = 1),
token_hash BLOB NOT NULL,
prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used INTEGER
)
`);
const q = {
get: uidb.prepare("SELECT token_hash, prefix, created_at, last_used FROM metrics_token WHERE id = 1"),
set: uidb.prepare("INSERT OR REPLACE INTO metrics_token (id, token_hash, prefix, created_at, last_used) VALUES (1, ?, ?, ?, NULL)"),
del: uidb.prepare("DELETE FROM metrics_token WHERE id = 1"),
touch: uidb.prepare("UPDATE metrics_token SET last_used = ? WHERE id = 1"),
};
type TokenRow = { token_hash: Buffer; prefix: string; created_at: number; last_used: number | null };
// ── Управление токеном (роуты панели, за JWT) ──
const router = Router();
const view = () => {
const r = q.get.get() as TokenRow | undefined;
return r ? { exists: true, prefix: r.prefix, created_at: r.created_at, last_used: r.last_used, per_peer: PER_PEER }
: { exists: false, per_peer: PER_PEER };
};
router.get("/", (_req: Request, res: Response) => { res.json(view()); });
// Выпуск и перевыпуск — одно и то же: старый токен перестаёт работать. Значение отдаётся один раз.
router.post("/", (_req: Request, res: Response) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
q.set.run(hashToken(token), token.slice(0, 13) + "…", Math.floor(Date.now() / 1000));
res.status(201).json({ ...view(), token });
});
router.delete("/", (_req: Request, res: Response) => { q.del.run(); res.json(view()); });
// ── Доступ к /metrics ──
// Нет токена или он неверный — одинаковый 401: не подсказываем, выключены метрики или токен не тот.
function requireToken(req: Request, res: Response, next: NextFunction) {
const h = req.headers.authorization ?? "";
const given = h.startsWith("Bearer ") ? h.slice(7) : "";
const row = q.get.get() as TokenRow | undefined;
const gh = given.startsWith("awgm_") ? hashToken(given) : null;
const panelOk = !!row && !!gh && crypto.timingSafeEqual(gh, row.token_hash);
const local = localTokenHash();
const localOk = !!gh && !!local && crypto.timingSafeEqual(gh, local);
if (!panelOk && !localOk) { res.status(401).set("WWW-Authenticate", "Bearer").type("text/plain").send("unauthorized\n"); return; }
// last_used — про токен, выпущенный в панели (внешний Prometheus); встроенный в нём не участвует.
const now = Math.floor(Date.now() / 1000);
if (panelOk && row && (!row.last_used || now - row.last_used >= TOUCH_EVERY_S)) q.touch.run(now);
next();
}
// Один сервер за скрейп: сам сервер не отвечает — остальные не страдают.
async function scrapeServer(s: ServerInfo): Promise<any | null> {
if (s.pending || !deps.servers.isOnline(s.id)) return null;
let timer: NodeJS.Timeout | undefined;
try {
const r = await Promise.race([
deps.servers.ctrlFor(s.id)("GET", "/api/metrics"),
new Promise<never>((_, rej) => { timer = setTimeout(() => rej(new Error("deadline")), SCRAPE_DEADLINE_MS); }),
]);
return r.status === 200 ? r.data : null;
} catch { return null; }
finally { clearTimeout(timer); }
}
async function render(): Promise<string> {
const t0 = process.hrtime.bigint();
const list = deps.servers.list();
const data = await Promise.all(list.map(scrapeServer));
const out = new Exposition();
const L = (s: ServerInfo, extra: Labels = {}): Labels => ({ server: s.name, server_id: s.id, ...extra });
// Серии только тех серверов, чьи метрики получены; fn всегда отдаёт массив серий.
const per = (fn: (s: ServerInfo, d: any) => Sample[]): Sample[] =>
list.flatMap((s, i) => (data[i] ? fn(s, data[i]) : []));
out.add("awg_panel_info", "gauge", "Версия панели Forgetting.", [[{ version: deps.version }, 1]]);
out.add("awg_servers", "gauge", "Серверов в панели (ядро и ноды).", [[{}, list.length]]);
out.add("awg_server_online", "gauge", "Сервер на связи с панелью (1/0).", list.map(s => [L(s), s.online ? 1 : 0] as Sample));
out.add("awg_scrape_ok", "gauge", "Метрики сервера получены в этом скрейпе (1/0).", list.map((s, i) => [L(s), data[i] ? 1 : 0] as Sample));
out.add("awg_node_info", "gauge", "Версия Forgetting и поколение AWG на сервере.",
list.filter(s => !s.pending).map(s => [L(s, { version: s.version ?? "", gen: s.health?.gen ?? "", module: s.health?.module ?? "", tools: s.health?.tools ?? "" }), 1] as Sample));
out.add("awg_up", "gauge", "Интерфейс AWG поднят (1/0).", per((s, d) => [[L(s), d.up ? 1 : 0] as Sample]));
out.add("awg_peers", "gauge", "Пиров в интерфейсе.", per((s, d) => [[L(s), d.peers?.total ?? 0] as Sample]));
out.add("awg_peers_online", "gauge", "Пиров с рукопожатием за последние 3 минуты.", per((s, d) => [[L(s), d.peers?.online ?? 0] as Sample]));
// Сумма по пирам — gauge, а не counter: при удалении пира или смене его ключа сумма уменьшается,
// и rate() принял бы это за сброс счётчика с ложным всплеском. Для скорости берите awg_link_*_bytes_total.
out.add("awg_peers_received_bytes", "gauge", "Принято от клиентов (их отдача), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.rx_bytes ?? 0] as Sample]));
out.add("awg_peers_sent_bytes", "gauge", "Отправлено клиентам (их загрузка), байт, сумма по пирам. Уменьшается при удалении пира.", per((s, d) => [[L(s), d.tx_bytes ?? 0] as Sample]));
for (const [k, name, help] of [
["rx_bytes", "receive_bytes", "байт"], ["tx_bytes", "transmit_bytes", "байт"],
["rx_packets", "receive_packets", "пакетов"], ["tx_packets", "transmit_packets", "пакетов"],
["rx_dropped", "receive_dropped", "потерянных пакетов"], ["tx_dropped", "transmit_dropped", "потерянных пакетов"],
["rx_errors", "receive_errors", "ошибок"], ["tx_errors", "transmit_errors", "ошибок"],
] as const) {
out.add(`awg_link_${name}_total`, "counter", `Интерфейс awg1: ${help} (${k}).`, per((s, d) => [[L(s), d.link?.[k] ?? 0] as Sample]));
}
// Нагрузка хоста (ядро и ноды). CPU — счётчики секунд по режимам: доля = rate() в Prometheus.
out.add("awg_host_cpu_seconds_total", "counter", "Время процессора по режимам, секунд (как node_exporter).",
per((s, d) => Object.entries(d.host?.cpu ?? {}).map(([mode, v]) => [L(s, { mode }), Number(v)] as Sample)));
out.add("awg_host_cpu_cores", "gauge", "Ядер процессора.", per((s, d) => d.host ? [[L(s), d.host.cores ?? 0] as Sample] : []));
out.add("awg_host_memory_total_bytes", "gauge", "Оперативной памяти всего, байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.total ?? 0] as Sample] : []));
out.add("awg_host_memory_available_bytes", "gauge", "Доступной памяти (MemAvailable), байт.", per((s, d) => d.host ? [[L(s), d.host.mem?.available ?? 0] as Sample] : []));
out.add("awg_host_load", "gauge", "Средняя загрузка системы (load average).",
per((s, d) => d.host ? (["1m", "5m", "15m"] as const).map((p, i) => [L(s, { period: p }), d.host.load?.[i] ?? 0] as Sample) : []));
// Деление канала (CAKE)
const sh = (fn: (d: any) => number) => per((s, d) => d.shaping ? [[L(s), fn(d.shaping)] as Sample] : []);
out.add("awg_shaping_enabled", "gauge", "Деление канала включено в настройках (1/0).", sh(x => x.settings?.enabled ? 1 : 0));
out.add("awg_shaping_active", "gauge", "CAKE реально стоит на интерфейсе (1/0).", sh(x => x.active ? 1 : 0));
out.add("awg_shaping_congested", "gauge", "Канал считается перегруженным (1/0).", sh(x => x.congested ? 1 : 0));
out.add("awg_shaping_heavy_devices", "gauge", "Устройств в фоновом классе.", sh(x => x.heavy?.length ?? 0));
out.add("awg_shaping_active_devices", "gauge", "Активных устройств в окне наблюдения.", sh(x => x.devices ?? 0));
const dirs = (fn: (x: any, dir: "down" | "up") => number | undefined) =>
per((s, d) => d.shaping ? (["down", "up"] as const).flatMap(dir => {
const v = fn(d.shaping, dir);
return v === undefined ? [] : [[L(s, { direction: dir }), v] as Sample];
}) : []);
out.add("awg_shaping_bandwidth_bits", "gauge", "Заданная ширина канала, бит/с.", dirs((x, dir) => (dir === "down" ? x.settings?.downMbit : x.settings?.upMbit) * 1e6));
out.add("awg_shaping_rate_bits", "gauge", "Скорость за последний тик сэмплера, бит/с.", dirs((x, dir) => (dir === "down" ? x.rateDown : x.rateUp) * 1e6));
out.add("awg_shaping_qdisc_drops_total", "counter", "Пакетов, отброшенных CAKE.", dirs((x, dir) => x[dir]?.drops));
out.add("awg_shaping_qdisc_backlog_bytes", "gauge", "Байт в очереди CAKE.", dirs((x, dir) => x[dir]?.backlog));
if (PER_PEER) {
const peers = (fn: (p: any) => number) => per((s, d) => (d.list ?? []).map((p: any) =>
[L(s, { peer: p.ip, name: p.name ?? "", kind: p.kind }), fn(p)] as Sample));
out.add("awg_peer_online", "gauge", "Пир на связи (1/0).", peers(p => p.online ? 1 : 0));
out.add("awg_peer_background", "gauge", "Пир в фоновом классе: канал занят, а он давно качает больше своей доли (1/0).", peers(p => p.priority === "background" ? 1 : 0));
out.add("awg_peer_received_bytes_total", "counter", "Принято от пира, байт.", peers(p => p.rx));
out.add("awg_peer_sent_bytes_total", "counter", "Отправлено пиру, байт.", peers(p => p.tx));
}
let masters = 0, devices = 0;
// Таблицы создаёт sub: готовим запросы здесь, а не при создании модуля, — порядок
// инициализации в server.ts не должен решать, упадёт ли панель на старте.
try {
masters = (uidb.prepare("SELECT COUNT(*) AS n FROM master_keys WHERE revoked_at IS NULL").get() as { n: number }).n;
devices = (uidb.prepare("SELECT COUNT(*) AS n FROM devices").get() as { n: number }).n;
} catch { /* sub ещё не создал таблицы */ }
out.add("awg_master_keys", "gauge", "Мастер-ключей sen:// (без удаляемых).", [[{}, masters]]);
out.add("awg_devices", "gauge", "Устройств sen://.", [[{}, devices]]);
out.add("awg_scrape_duration_seconds", "gauge", "Сколько панель собирала метрики.", [[{}, Number(process.hrtime.bigint() - t0) / 1e9]]);
return out.toString();
}
async function handler(_req: Request, res: Response) {
try {
res.type("text/plain; version=0.0.4; charset=utf-8").send(await render());
} catch (e) {
console.warn(`metrics: ${(e as Error).message}`);
res.status(500).type("text/plain").send("error\n");
}
}
return { router, requireToken, handler };
}
+98 -2
View File
@@ -57,7 +57,7 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь
const signPub = crypto.randomBytes(32); const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) }); const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась). // Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); const devOut = (m: MK, d: Dev) => ({ ...d, priority: shapingOf[0]?.enabled && d.id === masters[0]?.devs[0]?.id ? "background" : "normal", servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION })); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: MOCK_VERSION }));
@@ -95,8 +95,91 @@ app.delete("/ui/nodes/:id", (q, r) => {
app.post("/awg/restart", (_q, r) => r.json({ up: true })); app.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length })); app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
// Монитор: «установка» и «удаление» Prometheus с ходом процесса (как лог CLI на реальном сервере).
const MON_LOGS = {
install: ["1/4 установка пакета prometheus", "Reading package lists…", "Setting up prometheus (2.45.0)…", "2/4 токен и конфиг", "3/4 слушаем только 127.0.0.1:9090, хранение 15d", "4/4 запуск", "✓ Prometheus работает на 127.0.0.1:9090, метрики панели скрейпятся каждые 15 с."],
remove: ["1/3 остановка сервиса", "2/3 токен", "3/3 удаление пакета и накопленных данных", "Removing prometheus (2.45.0)…", "Purging configuration files…", "✓ Prometheus удалён вместе с накопленными графиками."],
};
let monJob: { action: "install" | "remove"; start: number } | null = null;
let monReady = false;
const MON_MS = 6000;
const monRunning = () => !!monJob && Date.now() - monJob.start < MON_MS;
app.get("/ui/monitor", (_q, r) => {
if (monJob && !monRunning()) monReady = monJob.action === "install";
let job = null;
if (monJob) {
const lines = MON_LOGS[monJob.action];
const n = monRunning() ? Math.max(1, Math.floor(((Date.now() - monJob.start) / MON_MS) * lines.length)) : lines.length;
job = { action: monJob.action, running: monRunning(), exit: monRunning() ? null : 0, log: lines.slice(0, n).join("\n") };
}
r.json({ ready: monReady, can_install: true, command: "awg-ctrl monitor install", job });
});
for (const action of ["install", "remove"] as const) {
app.post(`/ui/monitor/${action}`, (_q, r) => { if (!monRunning()) monJob = { action, start: Date.now() }; r.status(202).json({ running: true, action }); });
}
app.get("/ui/monitor/overview", (q, r) => {
const R: Record<string, [number, number]> = { "1h": [3600, 15], "6h": [21600, 60], "24h": [86400, 300] };
const [sec, step] = R[String(q.query.range ?? "1h")] ?? R["1h"];
const to = Math.floor(Date.now() / step) * step, from = to - sec;
const pts = (f: (i: number, t: number) => number): [number, number][] => Array.from({ length: Math.floor(sec / step) + 1 }, (_, i) => [from + i * step, Math.max(0, f(i, from + i * step))]);
const wave = (t: number, per: number, ph = 0) => Math.sin((t / per) * 2 * Math.PI + ph);
const per = sec / 3;
const down = pts((_, t) => 110e6 + 70e6 * wave(t, per) + 25e6 * wave(t, per / 7) + rnd(12e6));
const up = pts((_, t) => 14e6 + 7e6 * wave(t, per, 1) + rnd(3e6));
const online = pts((_, t) => Math.round(9 + 3 * wave(t, per * 1.6) + (rnd(100) > 90 ? 1 : 0)));
const heavy = pts((_, t) => (wave(t, per) > 0.55 ? 1 : 0));
const drops = pts((_, t) => (wave(t, per) > 0.55 ? 3 + rnd(6) : rnd(10) > 8 ? 0.4 : 0));
const cpu = pts((_, t) => 38 + 22 * wave(t, per * 0.8, 2) + 12 * wave(t, per / 5) + rnd(6));
const mem = pts((_, t) => 54 + 6 * wave(t, per * 3) + rnd(2));
const scale = (a: [number, number][], k: number) => a.map(([t, v]) => [t, v * k] as [number, number]);
// Один сервер — тот, что выбран в сервер-баре (?server=<id>): ядро (0) или нода
const id = Number(q.query.server ?? 0);
const k = id === 0 ? 0.7 : 0.3;
const down1 = scale(down, k), up1 = scale(up, k);
if (id === 3) return r.json({ range: q.query.range ?? "1h", step, from, to, servers: [], total: { down: [], up: [], online: [], heavy: [], drops: [], cpu: [], mem: [] } });
r.json({ range: q.query.range ?? "1h", step, from, to,
servers: [{ id, name: id === 0 ? "VPN (mock)" : "Frankfurt", ok: true, down: down1.at(-1)![1], up: up1.at(-1)![1], online: id === 0 ? 7 : 3,
heavy: id === 0 ? heavy.at(-1)![1] : 0, congested: id === 0 && heavy.at(-1)![1] > 0,
cpu: id === 0 ? cpu.at(-1)![1] : cpu.at(-1)![1] * 0.5, mem: id === 0 ? mem.at(-1)![1] : 31, cores: id === 0 ? 4 : 2, mem_total: (id === 0 ? 4 : 2) * 2 ** 30 }],
total: { down: down1, up: up1, online: scale(online, k), heavy: id === 0 ? heavy : scale(heavy, 0), drops: id === 0 ? drops : scale(drops, 0),
cpu: id === 0 ? cpu : scale(cpu, 0.5), mem: id === 0 ? mem : scale(mem, 0.55) } });
});
// Prometheus: токен метрик и сам /metrics (без проверки токена — мок).
let promTok: { prefix: string; created_at: number; last_used: number | null } | null = null;
const promView = () => promTok ? { exists: true, ...promTok, per_peer: false } : { exists: false, per_peer: false };
app.get("/ui/metrics-token", (_q, r) => r.json(promView()));
app.post("/ui/metrics-token", (_q, r) => {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
promTok = { prefix: token.slice(0, 13) + "…", created_at: now(), last_used: null };
r.status(201).json({ ...promView(), token });
});
app.delete("/ui/metrics-token", (_q, r) => { promTok = null; r.json(promView()); });
app.get("/metrics", (_q, r) => r.type("text/plain").send('awg_panel_info{version="mock"} 1\nawg_servers 4\n'));
// Деление канала: состояние по серверам; usersOf[1] играет «старую ноду» без маршрута (404 → «обнови сервер»).
const shapingOf: Record<number, { enabled: boolean; downMbit: number; upMbit: number; auto: boolean }> = {};
const shapingView = (id: number) => {
const settings = shapingOf[id] ?? { enabled: false, downMbit: 0, upMbit: 0, auto: true };
const on = settings.enabled;
return {
settings, active: on, congested: on && id === 0, heavy: on && id === 0 ? ["10.9.0.2"] : [],
devices: on ? 7 : 0, rateDown: on ? Math.round(settings.downMbit * 0.9 * 10) / 10 : 0, rateUp: on ? 12.4 : 0,
};
};
app.get("/api/shaping", (q, r) => srv(q) === 1 ? r.status(404).json({ error: "Not found" }) : r.json(shapingView(srv(q))));
app.put("/api/shaping", (q, r) => {
const id = srv(q), b = q.body ?? {};
if (id === 1) return r.status(404).json({ error: "Not found" });
if (b.enabled && !(b.downMbit >= 1 && b.upMbit >= 1)) return r.status(400).json({ error: "Для включения укажите ширину канала в обе стороны" });
shapingOf[id] = { enabled: !!b.enabled, downMbit: Number(b.downMbit) || 0, upMbit: Number(b.upMbit) || 0, auto: !!b.auto };
r.json(shapingView(id));
});
app.get("/api/users", (q, r) => r.json({ users: U_(q) })); app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) })); // Приоритет в моке: пока на сервере 0 включено деление канала, первый пользователь «фоновый».
const prioOf = (q: express.Request, ip: string) => (srv(q) === 0 && shapingOf[0]?.enabled && ip === "10.9.0.2" ? "background" : "normal");
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => ({ ...u, priority: prioOf(q, u.ip) })) }));
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" })); app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => { app.post("/api/users", (q, r) => {
const users = U_(q); const users = U_(q);
@@ -119,6 +202,19 @@ app.patch("/ui/apikeys/:id", (q, r) => {
}); });
app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); }); app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); });
let mockAuditSettings = { clean: false, every_h: 1, max: 10 };
let mockCleaned: unknown[] = [];
let mockGhost = true;
const mockAudit = () => ({ enabled: true, settings: mockAuditSettings, cleaned: mockCleaned, report: { at: Math.floor(Date.now() / 1000) - 600, servers: [
{ server_id: 0, status: "ok", ghosts_checked: true, foreign: 0, orphans: [], ghosts: mockGhost ? [
{ pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", online: true, lastHandshake: Math.floor(Date.now() / 1000) - 30, seen_before: true } ] : [] } ] } });
app.get("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit", (_q, r) => r.json(mockAudit()));
app.post("/ui/masterkeys/audit/clean", (_q, r) => {
if (mockGhost) mockCleaned = [{ at: Math.floor(Date.now() / 1000), server_id: 0, pub_key: "Zm9yZ2V0dGluZy16b21iaWUta2V5LTAwMDAwMDAwMDA=", kind: "ghost", auto: false }, ...mockCleaned];
mockGhost = false; r.json(mockAudit());
});
app.put("/ui/masterkeys/audit/settings", (q, r) => { mockAuditSettings = { ...mockAuditSettings, ...q.body }; r.json(mockAudit()); });
app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) })); app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) }));
app.post("/ui/masterkeys", (q, r) => { app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3; const limit = q.body.device_limit ?? 3;
+165
View File
@@ -0,0 +1,165 @@
// 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.
//
// Вкладка «Монитор»: встроенный Prometheus и основные графики прямо в панели.
// GET /ui/monitor — готов ли Prometheus, можно ли поставить его кнопкой, ход установки
// POST /ui/monitor/install — попросить локальный awg-ctrl выполнить `awg-ctrl monitor install` (root)
// POST /ui/monitor/remove — то же для `awg-ctrl monitor remove` (удаление вместе с накопленными данными)
// GET /ui/monitor/overview — серии для графиков за 1 ч / 6 ч / 24 ч
// Панель сама пакетов не ставит и в Prometheus лезет только фиксированными запросами (не PromQL от клиента).
import axios from "axios";
import { Router, type Request, type Response } from "express";
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
const PROM_URL = process.env.PROM_URL ?? "http://127.0.0.1:9090";
// Шаг подбираем так, чтобы на графике было ~250–360 точек; окно rate — минимум два скрейпа (15 с).
const RANGES: Record<string, { seconds: number; step: number }> = {
"1h": { seconds: 3600, step: 15 },
"6h": { seconds: 21600, step: 60 },
"24h": { seconds: 86400, step: 300 },
};
type Point = [number, number];
type Series = Map<number, { name: string; points: Point[] }>; // server_id → ряд
// Запросы по серверам. Трафик берём со счётчиков интерфейса (монотонны), а не с сумм по пирам:
// сумма по пирам падает при удалении пира, и rate() рисовал бы ложный всплеск.
const QUERIES = {
down: 'sum by (server_id, server) (rate(awg_link_transmit_bytes_total[2m])) * 8',
up: 'sum by (server_id, server) (rate(awg_link_receive_bytes_total[2m])) * 8',
online: 'sum by (server_id, server) (awg_peers_online)',
heavy: 'sum by (server_id, server) (awg_shaping_heavy_devices)',
drops: 'sum by (server_id, server) (rate(awg_shaping_qdisc_drops_total[2m]))',
congested: 'max by (server_id, server) (awg_shaping_congested)',
scrape_ok: 'max by (server_id, server) (awg_scrape_ok)',
// Загрузка процессора: доля времени не в idle/iowait. Память: MemAvailable, а не MemFree (кэш ядра свободен).
cpu: '100 * (1 - sum by (server_id, server) (rate(awg_host_cpu_seconds_total{mode=~"idle|iowait"}[2m])) / sum by (server_id, server) (rate(awg_host_cpu_seconds_total[2m])))',
mem: '100 * (1 - sum by (server_id, server) (awg_host_memory_available_bytes) / sum by (server_id, server) (awg_host_memory_total_bytes))',
cores: 'max by (server_id, server) (awg_host_cpu_cores)',
mem_total: 'max by (server_id, server) (awg_host_memory_total_bytes)',
} as const;
type Key = keyof typeof QUERIES;
async function rangeQuery(query: string, start: number, end: number, step: number): Promise<Series> {
const r = await axios.get(`${PROM_URL}/api/v1/query_range`, {
params: { query, start, end, step }, timeout: 8000,
});
const out: Series = new Map();
for (const m of (r.data?.data?.result ?? []) as { metric: Record<string, string>; values: [number, string][] }[]) {
const id = Number(m.metric.server_id);
if (!Number.isInteger(id)) continue;
out.set(id, {
name: m.metric.server ?? String(id),
// NaN/Inf из деления на ноль отбрасываем: на графике это дыра, а не нулевая скорость
points: m.values.map(([t, v]) => [t, Number(v)] as Point).filter(p => Number.isFinite(p[1])),
});
}
return out;
}
// Сумма рядов по меткам времени (Prometheus выравнивает точки по шагу, метки совпадают).
function total(series: Series, how: "sum" | "max" = "sum"): Point[] {
const acc = new Map<number, number>();
for (const s of series.values()) for (const [t, v] of s.points)
acc.set(t, how === "sum" ? (acc.get(t) ?? 0) + v : Math.max(acc.get(t) ?? 0, v));
return [...acc.entries()].sort((a, b) => a[0] - b[0]);
}
const last = (s?: { points: Point[] }): number | null => s && s.points.length ? s.points[s.points.length - 1][1] : null;
export function createMonitor(deps: { ctrlLocal: Ctrl; localEnabled: boolean }) {
const router = Router();
const promReady = async (): Promise<boolean> => {
try { return (await axios.get(`${PROM_URL}/-/ready`, { timeout: 1500, validateStatus: () => true })).status === 200; }
catch { return false; }
};
// Установка возможна, только если рядом свой awg-ctrl (у панели без собственного VPN его нет —
// тогда остаётся команда в терминале) и он достаточно новый, чтобы знать маршрут.
type Job = { can: boolean; action: "install" | "remove" | null; running: boolean; exit: number | null; log: string };
async function jobState(): Promise<Job> {
const none: Job = { can: false, action: null, running: false, exit: null, log: "" };
if (!deps.localEnabled) return none;
try {
const r = await deps.ctrlLocal("GET", "/api/monitor/job");
if (r.status !== 200) return none;
return { can: true, action: r.data.action ?? null, running: !!r.data.running, exit: r.data.exit ?? null, log: String(r.data.log ?? "") };
} catch { return none; }
}
router.get("/", async (_req: Request, res: Response) => {
const [ready, job] = await Promise.all([promReady(), jobState()]);
res.json({
ready,
can_install: job.can,
// Последняя операция (установка или удаление) и её лог; null — ещё не запускали
job: job.can && job.action ? { action: job.action, running: job.running, exit: job.exit, log: job.log } : null,
command: "awg-ctrl monitor install",
});
});
// Установка и удаление — одно и то же: просим локальный awg-ctrl, он запускает CLI от root.
const ask = (action: "install" | "remove") => async (_req: Request, res: Response) => {
if (!deps.localEnabled) { res.status(409).json({ error: "У этой панели нет своего VPN-сервера: выполните команду на машине" }); return; }
try {
const r = await deps.ctrlLocal("POST", `/api/monitor/${action}`);
res.status(r.status === 404 ? 501 : r.status).json(
r.status === 404 ? { error: "awg-ctrl старой версии не умеет этого: обновите сервер или выполните команду вручную" } : r.data);
} catch { res.status(502).json({ error: "awg-ctrl недоступен" }); }
};
router.post("/install", ask("install"));
router.post("/remove", ask("remove"));
router.get("/overview", async (req: Request, res: Response) => {
const range = RANGES[String(req.query.range ?? "1h")];
if (!range) { res.status(400).json({ error: "range: 1h, 6h или 24h" }); return; }
const end = Math.floor(Date.now() / 1000);
const start = end - range.seconds;
try {
const keys = Object.keys(QUERIES) as Key[];
const got = await Promise.all(keys.map(k => rangeQuery(QUERIES[k], start, end, range.step)));
// Вкладка показывает один сервер — тот, что выбран в сервер-баре (?server=<id>).
const only = Number.isInteger(Number(req.query.server)) && req.query.server !== undefined ? Number(req.query.server) : null;
const s = Object.fromEntries(keys.map((k, i) => [k,
only === null ? got[i] : new Map([...got[i]].filter(([id]) => id === only))])) as Record<Key, Series>;
// Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна.
const ids = new Set<number>();
for (const k of keys) for (const id of s[k].keys()) ids.add(id);
const servers = [...ids].sort((a, b) => a - b).map(id => ({
id,
name: (s.scrape_ok.get(id) ?? s.down.get(id) ?? s.online.get(id))?.name ?? String(id),
ok: last(s.scrape_ok.get(id)) === 1,
down: last(s.down.get(id)),
up: last(s.up.get(id)),
online: last(s.online.get(id)),
heavy: last(s.heavy.get(id)),
congested: last(s.congested.get(id)) === 1,
cpu: last(s.cpu.get(id)),
mem: last(s.mem.get(id)),
cores: last(s.cores.get(id)),
mem_total: last(s.mem_total.get(id)),
}));
res.json({
range: req.query.range ?? "1h", step: range.step, from: start, to: end,
servers,
total: {
down: total(s.down), up: total(s.up), online: total(s.online),
heavy: total(s.heavy), drops: total(s.drops),
cpu: total(s.cpu, "max"), mem: total(s.mem, "max"),
},
});
} catch (e) {
// Prometheus стоит, но не ответил: график важнее ошибки, но причину оператору покажем
res.status(502).json({ error: axios.isAxiosError(e) ? "Prometheus не отвечает" : (e as Error).message });
}
});
return { router };
}
+10 -5
View File
@@ -305,9 +305,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
const router = Router(); const router = Router();
router.get("/", async (_req: Request, res: Response) => { // Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик.
if (LOCAL) await refreshHealth(0); const list = () => {
const nodes: unknown[] = []; const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = [];
if (LOCAL) { if (LOCAL) {
const h = health.get(0) ?? null; const h = health.get(0) ?? null;
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h }); nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
@@ -318,7 +318,12 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null, pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
}); });
} }
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes }); return nodes;
};
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes: list() });
}); });
router.post("/", (req: Request, res: Response) => { router.post("/", (req: Request, res: Response) => {
@@ -379,5 +384,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
res.json({ success: true, id }); res.json({ success: true, id });
}); });
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start }; return { router, ctrlFor, exists, isOnline, ids, list, onOnline, beforeDelete, afterDelete, defaultId, start };
} }
+11
View File
@@ -9,6 +9,8 @@ import axios from "axios";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { createSub } from "./sub"; import { createSub } from "./sub";
import { createNodes } from "./nodes"; import { createNodes } from "./nodes";
import { createMetrics } from "./metrics";
import { createMonitor } from "./monitor";
const app = express(); const app = express();
const PORT = Number(process.env.PORT) || 8080; const PORT = Number(process.env.PORT) || 8080;
@@ -362,6 +364,15 @@ app.use("/ui/nodes", requireAuth, nodes.router);
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname }); const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys); app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices); app.use("/ui/devices", requireAuth, sub.devices);
// Prometheus: /metrics за отдельным токеном «только метрики» (не JWT и не ключ API), токеном
// управляет панель. Регистрируется до SPA-фолбэка внизу, иначе его перехватит `*`.
const metrics = createMetrics({ uidb, version: VERSION, servers: nodes });
app.use("/ui/metrics-token", requireAuth, metrics.router);
app.get("/metrics", metrics.requireToken, metrics.handler);
// Монитор: встроенный Prometheus и графики во вкладке. Ставит его не панель, а awg-ctrl от root.
const monitor = createMonitor({ ctrlLocal: ctrl, localEnabled: (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off" });
app.use("/ui/monitor", requireAuth, monitor.router);
// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован // Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован
// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже. // на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже.
ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? [])); ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? []));
+15
View File
@@ -332,6 +332,8 @@ body {
cursor: default; cursor: default;
user-select: none; user-select: none;
} }
.nav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; }
.nav-group-title { padding: 8px 12px 4px; font-size: 12px; color: var(--neutral-text); user-select: none; }
.nav-item--active { .nav-item--active {
background: var(--primary-container); background: var(--primary-container);
color: var(--on-primary-container); color: var(--on-primary-container);
@@ -433,6 +435,19 @@ body {
user-select: all; user-select: all;
} }
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } .node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
/* Настройки сервера: деление канала */
.ss-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; }
.ss-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ss-setting-name { font-size: 14px; color: var(--on-surface); }
.ss-setting input[type="checkbox"] { width: 20px; height: 20px; flex: none; accent-color: var(--primary); }
.ss-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.ss-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.ss-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; background: var(--surface-container); border-radius: var(--r-table); padding: 12px; }
.ss-mono { font-family: var(--mono); font-size: 12px; color: var(--neutral-text); word-break: break-all; }
/* Тёплый чип «внимание»: канал занят, фоновый приоритет */
.chip--warm { background: var(--tertiary-container); color: var(--on-tertiary-container); }
.chip--warm .chip-dot { background: var(--on-tertiary-container); }
.server-card-info { .server-card-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+27 -9
View File
@@ -11,7 +11,8 @@ import {
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar'; import ServerBar from './components/ServerBar';
import AddServerModal from './components/AddServerModal'; import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs'; import ServerSettingsModal from './components/ServerSettingsModal';
import { TABS, TABS_BY_GROUP } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, // Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит // список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
@@ -26,6 +27,7 @@ export default function App() {
const [panelVersion, setPanelVersion] = useState<string | null>(null); const [panelVersion, setPanelVersion] = useState<string | null>(null);
const [serverId, setServerIdState] = useState<number | null>(null); const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null); const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [settingsFor, setSettingsFor] = useState<NodeInfo | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id); const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -288,14 +290,19 @@ export default function App() {
</span> </span>
</div> </div>
<hr className="drawer-divider" /> <hr className="drawer-divider" />
{TABS.map(t => ( {TABS_BY_GROUP.map(g => (
<div <nav key={g.id} className="nav-group" aria-label={g.label}>
key={t.id} <span className="nav-group-title">{g.label}</span>
className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`} {g.tabs.map(t => (
onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }} <div
> key={t.id}
<t.Icon /> {t.label} className={`nav-item${t.id === activeTab ? ' nav-item--active' : ''}`}
</div> onClick={() => { setActiveTab(t.id); setDrawerOpen(false); }}
>
<t.Icon /> {t.label}
</div>
))}
</nav>
))} ))}
<div className="sidebar-spacer" /> <div className="sidebar-spacer" />
@@ -318,6 +325,8 @@ export default function App() {
onAdd={() => setModal({})} onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })} onJoin={n => setModal({ node: n })}
onDelete={deleteNode} onDelete={deleteNode}
// Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного
onSettings={n => { pickServer(n.id); setSettingsFor(n); }}
onRestartAwg={restartAwg} onRestartAwg={restartAwg}
restarting={restarting} restarting={restarting}
showRestart={showRestart} showRestart={showRestart}
@@ -346,6 +355,15 @@ export default function App() {
/> />
)} )}
{settingsFor && (
<ServerSettingsModal
token={token}
node={settingsFor}
onClose={() => setSettingsFor(null)}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>} {msg && <div className="snack">{msg}</div>}
</div> </div>
); );
+194
View File
@@ -0,0 +1,194 @@
// 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 { useEffect, useMemo, useRef, useState } from 'react';
// Линейный график для вкладки «Монитор». Одна ось, линия 2 px, конечная точка с кольцом цвета поверхности,
// перекрестие с подсказкой по всем рядам, легенда при ≥ 2 рядах, табличный вид и управление стрелками.
// Цвета рядов — токены темы (--series-N в monitor.css), подписи и значения — всегда цветом текста.
export interface ChartSeries { id: string; name: string; color: string; points: [number, number][] }
const M = { left: 46, right: 14, top: 10, bottom: 24 };
// Шкала оси: круглый шаг (1, 2, 2.5, 5 × 10ⁿ) и 3–5 интервалов, чтобы подписи были 0 / 50 / 100 / 150, а не 0 / 63 / 125.
function niceScale(max: number): { top: number; ticks: number[] } {
if (!(max > 0)) return { top: 1, ticks: [0, 1] };
const raw = max / 4;
const p = Math.pow(10, Math.floor(Math.log10(raw)));
const step = ([1, 2, 2.5, 5, 10].map(f => f * p).find(s => s >= raw)) ?? 10 * p;
const n = Math.max(1, Math.ceil(max / step - 1e-9));
return { top: step * n, ticks: Array.from({ length: n + 1 }, (_, i) => step * i) };
}
const timeLabel = (t: number, span: number) => {
const d = new Date(t * 1000);
const hm = d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
return span > 86400 * 1.5 ? `${d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} ${hm}` : hm;
};
export default function LineChart({ title, series, from, to, step, format, formatAxis, max, dim = false }: {
title: string;
series: ChartSeries[];
from: number;
to: number;
step: number; // шаг данных, с: разрыв длиннее трёх шагов рвёт линию, а не рисует мост
format: (v: number) => string; // значение в подсказке и таблице
formatAxis?: (v: number) => string; // компактная подпись оси
max?: number; // фиксированный верх оси (проценты — 100); без него подбирается по данным
dim?: boolean; // идёт перезагрузка: держим прежний кадр бледнее
}) {
const wrap = useRef<HTMLDivElement>(null); // содержимое карточки без её padding: от него зависит ширина svg
const [w, setW] = useState(640);
const [hover, setHover] = useState<number | null>(null); // индекс по оси времени
const [table, setTable] = useState(false);
const H = 200;
useEffect(() => {
const el = wrap.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(Math.max(260, el.clientWidth)));
ro.observe(el); setW(Math.max(260, el.clientWidth));
return () => ro.disconnect();
}, []);
// Общая ось времени: метки первого непустого ряда (Prometheus выравнивает точки по шагу).
const times = useMemo(() => {
const set = new Set<number>();
for (const s of series) for (const p of s.points) set.add(p[0]);
return [...set].sort((a, b) => a - b);
}, [series]);
const byTime = useMemo(() => series.map(s => new Map(s.points)), [series]);
const empty = times.length === 0;
const { top: yMax, ticks: yTicks } = max !== undefined
? { top: max, ticks: [0, 1, 2, 3, 4].map(i => (max / 4) * i) }
: niceScale(Math.max(0, ...series.flatMap(s => s.points.map(p => p[1]))) * 1.04);
const x = (t: number) => M.left + ((t - from) / Math.max(1, to - from)) * (w - M.left - M.right);
const y = (v: number) => H - M.bottom - (v / yMax) * (H - M.top - M.bottom);
const path = (pts: [number, number][]) => {
let d = '', prev: number | null = null;
for (const [t, v] of pts) {
d += `${prev !== null && t - prev <= step * 3 ? 'L' : 'M'}${x(t).toFixed(1)},${y(v).toFixed(1)}`;
prev = t;
}
return d;
};
const span = to - from;
const xTicks = [0, 1, 2, 3, 4].map(i => from + (span / 4) * i);
const axis = formatAxis ?? format;
const onMove = (e: React.PointerEvent<SVGRectElement>) => {
if (!times.length) return;
const r = e.currentTarget.getBoundingClientRect();
const t = from + ((e.clientX - r.left) / Math.max(1, r.width)) * span;
let best = 0;
for (let i = 1; i < times.length; i++) if (Math.abs(times[i] - t) < Math.abs(times[best] - t)) best = i;
setHover(best);
};
const onKey = (e: React.KeyboardEvent) => {
if (!times.length) return;
if (e.key === 'ArrowLeft') { e.preventDefault(); setHover(h => Math.max(0, (h ?? times.length) - 1)); }
if (e.key === 'ArrowRight') { e.preventDefault(); setHover(h => Math.min(times.length - 1, (h ?? -1) + 1)); }
if (e.key === 'Escape') setHover(null);
};
const ht = hover !== null ? times[hover] : null;
// Подсказка не вылезает за правый край: правее середины открывается влево от перекрестия
const flip = ht !== null && x(ht) > w * 0.55;
// Табличный вид — те же числа без наведения: ~12 равномерно выбранных строк, последняя — самая свежая
const rows = useMemo(() => {
if (!times.length) return [] as number[];
const n = Math.min(12, times.length);
return Array.from({ length: n }, (_, i) => times[Math.round(((times.length - 1) * (i + 1)) / n)]).reverse();
}, [times]);
return (
<figure className={`mon-chart${dim ? ' mon-chart--dim' : ''}`}>
<figcaption className="mon-chart-head">
<span className="mon-chart-title">{title}</span>
{series.length >= 2 && (
<span className="mon-legend">
{series.map(s => (
<span className="mon-legend-item" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />{s.name}
</span>
))}
</span>
)}
<button type="button" className="mon-chart-toggle" aria-pressed={table} onClick={() => setTable(t => !t)}>
{table ? 'График' : 'Таблица'}
</button>
</figcaption>
<div ref={wrap}>
{table ? (
<div className="mon-table-wrap">
<table className="mon-table">
<thead><tr><th>Время</th>{series.map(s => <th key={s.id}>{s.name}</th>)}</tr></thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={series.length + 1}>Нет данных</td></tr> : rows.map(t => (
<tr key={t}>
<td>{timeLabel(t, span)}</td>
{series.map((s, i) => <td key={s.id}>{byTime[i].has(t) ? format(byTime[i].get(t)!) : '—'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="mon-plot">
<svg width={w} height={H} role="img" tabIndex={0} aria-label={`${title}. Стрелки влево и вправо — значения по времени.`}
onKeyDown={onKey} onBlur={() => setHover(null)}>
{yTicks.map((v, i) => (
<g key={i}>
<line className="mon-grid" x1={M.left} x2={w - M.right} y1={y(v)} y2={y(v)} />
<text className="mon-tick" x={M.left - 8} y={y(v) + 4} textAnchor="end">{axis(v)}</text>
</g>
))}
{xTicks.map((t, i) => (
<text key={i} className="mon-tick" x={x(t)} y={H - 6} textAnchor={i === 0 ? 'start' : i === 4 ? 'end' : 'middle'}>
{timeLabel(t, span)}
</text>
))}
{!empty && series.map(s => (
<path key={s.id} d={path(s.points)} fill="none" style={{ stroke: s.color }} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
))}
{/* конечная точка: r 4 + кольцо цвета поверхности, чтобы читалась поверх линий */}
{!empty && series.map(s => s.points.length > 0 && (
<circle key={s.id} cx={x(s.points[s.points.length - 1][0])} cy={y(s.points[s.points.length - 1][1])}
r={4} style={{ fill: s.color }} className="mon-end" />
))}
{ht !== null && (
<g>
<line className="mon-cross" x1={x(ht)} x2={x(ht)} y1={M.top} y2={H - M.bottom} />
{series.map((s, i) => byTime[i].has(ht) && (
<circle key={s.id} cx={x(ht)} cy={y(byTime[i].get(ht)!)} r={4} style={{ fill: s.color }} className="mon-end" />
))}
</g>
)}
{/* зона наведения шире линии: указатель ищет момент времени, а не 2-пиксельную линию */}
<rect x={M.left} y={M.top} width={Math.max(0, w - M.left - M.right)} height={H - M.top - M.bottom}
fill="transparent" onPointerMove={onMove} onPointerLeave={() => setHover(null)} />
</svg>
{empty && <p className="mon-empty">Данные появятся через минуту-две после запуска Prometheus</p>}
{ht !== null && (
<div className="mon-tip" style={{ left: flip ? undefined : x(ht) + 12, right: flip ? w - x(ht) + 12 : undefined, top: M.top }} role="status">
<span className="mon-tip-time">{timeLabel(ht, span)}</span>
{series.map((s, i) => (
<span className="mon-tip-row" key={s.id}>
<i className="mon-key" style={{ background: s.color }} aria-hidden="true" />
<b>{byTime[i].has(ht) ? format(byTime[i].get(ht)!) : '—'}</b>
<span>{s.name}</span>
</span>
))}
</div>
)}
</div>
)}
</div>
</figure>
);
}
+25
View File
@@ -0,0 +1,25 @@
// 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 type { Priority } from '../lib/shared';
// Текущая приоритетность ключа/устройства. «Фоновый» — только пока канал занят, а устройство давно
// качает заметно больше своей доли: оно уступает остальным, но свободный канал занимает по-прежнему.
// Когда деление канала выключено или канал свободен, у всех «обычный». Без поля (awg-ctrl старше
// деления канала) чип не показываем: «обычный» там было бы выдумкой.
export default function PriorityChip({ priority }: { priority?: Priority }) {
if (!priority) return null;
const bg = priority === 'background';
return (
<span
className="tip-wrap"
data-tip={bg
? 'Канал занят, а это устройство давно качает больше своей доли — пока оно уступает остальным'
: 'Обычный приоритет: канал делится поровну'}
>
<span className={`chip ${bg ? 'chip--warm' : 'chip--offline'}`}>
<span className="chip-dot" />{bg ? 'фоновый' : 'обычный'}
</span>
</span>
);
}
+13 -2
View File
@@ -2,7 +2,7 @@
// This source code is licensed under the MIT license found in the // This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared'; import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons'; import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy, IcoSettings } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и // Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает // (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
@@ -29,7 +29,7 @@ function versionChip(n: NodeInfo, panel: string | null) {
} }
export default function ServerBar({ export default function ServerBar({
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete, onSettings,
onRestartAwg, restarting, showRestart = true, onRestartAwg, restarting, showRestart = true,
}: { }: {
nodes: NodeInfo[]; nodes: NodeInfo[];
@@ -40,6 +40,7 @@ export default function ServerBar({
onAdd: () => void; onAdd: () => void;
onJoin: (n: NodeInfo) => void; onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void; onDelete: (n: NodeInfo) => void;
onSettings: (n: NodeInfo) => void;
onRestartAwg: () => void; onRestartAwg: () => void;
restarting: boolean; restarting: boolean;
showRestart?: boolean; showRestart?: boolean;
@@ -103,6 +104,16 @@ export default function ServerBar({
<IcoCopy /> <IcoCopy />
</button> </button>
)} )}
{/* Настройки сервера: у ноды, которая ещё не подключалась, настраивать нечего */}
{!n.pending && n.online && (
<button
className="btn-icon btn-icon--neutral"
aria-label={`Настройки сервера ${n.name}`}
onClick={e => { e.stopPropagation(); onSettings(n); }}
>
<IcoSettings />
</button>
)}
{!n.local && ( {!n.local && (
<button <button
className="btn-icon btn-icon--danger" className="btn-icon btn-icon--danger"
@@ -0,0 +1,151 @@
// 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 { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, type NodeInfo, type ShapingStatus } from '../lib/shared';
// Настройки сервера: деление канала между устройствами (CAKE). Сами настройки живут в
// awg-ctrl этого сервера — запрос идёт обычным прокси с X-Server-Id (App выбирает сервер
// до открытия модалки), так что ядро и ноды настраиваются одинаково.
export default function ServerSettingsModal({ token, node, onClose, showMsg }: {
token: string;
node: NodeInfo;
onClose: () => void;
showMsg: (text: string) => void;
}) {
const [st, setSt] = useState<ShapingStatus | null>(null);
const [enabled, setEnabled] = useState(false);
const [auto, setAuto] = useState(true);
const [down, setDown] = useState('');
const [up, setUp] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [unsupported, setUnsupported] = useState(false);
const fill = (s: ShapingStatus) => {
setSt(s);
setEnabled(s.settings.enabled); setAuto(s.settings.auto);
setDown(s.settings.downMbit ? String(s.settings.downMbit) : '');
setUp(s.settings.upMbit ? String(s.settings.upMbit) : '');
};
const errText = (e: unknown, fallback: string) =>
axios.isAxiosError(e) ? e.response?.data?.error ?? fallback : fallback;
useEffect(() => {
let alive = true;
apiFetch('GET', '/api/shaping', token)
.then(s => { if (alive) fill(s as ShapingStatus); })
.catch(e => {
if (!alive) return;
// Старый awg-ctrl не знает этот маршрут — его надо обновить, а не «починить» здесь.
if (axios.isAxiosError(e) && e.response?.status === 404) setUnsupported(true);
else setError(errText(e, 'Не удалось загрузить настройки'));
});
return () => { alive = false; };
}, [token]);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const downN = Number(down), upN = Number(up);
const valid = !enabled || (Number.isInteger(downN) && downN >= 1 && Number.isInteger(upN) && upN >= 1);
const save = async () => {
setBusy(true); setError('');
try {
const s = await apiFetch('PUT', '/api/shaping', token, {
enabled, auto, downMbit: Number(down) || 0, upMbit: Number(up) || 0,
});
fill(s as ShapingStatus);
showMsg(enabled ? 'Деление канала включено' : 'Деление канала выключено');
} catch (e) { setError(errText(e, 'Не удалось сохранить')); }
finally { setBusy(false); }
};
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Настройки сервера" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">Деление канала: {node.name}</h3>
{unsupported ? (
<p className="node-modal-hint">
Этот сервер не поддерживает деление канала: обновите его (install.sh → «Обновить»).
</p>
) : (
<>
<p className="node-modal-hint">
Канал делится поровну между устройствами, но не простаивает: кто берёт мало, получает всё
нужное, остальное достаётся тем, кто качает много. Звонки и игры не ждут за чужими закачками.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Честное деление между устройствами</span>
</span>
<input type="checkbox" checked={enabled} disabled={!st || busy} onChange={e => setEnabled(e.target.checked)} />
</label>
<div className="ss-grid">
<label className="ss-field">
<span className="ss-setting-name">Загрузка, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={down} disabled={!st || busy} onChange={e => setDown(e.target.value)} />
</label>
<label className="ss-field">
<span className="ss-setting-name">Отдача, Мбит/с</span>
<input className="field" type="number" inputMode="numeric" min={1} max={100000}
value={up} disabled={!st || busy} onChange={e => setUp(e.target.value)} />
</label>
</div>
<p className="node-modal-hint">
Укажите около 90–95% реального канала сервера. Если задать больше, очередь копится у провайдера
и деление перестаёт работать.
</p>
<label className="ss-setting">
<span className="ss-setting-text">
<span className="ss-setting-name">Снижать приоритет тяжёлых</span>
<span className="node-modal-hint">
Когда канал занят, устройства, которые долго качают заметно больше своей доли, временно
уходят в фоновый класс. Работает на загрузку; свободный канал они по-прежнему занимают.
</span>
</span>
<input type="checkbox" checked={auto} disabled={!st || busy || !enabled} onChange={e => setAuto(e.target.checked)} />
</label>
{st?.settings.enabled && (
<div className="ss-status">
<span className={`chip ${st.active ? (st.congested ? 'chip--warm' : 'chip--online') : 'chip--offline'}`}>
<span className="chip-dot" />
{!st.active ? 'не применено' : st.congested ? 'канал занят' : 'канал свободен'}
</span>
<span className="ss-mono">
↓ {st.rateDown} · ↑ {st.rateUp} Мбит/с · устройств {st.devices}
</span>
{st.heavy.length > 0 && (
<span className="ss-mono">в фоне: {st.heavy.join(', ')}</span>
)}
</div>
)}
</>
)}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
{!unsupported && (
<button className="btn btn--primary" onClick={save} disabled={!st || busy || !valid}>
Сохранить
</button>
)}
<button className="btn btn--outline" onClick={onClose}>Закрыть</button>
</div>
</div>
</div>
);
}
+14
View File
@@ -90,7 +90,21 @@ export const IcoMoon = () => (
// Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр // Карта «имя → иконка» для вкладок: metadata.json хранит имя строкой, реестр
// (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку. // (tabs/index.ts) резолвит его сюда. Новой вкладке — добавь сюда её иконку.
export const IcoShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3z"/><path d="m9 12 2 2 4-4"/>
</svg>
);
export const IcoPulse = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>
</svg>
);
export const ICONS: Record<string, () => JSX.Element> = { export const ICONS: Record<string, () => JSX.Element> = {
users: IcoUsers, users: IcoUsers,
key: IcoKey, key: IcoKey,
shield: IcoShield,
pulse: IcoPulse,
}; };
+15
View File
@@ -5,6 +5,9 @@
import axios from 'axios'; import axios from 'axios';
// Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ. // Поколение протокола AmneziaWG, на параметрах которого выдан vpn:// ключ.
export type AwgGen = '2' | '3.1'; export type AwgGen = '2' | '3.1';
// Текущая приоритетность ключа/устройства: «background» — канал занят, а он давно качает больше своей доли.
export type Priority = 'normal' | 'background';
export interface User { export interface User {
name: string; name: string;
ip: string; ip: string;
@@ -15,6 +18,7 @@ export interface User {
lastHandshake: number; lastHandshake: number;
rx?: number; rx?: number;
tx?: number; tx?: number;
priority?: Priority; // нет у awg-ctrl старше деления канала
} }
export interface ServerInfo { export interface ServerInfo {
name: string; name: string;
@@ -64,6 +68,17 @@ export interface NodeInfo {
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null; health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
} }
// Деление канала (GET/PUT /api/shaping на awg-ctrl сервера).
export interface ShapingStatus {
settings: { enabled: boolean; downMbit: number; upMbit: number; auto: boolean };
active: boolean; // CAKE реально стоит на интерфейсе
congested: boolean;
heavy: string[]; // IP устройств, переведённых в фоновый класс
devices: number; // активных устройств в окне
rateDown: number; // Мбит/с за последний тик
rateUp: number;
}
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b. // Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
export function cmpVersion(a: string, b: string): number { export function cmpVersion(a: string, b: string): number {
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number); const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
+2
View File
@@ -90,3 +90,5 @@ LICENSE file in the root directory of this source tree.
.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } .apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; } .apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; }
.apikey-card-servers { font-size: 12px; color: var(--neutral-text); } .apikey-card-servers { font-size: 12px; color: var(--neutral-text); }
.apikey-new-servers { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 0 0 16px; }
+12 -2
View File
@@ -18,6 +18,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [created, setCreated] = useState<{ label: string; key: string } | null>(null); const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками const [tools, setTools] = useState<number | null>(null); // ключ с открытыми настройками
const [picked, setPicked] = useState<number[] | null>(null); // серверы нового ключа; null — только выбранный в сервер-баре
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -54,12 +55,15 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const createKey = useCallback(async () => { const createKey = useCallback(async () => {
if (!label.trim()) return; if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 }); const def = getServerId() ?? 0;
const servers = picked ?? [def];
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: servers.includes(def) ? def : servers[0], servers });
if (r.error) { showMsg(r.error); return; } if (r.error) { showMsg(r.error); return; }
setLabel(''); setLabel('');
setPicked(null);
setCreated({ label: r.label, key: r.key }); setCreated({ label: r.label, key: r.key });
await load(); await load();
}, [label, token, load, showMsg]); }, [label, picked, token, load, showMsg]);
const deleteKey = useCallback(async (id: number, lbl: string) => { const deleteKey = useCallback(async (id: number, lbl: string) => {
if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return; if (!confirm('Удалить ключ «' + lbl + '»? Клиенты с ним потеряют доступ.')) return;
@@ -70,6 +74,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
useEffect(() => { if (token) load(); }, [token, load]); useEffect(() => { if (token) load(); }, [token, load]);
return ( return (
<> <>
{/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */} {/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
@@ -87,6 +92,11 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
</button> </button>
</div> </div>
</div> </div>
<div className="apikey-new-servers">
<span className="apikey-tools-hint">Серверы нового ключа:</span>
<ServerPicker nodes={nodes} selected={picked ?? [getServerId() ?? 0]}
onChange={next => setPicked(next)} />
</div>
<div className="table-card"> <div className="table-card">
<table> <table>
+1
View File
@@ -2,5 +2,6 @@
"id": "apikeys", "id": "apikeys",
"label": "API-ключи", "label": "API-ключи",
"icon": "key", "icon": "key",
"group": "main",
"order": 2 "order": 2
} }
+39
View File
@@ -0,0 +1,39 @@
/*
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.
*/
/* Стили вкладки «Аудит». Общая дизайн-система — в App.css. */
.au-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.au-run { margin-left: auto; }
.au-hint { font-size: 13px; color: var(--neutral-text); }
.au-notice {
font-size: 13px; color: var(--error-text); background: var(--error-bg);
border-radius: 16px; padding: 10px 16px; margin: 0;
}
.au-empty { font-size: 14px; color: var(--neutral-text); text-align: center; padding: 48px 0; }
.au-pub { display: inline-block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; font-family: var(--mono); font-size: 12px; }
.au-table td, .au-table th { white-space: nowrap; }
/* мобильный: таблица скрыта (.table-card), то же — карточками */
.au-cards { list-style: none; margin: 0; padding: 0; display: none; flex-direction: column; gap: 8px; }
.au-card { display: flex; flex-direction: column; gap: 4px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-card-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.au-card .au-pub { max-width: 100%; }
@media (max-width: 640px) { .au-cards { display: flex; } }
.au-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-left: auto; }
.au-run { margin-left: 0; }
.au-box { display: flex; flex-direction: column; gap: 10px; background: var(--surface-container); border-radius: 16px; padding: 12px 16px; }
.au-box-title { margin: 0; font-size: 14px; font-weight: 500; }
.au-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.au-setting-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.au-setting-name { font-size: 14px; }
.au-setting-ctl { display: inline-flex; align-items: center; gap: 8px; }
.au-max { width: 88px !important; }
.au-select { width: auto !important; }
.au-log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.au-log li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; font-size: 13px; }
.au-log .au-pub { max-width: 240px; }
+193
View File
@@ -0,0 +1,193 @@
// 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 { useState, useEffect, useCallback } from 'react';
import { apiFetch, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoRefresh, IcoTrash } from '../../components/icons';
import { serverName } from '../../components/ServerPicker';
import './audit.css';
// Вкладка «Аудит»: core периодически обходит все серверы и ищет «мёртвых» пиров — живых на сервере,
// хотя панель про них не знает (мастер-ключ удалён, а пир остался). Снимает только подтверждённых
// (мёртвых два прохода подряд): автоматически, если включено в настройках, или кнопкой.
// Отчёт и очистку считает awg-ui/sub.ts (/ui/masterkeys/audit*).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: 'ok' | 'offline' | 'error'; ghosts_checked: boolean;
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number; blocked?: number;
}
interface Settings { clean: boolean; every_h: number; max: number }
interface Cleaned { at: number; server_id: number; pub_key: string; kind: 'ghost' | 'orphan'; key?: string | null; auto: boolean }
interface AuditData { enabled: boolean; settings: Settings; report: { at: number; servers: AuditEntry[] } | null; cleaned: Cleaned[] }
const PERIODS = [1, 3, 6, 12, 24];
const errText = (e: any): string => e?.response?.data?.error ?? 'Ошибка запроса';
export default function AuditPage({ token, showMsg }: PageProps) {
const [audit, setAudit] = useState<AuditData | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [busy, setBusy] = useState(false);
const [maxEdit, setMaxEdit] = useState('');
const load = useCallback(async () => {
try {
setAudit(await apiFetch('GET', '/ui/masterkeys/audit', token));
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
} catch { showMsg('Ошибка загрузки аудита'); }
}, [token, showMsg]);
useEffect(() => { if (token) load(); }, [token, load]);
const call = useCallback(async (method: string, path: string, ok: string, body?: object) => {
setBusy(true);
try { setAudit(await apiFetch(method, `/ui/masterkeys/audit${path}`, token, body)); showMsg(ok); }
catch (e) { showMsg(errText(e)); }
finally { setBusy(false); }
}, [token, showMsg]);
const run = () => call('POST', '', 'Аудит выполнен');
const save = (patch: Partial<Settings>) => call('PUT', '/settings', 'Настройки сохранены', patch);
if (!audit) return null;
if (!audit.enabled) return <p className="au-empty">Аудит выключен на этой панели: его проводит только core.</p>;
const { settings } = audit;
const servers = audit.report?.servers ?? [];
const dead = servers.reduce((n, e) => n + e.ghosts.length + e.orphans.length, 0);
const bad = servers.filter(e => e.status !== 'ok');
const confirmed = servers.reduce((n, e) => n + [...e.ghosts, ...e.orphans].filter(p => p.seen_before).length, 0);
const blocked = servers.filter(e => e.blocked);
const rows = servers.flatMap(e => [
...e.ghosts.map(p => ({ e, p, why: 'Есть в ядре, у awg-ctrl записи нет' })),
...e.orphans.map(p => ({ e, p, why: 'Панели не нужен, не снят' })),
]);
const state = (p: AuditPeer) => (
<span className={`chip chip--${p.online ? 'error' : 'offline'}`}>
<span className="chip-dot" />{p.online ? 'Онлайн' : p.lastHandshake ? `был ${timeAgo(p.lastHandshake)} назад` : 'не подключался'}
</span>
);
const confirm = (p: AuditPeer) => (
<span className="au-hint">{p.seen_before ? 'подтверждён' : 'найден впервые — снимется, если останется и в следующий раз'}</span>
);
return (
<>
<section className="au-head">
<span className={`chip chip--${!audit.report ? 'offline' : dead ? 'error' : 'online'}`}>
<span className="chip-dot" />
{!audit.report ? 'Аудит ещё не проводился' : dead ? `Мёртвых пиров: ${dead}` : 'Мёртвых пиров нет'}
</span>
{audit.report && (
<span className="au-hint">
Проверено {timeAgo(audit.report.at)} назад · серверов: {servers.length} · раз в {settings.every_h} ч
</span>
)}
<div className="au-btns">
{confirmed > 0 && (
<button className="btn btn--tonal" disabled={busy}
onClick={() => window.confirm(`Снять с серверов подтверждённых пиров: ${confirmed}?`) && call('POST', '/clean', 'Очистка выполнена')}>
<IcoTrash /> Снять подтверждённых ({confirmed})
</button>
)}
<button className="btn btn--tonal" disabled={busy} onClick={run}>
<IcoRefresh /> {busy ? 'Работаем…' : 'Проверить сейчас'}
</button>
</div>
</section>
<section className="au-box">
<h4 className="au-box-title">Настройки</h4>
<label className="au-setting">
<span className="au-setting-text">
<span className="au-setting-name">Снимать мёртвых автоматически</span>
<span className="au-hint">Только тех, кто мёртв два прохода подряд. Ручной проход ничего не снимает.</span>
</span>
<input type="checkbox" checked={settings.clean} disabled={busy} onChange={e => save({ clean: e.target.checked })} />
</label>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-every">Период проверки</label>
<span className="au-hint">Как часто core обходит серверы</span>
</span>
<select id="au-every" className="field au-select" value={settings.every_h} disabled={busy}
onChange={e => save({ every_h: Number(e.target.value) })}>
{[...new Set([...PERIODS, settings.every_h])].sort((a, b) => a - b).map(h => <option key={h} value={h}>{h} ч</option>)}
</select>
</div>
<div className="au-setting">
<span className="au-setting-text">
<label className="au-setting-name" htmlFor="au-max">Предел за проход</label>
<span className="au-hint">Если на сервере мёртвых больше — автоочистка ничего не снимает и только сообщает (защита от сбоя)</span>
</span>
<span className="au-setting-ctl">
<input id="au-max" className="field au-max" type="number" min={1} max={1000}
value={maxEdit || String(settings.max)} onChange={e => setMaxEdit(e.target.value)} />
<button className="btn btn--tonal" disabled={busy || !maxEdit || Number(maxEdit) === settings.max}
onClick={() => save({ max: Number(maxEdit) }).then(() => setMaxEdit(''))}>Сохранить</button>
</span>
</div>
</section>
{bad.length > 0 && (
<p className="au-notice">
Не проверены: {bad.map(e => `${serverName(nodes, e.server_id)} (${e.status === 'offline' ? 'не в сети' : 'ошибка'})`).join(', ')}
</p>
)}
{blocked.length > 0 && (
<p className="au-notice">
Автоочистка пропустила: {blocked.map(e => `${serverName(nodes, e.server_id)} (${e.blocked} пиров при пределе ${settings.max})`).join(', ')}. Проверьте список и снимите вручную.
</p>
)}
{servers.some(e => e.status === 'ok' && !e.ghosts_checked) && (
<p className="au-notice">Часть серверов на старой версии awg-ctrl: пиров, которые есть только в ядре, там не видно.</p>
)}
{rows.length > 0 && (
<>
<div className="table-card au-table-card">
<table>
<thead><tr><th>Сервер</th><th>Публичный ключ</th><th>Причина</th><th>Мастер-ключ</th><th>Состояние</th></tr></thead>
<tbody>
{rows.map(({ e, p, why }) => (
<tr key={`${e.server_id}:${p.pub_key}`}>
<td>{serverName(nodes, e.server_id)}</td>
<td className="td-mono"><code className="au-pub" title={p.pub_key}>{p.pub_key}</code></td>
<td>{why}</td>
<td>{p.key ?? '—'}</td>
<td>{state(p)}<br />{confirm(p)}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="au-cards">
{rows.map(({ e, p, why }) => (
<li className="au-card" key={`${e.server_id}:${p.pub_key}`}>
<div className="au-card-top"><b>{serverName(nodes, e.server_id)}</b>{state(p)}</div>
<code className="au-pub" title={p.pub_key}>{p.pub_key}</code>
<span className="au-hint">{why}{p.key ? ` · ${p.key}` : ''}</span>
{confirm(p)}
</li>
))}
</ul>
</>
)}
{audit.cleaned.length > 0 && (
<section className="au-box">
<h4 className="au-box-title">Снято</h4>
<ul className="au-log">
{audit.cleaned.slice(0, 20).map(c => (
<li key={`${c.at}:${c.pub_key}`}>
<span className="au-hint">{timeAgo(c.at)} назад</span>
<span>{serverName(nodes, c.server_id)}</span>
<code className="au-pub" title={c.pub_key}>{c.pub_key}</code>
<span className="au-hint">{c.kind === 'ghost' ? 'был только в ядре' : 'лишний у awg-ctrl'}{c.key ? ` · ${c.key}` : ''} · {c.auto ? 'автоматически' : 'вручную'}</span>
</li>
))}
</ul>
</section>
)}
{audit.report && rows.length === 0 && <p className="au-empty">Всё чисто: лишних пиров на серверах нет.</p>}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "audit",
"label": "Аудит",
"icon": "shield",
"group": "server",
"order": 1.8,
"chrome": {
"serverBar": false,
"restart": false
}
}
+24 -3
View File
@@ -4,7 +4,7 @@
// Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/: // Автообнаружение вкладок. Каждая вкладка — самодостаточная папка tabs/<name>/:
// index.tsx — компонент + логика // index.tsx — компонент + логика
// <name>.css — стили вкладки (импортит сам компонент) // <name>.css — стили вкладки (импортит сам компонент)
// metadata.json — { id, label, icon, order?, chrome? } (манифест: описание + порядок) // metadata.json — { id, label, icon, group?, order?, chrome? } (манифест: описание, группа меню, порядок)
// Реестр сам находит все папки через import.meta.glob и собирает «живые» части // Реестр сам находит все папки через import.meta.glob и собирает «живые» части
// (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто // (Page-компонент, Icon по имени из ICONS) к метадате. Добавить вкладку = просто
// создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно. // создать папку с index.tsx и metadata.json — этот файл трогать НЕ нужно.
@@ -21,12 +21,20 @@ export interface TabChrome {
restart?: boolean; restart?: boolean;
} }
// Группы меню: порядок здесь = порядок заголовков в сайдбаре. Новая группа — строка сюда.
export const TAB_GROUPS = [
{ id: 'main', label: 'Основное' },
{ id: 'server', label: 'О сервере' },
] as const;
export type TabGroupId = (typeof TAB_GROUPS)[number]['id'];
// Сериализуемая часть из metadata.json. // Сериализуемая часть из metadata.json.
export interface TabMeta { export interface TabMeta {
id: string; id: string;
label: string; label: string;
icon: string; // имя иконки → ICONS icon: string; // имя иконки → ICONS
order?: number; // порядок в меню (по возрастанию; без него — 0) group: TabGroupId; // группа меню; без поля — 'main'
order?: number; // порядок внутри группы (по возрастанию; без него — 0)
chrome?: TabChrome; chrome?: TabChrome;
} }
@@ -58,10 +66,15 @@ function toMeta(name: string, raw: unknown): TabMeta {
if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) { if (m.chrome !== undefined && (typeof m.chrome !== 'object' || m.chrome === null)) {
throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`); throw new Error(`tabs/${name}/metadata.json: "chrome" должно быть объектом`);
} }
const group = (m.group ?? 'main') as string;
if (!TAB_GROUPS.some(g => g.id === group)) {
throw new Error(`tabs/${name}/metadata.json: "group" должно быть одним из: ${TAB_GROUPS.map(g => g.id).join(', ')}`);
}
return { return {
id: str('id'), id: str('id'),
label: str('label'), label: str('label'),
icon: str('icon'), icon: str('icon'),
group: group as TabGroupId,
order: m.order as number | undefined, order: m.order as number | undefined,
chrome: m.chrome as TabChrome | undefined, chrome: m.chrome as TabChrome | undefined,
}; };
@@ -80,7 +93,15 @@ export const TABS: TabDef[] = Object.entries(metas)
} }
return { ...meta, Icon: ICONS[meta.icon], Page: page }; return { ...meta, Icon: ICONS[meta.icon], Page: page };
}) })
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); // сначала по группе (как в TAB_GROUPS), внутри группы — по order
.sort((a, b) =>
TAB_GROUPS.findIndex(g => g.id === a.group) - TAB_GROUPS.findIndex(g => g.id === b.group)
|| (a.order ?? 0) - (b.order ?? 0));
// Вкладки по группам для меню; пустые группы не показываем.
export const TABS_BY_GROUP = TAB_GROUPS
.map(g => ({ ...g, tabs: TABS.filter(t => t.group === g.id) }))
.filter(g => g.tabs.length > 0);
// id вкладок используются как ключ активной вкладки — дубли ломают переключение. // id вкладок используются как ключ активной вкладки — дубли ломают переключение.
const ids = TABS.map(t => t.id); const ids = TABS.map(t => t.id);
+4 -2
View File
@@ -3,9 +3,10 @@
// LICENSE file in the root directory of this source tree. // LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react'; import { Fragment, useState, useEffect, useCallback } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared'; import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps, type Priority } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
import ServerPicker, { serverName } from '../../components/ServerPicker'; import ServerPicker, { serverName } from '../../components/ServerPicker';
import PriorityChip from '../../components/PriorityChip';
import './masterkeys.css'; import './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
@@ -15,7 +16,7 @@ import './masterkeys.css';
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] } interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
interface Device { interface Device {
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: Priority;
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
} }
@@ -283,6 +284,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}> <span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'} <span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
</span> </span>
<PriorityChip priority={d.priority} />
<div className="mk-device-traffic"> <div className="mk-device-traffic">
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span> <span className="mk-metric"><i className="mk-metric-name" data-sfx=" rx">↓</i><b>{bytes(d.rx)}</b></span>
<span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span> <span className="mk-metric"><i className="mk-metric-name" data-sfx=" tx">↑</i><b>{bytes(d.tx)}</b></span>
+1
View File
@@ -2,5 +2,6 @@
"id": "masterkeys", "id": "masterkeys",
"label": "Мастер-ключи", "label": "Мастер-ключи",
"icon": "key", "icon": "key",
"group": "main",
"order": 1.5 "order": 1.5
} }
+328
View File
@@ -0,0 +1,328 @@
// 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 { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch, copyText, getServerId, type NodeInfo, type PageProps } from '../../lib/shared';
import { IcoCopy, IcoTrash } from '../../components/icons';
import LineChart from '../../components/LineChart';
import './monitor.css';
// Вкладка «Монитор»: встроенный Prometheus и основные графики.
// Кнопка «Подключить Prometheus» просит локальный awg-ctrl поставить его (`awg-ctrl monitor install`, root;
// сама панель пакетов не ставит). Графики читают Prometheus через фиксированные запросы панели
// (awg-ui/monitor.ts).
interface Status {
ready: boolean; can_install: boolean; command: string;
job: { action: 'install' | 'remove'; running: boolean; exit: number | null; log: string } | null; // последняя операция
}
type Pts = [number, number][];
interface Overview {
range: string; step: number; from: number; to: number;
servers: {
id: number; name: string; ok: boolean; down: number | null; up: number | null; online: number | null; heavy: number | null; congested: boolean;
cpu: number | null; mem: number | null; cores: number | null; mem_total: number | null;
}[];
total: Series;
}
interface Series { down: Pts; up: Pts; online: Pts; heavy: Pts; drops: Pts; cpu: Pts; mem: Pts }
const RANGES: [string, string][] = [['1h', '1 час'], ['6h', '6 часов'], ['24h', '24 часа']];
const nf = (v: number, d = 1) => v.toLocaleString('ru-RU', { maximumFractionDigits: d });
// Скорость в битах/с → «12,4 Мбит/с»
function rate(v: number, d = 1): string {
if (v >= 1e9) return `${nf(v / 1e9, d)} Гбит/с`;
if (v >= 1e6) return `${nf(v / 1e6, d)} Мбит/с`;
if (v >= 1e3) return `${nf(v / 1e3, d)} Кбит/с`;
return `${nf(v, 0)} бит/с`;
}
const lastOf = (p: Pts) => (p.length ? p[p.length - 1][1] : null);
// Полоска нагрузки: цвет заливки — серьёзность (норма → внимание → опасно), дорожка — светлая ступень той же шкалы.
// Цифра рядом всегда есть: цвет не единственный носитель смысла.
const level = (v: number) => (v >= 90 ? 'mon-bar--bad' : v >= 70 ? 'mon-bar--warn' : '');
function Bar({ v, label }: { v: number | null | undefined; label: string }) {
const pct = v == null ? 0 : Math.min(100, Math.max(0, v));
return (
<span className="mon-bar" role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100}
aria-valuenow={v == null ? undefined : Math.round(pct)}>
<i className={v == null ? '' : level(pct)} style={{ width: `${pct}%` }} />
</span>
);
}
// Прогресс операции из строк «N/M шаг», которые печатает сам установщик (cli/src/monitor.ts). Шаг N считаем
// выполненным наполовину: так полоска не стоит на нуле, пока идёт долгий apt, и не обещает больше, чем известно.
const STEP = /^(\d+)\/(\d+)\s+(.+)$/;
function JobProgress({ job, starting }: { job: Status['job']; starting: boolean }) {
let step: [number, number, string] | null = null;
for (const l of job?.log.split('\n') ?? []) { const m = STEP.exec(l.trim()); if (m) step = [Number(m[1]), Number(m[2]), m[3]]; }
const failed = !!job && job.exit !== null && job.exit !== 0;
const done = !!job && job.exit === 0;
const pct = done ? 100 : step ? ((step[0] - 0.5) / step[1]) * 100 : 0;
const label = failed ? `Не удалось (код ${job!.exit})`
: done ? (job!.action === 'install' ? 'Готово' : 'Удалено')
: step ? `Шаг ${step[0]} из ${step[1]}: ${step[2]}` : 'Запуск…';
return (
<div className="mon-progress" aria-live="polite">
<span className={`mon-pbar${failed ? ' mon-pbar--bad' : done ? ' mon-pbar--ok' : !step && (starting || job?.running) ? ' mon-pbar--wait' : ''}`}
role="progressbar" aria-label="Ход операции" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(pct)}>
<i style={{ width: `${pct}%` }} />
</span>
<span className="mon-progress-label">{label}</span>
</div>
);
}
const gb = (b: number) => `${nf(b / 2 ** 30, 1)} ГБ`;
export default function MonitorPage({ token, showMsg }: PageProps) {
const [status, setStatus] = useState<Status | null>(null);
const [range, setRange] = useState('1h');
const [ov, setOv] = useState<Overview | null>(null);
const [ovErr, setOvErr] = useState('');
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<'install' | 'remove' | null>(null); // операция запущена из этой вкладки
const [server, setServer] = useState<NodeInfo | null>(null); // показанный сервер: выбран в другой вкладке, здесь не меняется
const alive = useRef(true);
// StrictMode монтирует эффект дважды: флаг надо вернуть в true, иначе все ответы отбрасываются
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
const loadStatus = useCallback(async () => {
try { const s = await apiFetch('GET', '/ui/monitor', token) as Status; if (alive.current) setStatus(s); return s; }
catch { return null; }
}, [token]);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
const o = await apiFetch('GET', `/ui/monitor/overview?range=${range}&server=${getServerId() ?? 0}`, token) as Overview;
if (alive.current) { setOv(o); setOvErr(''); }
} catch (e: any) { if (alive.current) setOvErr(e?.response?.data?.error ?? 'Не удалось получить данные'); }
finally { if (alive.current) setLoading(false); }
}, [token, range]);
useEffect(() => { void loadStatus(); }, [loadStatus]);
// Название и состояние сервера для заголовка; список серверов на этой вкладке не показываем
useEffect(() => {
const load = () => apiFetch('GET', '/ui/nodes', token)
.then(r => { if (alive.current) setServer((r.nodes as NodeInfo[]).find(n => n.id === (getServerId() ?? 0)) ?? null); })
.catch(() => { /* заголовок необязателен */ });
void load();
const t = setInterval(load, 15000);
return () => clearInterval(t);
}, [token]);
// Пока Prometheus готов — обновляем графики; пока ставится — следим за ходом установки.
useEffect(() => {
if (!status?.ready) return;
void loadOverview();
const t = setInterval(() => { void loadOverview(); }, 15000);
return () => clearInterval(t);
}, [status?.ready, loadOverview]);
// Пока идёт установка или удаление — следим за ходом: раз в 2 с читаем статус и лог
useEffect(() => {
if (!pending && !status?.job?.running) return;
const t = setInterval(async () => {
const s = await loadStatus();
if (s && !s.job?.running) setPending(null);
}, 2000);
return () => clearInterval(t);
}, [pending, status?.job?.running, loadStatus]);
const run = async (action: 'install' | 'remove') => {
if (action === 'remove' && !confirm('Удалить Prometheus? Он будет остановлен, а накопленные графики (до 15 дней) сотрутся. Панель продолжит отдавать /metrics.')) return;
setPending(action);
try { await apiFetch('POST', `/ui/monitor/${action}`, token); await loadStatus(); }
catch (e: any) { setPending(null); showMsg(e?.response?.data?.error ?? (action === 'install' ? 'Не удалось запустить установку' : 'Не удалось запустить удаление')); }
};
const working = !!pending || !!status?.job?.running;
// Прогресс рядом с кнопкой: для установки — пока Prometheus нет (там же виден итог удаления), для удаления — только удаление
const progressFor = (action: 'install' | 'remove' | 'any') => {
const j = status?.job;
const mine = !!j && (action === 'any' || j.action === action) && (j.running || j.exit !== null);
const starting = !!pending && (action === 'any' || action === pending);
return mine || starting ? <JobProgress job={mine ? j! : null} starting={starting} /> : null;
};
// Полный лог — только при ошибке: оператору нужна причина, а не поток строк apt
const failureLog = () => {
const j = status?.job;
if (!j || j.exit === null || j.exit === 0) return null;
return (
<>
<pre className="mon-log">{j.log.trim()}</pre>
<p className="login-error">
{j.action === 'install' ? 'Установка' : 'Удаление'} не удалась. Если дело в правах или пакетах, выполните вручную: <b>{`awg-ctrl monitor ${j.action}`}</b>
</p>
</>
);
};
// ───────── установка ─────────
const setup = status && !status.ready && (
<section className="mon-card" aria-labelledby="mon-setup">
<div className="mon-head">
<h3 className="mon-title" id="mon-setup">Мониторинг</h3>
<span className="chip chip--offline"><span className="chip-dot" />не подключён</span>
</div>
<p className="mon-hint">
Панель поставит Prometheus на этот сервер и сама подключит его к своим метрикам: основные графики —
скорость, устройства онлайн, нагрузка на канал, — появятся здесь через минуту-две. Prometheus слушает
только 127.0.0.1, наружу он не открывается.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--primary" onClick={() => run('install')} disabled={working}>
{pending === 'install' || (status.job?.running && status.job.action === 'install') ? 'Устанавливаем…' : 'Подключить Prometheus'}
</button>
{progressFor('any')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root (у этой панели нет своего awg-ctrl, который мог бы сделать это сам):</p>
<code className="mon-code">{status.command}</code>
<div className="mon-actions">
<button className="btn btn--tonal" onClick={() => copyText(status.command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
</div>
</>
)}
{failureLog()}
</section>
);
// Удаление: только пока Prometheus есть
const manage = status?.ready && (
<section className="mon-card" aria-labelledby="mon-manage">
<div className="mon-head">
<h3 className="mon-title" id="mon-manage">Prometheus</h3>
<span className="chip chip--online"><span className="chip-dot" />работает</span>
</div>
<p className="mon-hint">
Стоит на этом сервере и слушает только 127.0.0.1:9090, графики хранит 15 дней. Удаление остановит его, снимет
пакет и сотрёт накопленные данные.
</p>
{status.can_install ? (
<div className="mon-actions">
<button className="btn btn--danger" onClick={() => run('remove')} disabled={working}>
<IcoTrash /> {pending === 'remove' || (status.job?.running && status.job.action === 'remove') ? 'Удаляем…' : 'Удалить Prometheus'}
</button>
{progressFor('remove')}
</div>
) : (
<>
<p className="mon-hint">Выполните на сервере от root:</p>
<code className="mon-code">awg-ctrl monitor remove</code>
</>
)}
{status.job?.action === 'remove' && failureLog()}
</section>
);
// ───────── дашборд ─────────
// Вкладка показывает один сервер — выбранный в сервер-баре; при смене сервера App монтирует её заново.
const one = ov?.servers[0];
const data = ov?.total ?? null;
const cur = data && {
down: lastOf(data.down), up: lastOf(data.up), online: lastOf(data.online), heavy: lastOf(data.heavy),
busy: !!one?.congested,
};
// Одна единица на всю ось (Мбит/с, а не «50 Мбит» у каждой риски): выбирается по максимуму ряда
const peak = data ? Math.max(0, ...data.down.map(p => p[1]), ...data.up.map(p => p[1])) : 0;
const unit = peak >= 1e9 ? { name: 'Гбит/с', div: 1e9 } : peak >= 1e6 ? { name: 'Мбит/с', div: 1e6 } : peak >= 1e3 ? { name: 'Кбит/с', div: 1e3 } : { name: 'бит/с', div: 1 };
const dashboard = status?.ready && (
<>
{/* Период — единственный фильтр: он задаёт окно для всего ниже */}
<div className="mon-filters" role="group" aria-label="Период">
{RANGES.map(([id, label]) => (
<button key={id} type="button" className={`mon-range${range === id ? ' mon-range--on' : ''}`}
aria-pressed={range === id} onClick={() => setRange(id)}>{label}</button>
))}
</div>
{ovErr && <p className="login-error">{ovErr}</p>}
{ov && !one?.ok && (
<p className="mon-hint mon-notice">Этот сервер сейчас не отдаёт метрики: графики могут быть неполными или пустыми.</p>
)}
<div className="mon-tiles">
<div className="mon-tile">
<span className="mon-tile-label">Загрузка сейчас</span>
<span className="mon-tile-value">{cur?.down != null ? rate(cur.down) : '—'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Отдача сейчас</span>
<span className="mon-tile-value">{cur?.up != null ? rate(cur.up) : '—'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Устройств онлайн</span>
<span className="mon-tile-value">{cur?.online != null ? nf(cur.online, 0) : '—'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">В фоне</span>
<span className="mon-tile-value">{cur?.heavy != null ? nf(cur.heavy, 0) : '—'}</span>
<span className="mon-tile-sub">{cur?.busy ? 'канал занят' : 'канал свободен'}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Процессор</span>
<span className="mon-tile-value">{one?.cpu != null ? `${nf(one.cpu, 0)} %` : '—'}</span>
<Bar v={one?.cpu} label="Загрузка процессора" />
<span className="mon-tile-sub">{one?.cores ? `${nf(one.cores, 0)} ядер` : ''}</span>
</div>
<div className="mon-tile">
<span className="mon-tile-label">Память</span>
<span className="mon-tile-value">{one?.mem != null ? `${nf(one.mem, 0)} %` : '—'}</span>
<Bar v={one?.mem} label="Занято оперативной памяти" />
<span className="mon-tile-sub">{one?.mem_total ? `из ${gb(one.mem_total)}` : ''}</span>
</div>
</div>
{ov && data && (
<div className="mon-charts">
<LineChart title={`Трафик, ${unit.name}`} from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => rate(v)} formatAxis={v => nf(v / unit.div, unit.div > 1 && v / unit.div < 10 ? 1 : 0)}
series={[
{ id: 'down', name: 'загрузка', color: 'var(--series-1)', points: data.down },
{ id: 'up', name: 'отдача', color: 'var(--series-2)', points: data.up },
]} />
<LineChart title="Процессор и память, %" from={ov.from} to={ov.to} step={ov.step} dim={loading} max={100}
format={v => `${nf(v, 0)} %`}
series={[
{ id: 'cpu', name: 'процессор', color: 'var(--series-1)', points: data.cpu },
{ id: 'mem', name: 'память', color: 'var(--series-2)', points: data.mem },
]} />
<LineChart title="Устройства" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 0)}
series={[
{ id: 'online', name: 'онлайн', color: 'var(--series-1)', points: data.online },
{ id: 'heavy', name: 'в фоне', color: 'var(--series-2)', points: data.heavy },
]} />
<LineChart title="Отброшено очередью, пакетов/с" from={ov.from} to={ov.to} step={ov.step} dim={loading}
format={v => nf(v, 1)}
series={[{ id: 'drops', name: 'отброшено', color: 'var(--series-1)', points: data.drops }]} />
</div>
)}
</>
);
return (
<>
{server && (
<p className="mon-server">
<span className="mon-server-label">Сервер</span>
<b>{server.health?.server || server.name}</b>
<span className={`chip chip--${server.online ? 'online' : 'offline'}`}><span className="chip-dot" />{server.online ? 'на связи' : 'нет связи'}</span>
</p>
)}
{!status && <p className="mon-hint">Загрузка…</p>}
{setup}
{dashboard}
{manage}
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
{
"id": "monitor",
"label": "Монитор",
"icon": "pulse",
"group": "server",
"order": 1.7,
"chrome": {
"serverBar": false,
"restart": false
}
}
+142
View File
@@ -0,0 +1,142 @@
/*
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.
*/
/* Стили вкладки «Монитор». Общая дизайн-система — в App.css. */
.mon-card {
display: flex; flex-direction: column; gap: 12px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 20px;
margin-bottom: 16px;
}
.mon-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.mon-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.mon-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.mon-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mon-code {
display: block; word-break: break-all; user-select: all;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
}
@media (max-width: 640px) {
.mon-actions .btn { flex: 1; }
}
/* ───── Графики ─────
Цвета рядов — токены темы. Проверено validate_palette: светлая #6a45b8 / #c4620a на #fdfaf6,
тёмная #8f6be0 / #d9772a на #2b251d (CVD ΔE ≥ 19, контраст ≥ 3:1). Подписи и значения цветом ряда не красим. */
:root { --series-1: #6a45b8; --series-2: #c4620a; }
:root[data-theme="dark"] { --series-1: #8f6be0; --series-2: #d9772a; }
.mon-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.mon-range {
min-height: 44px; padding: 0 18px;
font: inherit; font-size: 14px; color: var(--on-surface);
background: var(--surface-bright);
border: 1px solid var(--outline); border-radius: var(--r-pill);
cursor: pointer;
}
.mon-range:hover { background: var(--state-primary); }
.mon-range--on { background: var(--primary-container); color: var(--on-primary-container); border-color: transparent; font-weight: 500; }
.mon-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); gap: 12px; margin-bottom: 16px; }
.mon-tile {
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-table);
padding: 14px 16px;
}
.mon-tile-label { font-size: 12px; color: var(--neutral-text); }
.mon-tile-value { font-size: 20px; font-weight: 500; color: var(--on-surface); }
.mon-tile-sub { font-size: 12px; color: var(--neutral-text); }
.mon-charts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-bottom: 16px; }
@media (min-width: 1000px) {
.mon-charts { grid-template-columns: 1fr 1fr; }
.mon-charts .mon-chart:first-child { grid-column: 1 / -1; }
}
.mon-chart {
margin: 0; min-width: 0;
background: var(--surface-bright);
border: 1px solid var(--outline-variant); border-radius: var(--r-card);
padding: 16px;
transition: opacity .15s;
}
.mon-chart--dim { opacity: .6; }
.mon-chart-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 4px; }
.mon-chart-title { font-size: 15px; font-weight: 500; color: var(--on-surface); }
.mon-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--neutral-text); }
.mon-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.mon-key { display: inline-block; width: 14px; height: 3px; border-radius: 2px; flex: none; }
.mon-chart-toggle {
margin-left: auto; min-height: 44px; padding: 0 12px;
font: inherit; font-size: 13px; color: var(--on-primary-container);
background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
}
.mon-chart-toggle:hover { background: var(--state-primary); }
.mon-plot { position: relative; }
.mon-plot svg { display: block; max-width: 100%; }
.mon-plot svg:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 8px; }
.mon-grid { stroke: var(--outline-variant); stroke-width: 1; }
.mon-tick { fill: var(--neutral-text); font-size: 12px; font-family: var(--font); }
.mon-cross { stroke: var(--outline); stroke-width: 1; }
.mon-end { stroke: var(--surface-bright); stroke-width: 2px; }
.mon-empty { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 0 24px; text-align: center; font-size: 13px; color: var(--neutral-text); }
.mon-tip {
position: absolute; pointer-events: none; z-index: 2;
display: flex; flex-direction: column; gap: 4px;
background: var(--surface-container);
border: 1px solid var(--outline-variant); border-radius: 12px;
padding: 8px 10px; font-size: 12px; color: var(--neutral-text);
}
.mon-tip-time { color: var(--neutral-text); }
.mon-tip-row { display: flex; align-items: center; gap: 8px; }
.mon-tip-row b { font-weight: 500; font-size: 13px; color: var(--on-surface); }
.mon-table-wrap { overflow-x: auto; }
.mon-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.mon-table th { text-align: left; font-weight: 500; color: var(--label); padding: 8px 10px; white-space: nowrap; }
.mon-table td { padding: 8px 10px; color: var(--on-surface); border-top: .5px solid var(--outline-variant); font-variant-numeric: tabular-nums; white-space: nowrap; }
.mon-log {
margin: 0; max-height: 240px; overflow: auto;
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
background: var(--surface-container); border-radius: var(--r-table);
padding: 12px; white-space: pre-wrap; word-break: break-word;
}
.mon-notice { margin-bottom: 12px; }
/* Полоски процессора и памяти в плитках */
.mon-bar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); margin: 2px 0; }
.mon-bar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .3s; }
.mon-bar i.mon-bar--warn { background: var(--warn-dot); }
.mon-bar i.mon-bar--bad { background: var(--error-dot); }
/* Какой сервер показан (выбирается в других вкладках) */
.mon-server { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 12px; font-size: 16px; color: var(--on-surface); }
.mon-server b { font-weight: 500; }
.mon-server-label { font-size: 12px; color: var(--neutral-text); }
/* Прогресс операции рядом с кнопкой */
.mon-actions { align-items: center; }
.mon-progress { flex: 1; min-width: 200px; display: flex; flex-direction: column; gap: 6px; }
.mon-progress-label { font-size: 12px; color: var(--neutral-text); }
.mon-pbar { display: block; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; background: var(--primary-container); }
.mon-pbar i { display: block; height: 100%; border-radius: 4px; background: var(--series-1); transition: width .4s ease; }
.mon-pbar--ok i { background: var(--success-dot); }
.mon-pbar--bad i { background: var(--error-dot); }
/* Шага ещё нет — ход неизвестен: бегущий отрезок вместо ложных процентов */
.mon-pbar--wait i { width: 35% !important; animation: mon-wait 1.2s ease-in-out infinite; }
@keyframes mon-wait { 0% { transform: translateX(-100%); } 100% { transform: translateX(290%); } }
@media (prefers-reduced-motion: reduce) { .mon-pbar--wait i { animation: none; } .mon-pbar i { transition: none; } }
+6 -2
View File
@@ -8,6 +8,7 @@ import {
type User, type PageProps, type AwgGen, type User, type PageProps, type AwgGen,
} from '../../lib/shared'; } from '../../lib/shared';
import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons'; import { IcoPlus, IcoRefresh, IcoQR, IcoTrash, IcoGlobe } from '../../components/icons';
import PriorityChip from '../../components/PriorityChip';
import './users.css'; import './users.css';
// Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный) // Вкладка «Пользователи»: форма создания + таблица (десктоп) / карточки (мобильный)
@@ -28,7 +29,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
const data = await apiFetch('GET', '/api/users/stats', tok); const data = await apiFetch('GET', '/api/users/stats', tok);
setUsers(prev => prev.map(u => { setUsers(prev => prev.map(u => {
const s = (data.users as User[] ?? []).find(x => x.name === u.name); const s = (data.users as User[] ?? []).find(x => x.name === u.name);
return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx } : u; return s ? { ...u, online: s.online, lastHandshake: s.lastHandshake, rx: s.rx, tx: s.tx, priority: s.priority } : u;
})); }));
} catch { /* silent */ } } catch { /* silent */ }
}, []); }, []);
@@ -162,6 +163,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<tr> <tr>
<th>#</th> <th>#</th>
<th>Статус</th> <th>Статус</th>
<th>Приоритет</th>
<th>Имя</th> <th>Имя</th>
<th>IP</th> <th>IP</th>
<th>↓ rx</th> <th>↓ rx</th>
@@ -171,7 +173,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
</thead> </thead>
<tbody> <tbody>
{users.length === 0 ? ( {users.length === 0 ? (
<tr><td colSpan={7} className="empty">Нет пользователей</td></tr> <tr><td colSpan={8} className="empty">Нет пользователей</td></tr>
) : users.map((u, i) => ( ) : users.map((u, i) => (
<tr key={u.name}> <tr key={u.name}>
<td className="td-num">{i + 1}</td> <td className="td-num">{i + 1}</td>
@@ -185,6 +187,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
: 'никогда'} : 'никогда'}
</span> </span>
</td> </td>
<td><PriorityChip priority={u.priority} /></td>
<td> <td>
{u.name} {u.name}
{u.key_gen !== serverGen && ( {u.key_gen !== serverGen && (
@@ -251,6 +254,7 @@ export default function UsersPage({ token, showMsg }: PageProps) {
<div className="user-card-ip"> <div className="user-card-ip">
<IcoGlobe /> <IcoGlobe />
<span className="user-card-ip-text">{u.ip}</span> <span className="user-card-ip-text">{u.ip}</span>
<PriorityChip priority={u.priority} />
</div> </div>
<div className="user-card-metrics"> <div className="user-card-metrics">
<div className="metric-block"> <div className="metric-block">
+1
View File
@@ -2,5 +2,6 @@
"id": "users", "id": "users",
"label": "Пользователи", "label": "Пользователи",
"icon": "users", "icon": "users",
"group": "main",
"order": 1 "order": 1
} }
+201 -3
View File
@@ -72,6 +72,8 @@ const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3; const DEFAULT_LIMIT = 3;
const MAX_SERVERS = 32; // серверов в одном мастер-ключе const MAX_SERVERS = 32; // серверов в одном мастер-ключе
const SYNC_EVERY = 60_000; // фоновое сведение серверов const SYNC_EVERY = 60_000; // фоновое сведение серверов
const AUDIT_HOUR = 3_600_000;
const AUDIT_FIRST = 120_000; // первый аудит — вскоре после старта, когда ноды подключились
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/; const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
@@ -407,6 +409,161 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
q.revokedGone.run(); q.revokedGone.run();
} }
// ── Аудит серверов ─────────────────────────────────────────────────────
// Периодически (по умолчанию раз в час) core обходит все серверы и ищет «мёртвых»: пиров, которые живы на сервере, хотя
// панель про них не знает. Сообщает и — если включена очистка (настройки аудита) или оператор нажал кнопку — снимает подтверждённых: мёртвых два прохода подряд.
// ghosts — есть в ядре (awg show), но неизвестны awg-ctrl: строку удалили, а `awg set … remove`
// не сработал, и никто про такого пира уже не вспомнит;
// orphans — есть у awg-ctrl в области этой панели, но панели не нужны (sync их не снял);
// key — мастер-ключ пира, если он отозван или удалён.
// Пиры чужой области (другая панель, пиры до областей) в отчёт не попадают — считаются в foreign.
// seen_before — «мёртвый» и в прошлом проходе тоже: только такие снимает очистка (пир, которого
// прямо сейчас добавляют или удаляют, за два прохода подряд мёртвым не окажется).
interface AuditPeer { pub_key: string; owner?: string; key?: string | null; online: boolean; lastHandshake: number; seen_before?: boolean }
interface AuditEntry {
server_id: number; status: "ok" | "offline" | "error";
ghosts_checked: boolean; // false — awg-ctrl старше /api/audit, «призраков» не видно
ghosts: AuditPeer[]; orphans: AuditPeer[]; foreign: number;
blocked?: number; // очистка отказалась снимать столько пиров разом (предохранитель)
}
interface AuditReport { at: number; servers: AuditEntry[] }
interface CleanedPeer { at: number; server_id: number; pub_key: string; kind: "ghost" | "orphan"; key?: string | null; auto: boolean }
// Настройки аудита (панель, вкладка «Аудит»). clean — снимать «мёртвых» сами; every_h — период;
// max — предохранитель: больше пиров на сервере за проход не снимаем, только сообщаем.
interface AuditSettings { clean: boolean; every_h: number; max: number }
const AUDIT_DEFAULTS: AuditSettings = { clean: false, every_h: 1, max: 10 };
const AUDIT_ON = (process.env.AUDIT ?? "on").toLowerCase() !== "off"; // выключен у ноды со своей панелью
const metaGet = <T,>(key: string, dflt: T): T => {
try {
const row = uidb.prepare("SELECT value FROM sub_meta WHERE key = ?").get(key) as { value: string } | undefined;
return row ? JSON.parse(row.value) as T : dflt;
} catch { return dflt; }
};
const metaSet = (key: string, v: unknown) =>
uidb.prepare("INSERT INTO sub_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, JSON.stringify(v));
let lastAudit: AuditReport | null = metaGet<AuditReport | null>("audit", null);
let auditSettings: AuditSettings = { ...AUDIT_DEFAULTS, ...metaGet<Partial<AuditSettings>>("audit_settings", {}) };
let cleanedLog: CleanedPeer[] = metaGet<CleanedPeer[]>("audit_cleaned", []);
async function auditNow(id: number): Promise<AuditEntry> {
const entry: AuditEntry = { server_id: id, status: "ok", ghosts_checked: true, ghosts: [], orphans: [], foreign: 0 };
if (!servers.exists(id) || !servers.isOnline(id)) return { ...entry, status: "offline", ghosts_checked: false };
const ctrl = servers.ctrlFor(id);
// Сначала обычное сведение: отчёт — про то, что не снялось само.
await reconcileNow(id);
const [a, p] = [await ctrl("GET", "/api/audit"), await ctrl("GET", "/api/peers")];
if (!hasCtrlOk(p.status)) return { ...entry, status: "error", ghosts_checked: false };
if (hasCtrlOk(a.status)) entry.ghosts = (a.data.ghosts as AuditPeer[]).map(g => ({
pub_key: g.pub_key, online: g.online, lastHandshake: g.lastHandshake,
}));
else if (a.status === 404) entry.ghosts_checked = false;
else return { ...entry, status: "error", ghosts_checked: false };
const keep = new Set((q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub));
const prefix = panelId + "/";
for (const peer of p.data.peers as { pub_key: string; owner: string; online: boolean; lastHandshake: number }[]) {
if (!peer.owner.startsWith(prefix)) { if (peer.owner.includes("/")) entry.foreign++; continue; }
if (keep.has(peer.pub_key)) continue;
const mid = Number(peer.owner.match(/^[^/]+\/m(\d+)\//)?.[1]);
const m = Number.isSafeInteger(mid) ? q.masterRow.get(mid) as MasterRow | undefined : undefined;
entry.orphans.push({
pub_key: peer.pub_key, owner: peer.owner, online: peer.online, lastHandshake: peer.lastHandshake,
key: m ? (m.revoked_at !== null ? `${m.label} (удалён)` : m.label) : "(нет в ui.db)",
});
}
return entry;
}
// Под той же блокировкой, что и сведение: пир, которого прямо сейчас добавляет проход, не «призрак».
function audit(id: number): Promise<AuditEntry> {
const run = (locks.get(id) ?? Promise.resolve()).then(() => auditNow(id));
locks.set(id, run.then(() => {}, () => {}));
return run.catch((): AuditEntry => ({ server_id: id, status: "error", ghosts_checked: false, ghosts: [], orphans: [], foreign: 0 }));
}
// Снять «мёртвых» этого сервера: только подтверждённых (seen_before), не больше max за проход.
// «Призраки» — через awg-ctrl (он сам перепроверяет ключи), лишние — обычным DELETE.
async function cleanEntry(e: AuditEntry, auto: boolean): Promise<void> {
const ghosts = e.ghosts.filter(p => p.seen_before);
const orphans = e.orphans.filter(p => p.seen_before);
if (ghosts.length + orphans.length === 0 || e.status !== "ok") return;
if (auto && ghosts.length + orphans.length > auditSettings.max) {
e.blocked = ghosts.length + orphans.length;
console.warn(`sub: очистка сервера #${e.server_id} пропущена: ${e.blocked} пиров больше предела ${auditSettings.max}`);
return;
}
const ctrl = servers.ctrlFor(e.server_id);
const done: CleanedPeer[] = [];
const log = (p: AuditPeer, kind: CleanedPeer["kind"]) =>
done.push({ at: now(), server_id: e.server_id, pub_key: p.pub_key, kind, key: p.key, auto });
if (ghosts.length) {
const r = await ctrl("POST", "/api/audit/clean", { pub_keys: ghosts.map(g => g.pub_key) });
if (hasCtrlOk(r.status)) {
const gone = new Set(r.data.removed as string[]);
for (const g of ghosts) if (gone.has(g.pub_key)) log(g, "ghost");
}
}
for (const p of orphans) {
const r = await ctrl("DELETE", `/api/peers/${pubParam(p.pub_key)}`);
if (hasCtrlOk(r.status) || r.status === 404) log(p, "orphan");
else if (gone502(r.status)) break;
}
if (!done.length) return;
const cleaned = new Set(done.map(d => d.pub_key));
e.ghosts = e.ghosts.filter(p => !cleaned.has(p.pub_key));
e.orphans = e.orphans.filter(p => !cleaned.has(p.pub_key));
cleanedLog = [...done, ...cleanedLog].slice(0, 100);
metaSet("audit_cleaned", cleanedLog);
console.warn(`sub: ${auto ? "автоочистка" : "очистка"} сервера #${e.server_id}: снято ${done.length} (${done.map(d => d.pub_key.slice(0, 8)).join(", ")})`);
}
const gone502 = (s: number) => s === 502 || s === 504;
let auditing: Promise<AuditReport> | null = null;
// auto — плановый проход: очищает сам, если включено. Ручной только смотрит и обновляет «видели раньше».
function auditAll(auto = false): Promise<AuditReport> {
auditing ??= (async () => {
const prev = lastAudit;
const report: AuditReport = { at: now(), servers: await Promise.all(servers.ids().map(audit)) };
for (const e of report.servers) {
const before = prev?.servers.find(x => x.server_id === e.server_id);
const was = new Set([...(before?.ghosts ?? []), ...(before?.orphans ?? [])].map(p => p.pub_key));
for (const p of [...e.ghosts, ...e.orphans]) p.seen_before = was.has(p.pub_key);
if (auto && auditSettings.clean) await audit_clean(e);
}
lastAudit = report;
metaSet("audit", report);
for (const e of report.servers) {
if (e.status === "error") console.warn(`sub: аудит сервера #${e.server_id} не удался`);
if (e.ghosts.length || e.orphans.length)
console.warn(`sub: аудит сервера #${e.server_id}: мёртвых пиров ${e.ghosts.length + e.orphans.length} ` +
`(в ядре без записи: ${e.ghosts.length}, лишних у awg-ctrl: ${e.orphans.length}, онлайн: ` +
`${[...e.ghosts, ...e.orphans].filter(x => x.online).length})`);
}
return report;
})().finally(() => { auditing = null; });
return auditing;
}
// Очистка под блокировкой сервера — как и сам аудит.
function audit_clean(e: AuditEntry): Promise<void> {
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, true));
locks.set(e.server_id, run.then(() => {}, () => {}));
return run.catch(err => { console.warn(`sub: очистка сервера #${e.server_id} не удалась: ${(err as Error).message}`); });
}
// Ручная очистка подтверждённых «мёртвых» по последнему отчёту (кнопка на вкладке «Аудит»).
async function cleanNow(): Promise<AuditReport | null> {
if (!lastAudit) return null;
for (const e of lastAudit.servers) {
const run = (locks.get(e.server_id) ?? Promise.resolve()).then(() => cleanEntry(e, false));
locks.set(e.server_id, run.then(() => {}, () => {}));
await run.catch(() => {});
}
metaSet("audit", lastAudit);
return lastAudit;
}
const auditState = () => ({ enabled: AUDIT_ON, settings: auditSettings, report: lastAudit, cleaned: cleanedLog });
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */ /** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
function forgetDevice(dev: DeviceRow): number[] { function forgetDevice(dev: DeviceRow): number[] {
const links = q.linksOf.all(dev.id) as LinkRow[]; const links = q.linksOf.all(dev.id) as LinkRow[];
@@ -834,15 +991,17 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть // Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ. // на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
async function masterDevices(m: MasterRow) { async function masterDevices(m: MasterRow) {
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>(); const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number; background: boolean }>();
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => { await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers"); const r = await servers.ctrlFor(id)("GET", "/api/peers");
if (!hasCtrlOk(r.status)) return; if (!hasCtrlOk(r.status)) return;
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) { for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number; priority?: string }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 }; const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0, background: false };
stats.set(p.pub_key, { stats.set(p.pub_key, {
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake), online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
rx: s.rx + p.rx, tx: s.tx + p.tx, rx: s.rx + p.rx, tx: s.tx + p.tx,
// Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них.
background: s.background || p.priority === "background",
}); });
} }
})); }));
@@ -853,6 +1012,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at, id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1, last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0, online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
priority: s?.background ? "background" : "normal",
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length, servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
}; };
}); });
@@ -876,6 +1036,35 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
masterKeys.get("/", (_req, res) => { masterKeys.get("/", (_req, res) => {
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) }); res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
}); });
// Аудит серверов (только core): отчёт, внеочередной проход, ручная очистка, настройки.
masterKeys.get("/audit", (_req, res) => { res.json(auditState()); });
masterKeys.post("/audit", aw(async (_req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
await auditAll();
res.json(auditState());
}));
masterKeys.post("/audit/clean", aw(async (_req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
await cleanNow();
res.json(auditState());
}));
masterKeys.put("/audit/settings", aw((req, res) => {
if (!AUDIT_ON) fail(409, "Аудит выключен на этой панели");
const b = (req.body ?? {}) as Partial<AuditSettings>;
const next = { ...auditSettings };
if (b.clean !== undefined) { if (typeof b.clean !== "boolean") fail(400, "clean: true или false"); next.clean = b.clean; }
if (b.every_h !== undefined) {
if (!Number.isInteger(b.every_h) || b.every_h < 1 || b.every_h > 168) fail(400, "Период: от 1 до 168 часов");
next.every_h = b.every_h;
}
if (b.max !== undefined) {
if (!Number.isInteger(b.max) || b.max < 1 || b.max > 1000) fail(400, "Предел: от 1 до 1000");
next.max = b.max;
}
auditSettings = next;
metaSet("audit_settings", next);
res.json(auditState());
}));
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); })); masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); })); masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); })); masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
@@ -955,6 +1144,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id)); void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
}, SYNC_EVERY).unref(); }, SYNC_EVERY).unref();
if (AUDIT_ON) {
// Период читается из настроек каждый раз: смена в панели действует без рестарта.
const next = (ms: number) => setTimeout(() => {
auditAll(true).catch(e => console.warn(`sub: аудит не удался: ${(e as Error).message}`))
.finally(() => next(auditSettings.every_h * AUDIT_HOUR));
}, ms).unref();
next(AUDIT_FIRST);
}
if (!signKey) return; if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; } if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub); const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
+7 -1
View File
@@ -9,6 +9,7 @@ import * as readline from "readline";
import * as crypto from "crypto"; import * as crypto from "crypto";
import * as http from "http"; import * as http from "http";
import path from "path"; import path from "path";
import { monitorCmd } from "./monitor";
const ROOT = path.resolve(__dirname, "../.."); const ROOT = path.resolve(__dirname, "../..");
const LOGS = path.join(ROOT, "logs"); const LOGS = path.join(ROOT, "logs");
@@ -107,6 +108,10 @@ const SERVICES = {
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off — // join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен. // core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "", NODE_PORT: process.env.NODE_PORT ?? "",
// Часовой аудит «мёртвых» пиров на всех серверах — только у core (у ноды со своей панелью выключен).
AUDIT: process.env.CORE_HOST ? "off" : (process.env.AUDIT ?? "on"),
// Пер-пировые серии в /metrics: только по явному согласию оператора (cli.env).
METRICS_PEERS: process.env.METRICS_PEERS ?? "off",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on", 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_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", NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
@@ -640,7 +645,7 @@ async function main() {
return; return;
} }
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>"; const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>";
const [,,, sub, arg] = process.argv; const [,,, sub, arg] = process.argv;
@@ -657,6 +662,7 @@ async function main() {
case "join": await joinCore(sub, arg); break; case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break; case "unjoin": await unjoinCore(); break;
case "peers": await peersCmd(sub, arg); break; case "peers": await peersCmd(sub, arg); break;
case "monitor": await monitorCmd(sub); break;
default: console.log(USAGE); process.exit(1); default: console.log(USAGE); process.exit(1);
} }
} }
+170
View File
@@ -0,0 +1,170 @@
// 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.
//
// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор».
// Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не ставит, она только просит
// (через awg-ctrl) или показывает эту команду. Всё слушает 127.0.0.1: наружу Prometheus не торчит,
// а панель читает его по HTTP на той же машине.
import { spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync, copyFileSync, unlinkSync } from "fs";
import * as crypto from "crypto";
import * as http from "http";
const AWG_DIR = "/etc/amnezia/amneziawg";
const PROM_DIR = "/etc/prometheus";
const CONF_FILE = `${PROM_DIR}/prometheus.yml`;
const TOKEN_FILE = `${PROM_DIR}/forgetting.token`;
// Хэш локального токена для awg-ui: читает его при проверке /metrics (сам токен знает только Prometheus).
export const MONITOR_HASH_FILE = `${AWG_DIR}/monitor_token.sha256`;
const DEFAULTS_FILE = "/etc/default/prometheus";
const MARKER = "# managed by forgetting";
const LISTEN = "127.0.0.1:9090";
const RETENTION = "15d";
const log = (s: string) => console.log(` ${s}`);
function fail(s: string): never { console.error(`\n ✗ ${s}\n`); process.exit(1); }
function sh(cmd: string, args: string[], opts: { quiet?: boolean } = {}) {
return spawnSync(cmd, args, {
encoding: "utf8",
stdio: opts.quiet ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
env: { ...process.env, DEBIAN_FRONTEND: "noninteractive" },
});
}
function osFamily(): string {
try {
const t = readFileSync("/etc/os-release", "utf8");
const id = t.match(/^ID=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
const like = t.match(/^ID_LIKE=(.*)$/m)?.[1]?.replace(/"/g, "") ?? "";
return `${id} ${like}`;
} catch { return ""; }
}
function ready(timeoutMs = 1500): Promise<boolean> {
return new Promise(resolve => {
const [host, port] = LISTEN.split(":");
const req = http.get({ host, port: Number(port), path: "/-/ready", timeout: timeoutMs }, res => {
res.resume(); resolve(res.statusCode === 200);
});
req.on("timeout", () => { req.destroy(); resolve(false); });
req.on("error", () => resolve(false));
});
}
const installed = () => sh("dpkg", ["-s", "prometheus"], { quiet: true }).status === 0;
const active = () => sh("systemctl", ["is-active", "--quiet", "prometheus"], { quiet: true }).status === 0;
const managed = () => existsSync(CONF_FILE) && readFileSync(CONF_FILE, "utf8").includes(MARKER);
// Новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран.
function authBlock(): string {
const v = sh("prometheus", ["--version"], { quiet: true });
const m = `${v.stdout ?? ""}${v.stderr ?? ""}`.match(/version (\d+)\.(\d+)/);
const [maj, min] = m ? [Number(m[1]), Number(m[2])] : [2, 0];
return maj > 2 || (maj === 2 && min >= 26)
? ` authorization:\n credentials_file: ${TOKEN_FILE}\n`
: ` bearer_token_file: ${TOKEN_FILE}\n`;
}
function writeConfig(uiPort: string) {
if (existsSync(CONF_FILE)) copyFileSync(CONF_FILE, `${CONF_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
writeFileSync(CONF_FILE, `${MARKER}
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: forgetting
scheme: http
metrics_path: /metrics
${authBlock()} static_configs:
- targets: ['127.0.0.1:${uiPort}']
`, { mode: 0o644 });
}
// Токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш.
function writeToken() {
const token = "awgm_" + crypto.randomBytes(24).toString("base64url");
writeFileSync(TOKEN_FILE, token, { mode: 0o640 });
sh("chown", ["root:prometheus", TOKEN_FILE], { quiet: true });
writeFileSync(MONITOR_HASH_FILE, crypto.createHash("sha256").update(token).digest("hex") + "\n", { mode: 0o644 });
}
function setArgs() {
const line = `ARGS="--web.listen-address=${LISTEN} --storage.tsdb.retention.time=${RETENTION}"`;
let text = existsSync(DEFAULTS_FILE) ? readFileSync(DEFAULTS_FILE, "utf8") : "";
if (text) copyFileSync(DEFAULTS_FILE, `${DEFAULTS_FILE}.bak-${new Date().toISOString().replace(/[:.]/g, "-")}`);
text = /^ARGS=/m.test(text) ? text.replace(/^ARGS=.*$/m, line) : `${text.replace(/\n?$/, "\n")}${line}\n`;
writeFileSync(DEFAULTS_FILE, text, { mode: 0o644 });
}
async function install() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor install");
if (!/debian|ubuntu/i.test(osFamily())) fail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).");
const uiPort = process.env.UI_PORT;
if (!uiPort) fail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.");
// Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus уже установлен и настроен не нами (${CONF_FILE}). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.`);
}
log("1/4 установка пакета prometheus");
if (!installed()) {
if (sh("apt-get", ["update", "-qq"]).status !== 0) fail("apt-get update завершился ошибкой");
if (sh("apt-get", ["install", "-y", "--no-install-recommends", "prometheus"]).status !== 0) fail("apt-get install prometheus завершился ошибкой");
} else log("уже установлен");
log("2/4 токен и конфиг");
writeToken();
writeConfig(uiPort);
log(`3/4 слушаем только ${LISTEN}, хранение ${RETENTION}`);
setArgs();
log("4/4 запуск");
sh("systemctl", ["enable", "prometheus"], { quiet: true });
if (sh("systemctl", ["restart", "prometheus"]).status !== 0) fail("prometheus не запустился: journalctl -u prometheus");
for (let i = 0; i < 30; i++) {
if (await ready()) { console.log(`\n ✓ Prometheus работает на ${LISTEN}, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n`); return; }
await new Promise(r => setTimeout(r, 1000));
}
fail("Prometheus не ответил за 30 с: journalctl -u prometheus");
}
async function status() {
const ok = await ready();
console.log(` пакет: ${installed() ? "установлен" : "нет"}`);
console.log(` сервис: ${active() ? "работает" : "остановлен"}`);
console.log(` наш конфиг: ${managed() ? "да" : "нет"}`);
console.log(` готов: ${ok ? `да, ${LISTEN}` : "нет"}`);
}
function remove() {
if (process.getuid?.() !== 0) fail("Нужен root: sudo awg-ctrl monitor remove");
// Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили.
if (installed() && existsSync(CONF_FILE) && !managed()) {
fail(`Prometheus настроен не нами (${CONF_FILE}), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus`);
}
log("1/3 остановка сервиса");
sh("systemctl", ["disable", "--now", "prometheus"], { quiet: true });
log("2/3 токен");
for (const f of [TOKEN_FILE, MONITOR_HASH_FILE]) { try { unlinkSync(f); } catch { /* уже нет */ } }
log("3/3 удаление пакета и накопленных данных");
if (installed() && sh("apt-get", ["purge", "-y", "prometheus"]).status !== 0) fail("apt-get purge prometheus завершился ошибкой");
console.log("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n");
}
export async function monitorCmd(sub?: string) {
switch (sub) {
case "install": await install(); break;
case "status": await status(); break;
case "remove": remove(); break;
default: console.log("Usage: awg-ctrl monitor <install|status|remove>"); process.exit(1);
}
}
+31
View File
@@ -0,0 +1,31 @@
// awg-control — единый бинарник Forgetting: роли ctrl / ui / agent и команды оператора.
package main
import (
"fmt"
"os"
"github.com/maeneko/forgetting/internal/ctrl"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: awg-control <ctrl|ui|agent|start|stop|restart|status|…>")
os.Exit(1)
}
switch os.Args[1] {
case "ctrl":
s, err := ctrl.New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := s.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1])
os.Exit(1)
}
}
+17
View File
@@ -0,0 +1,17 @@
module github.com/maeneko/forgetting
go 1.26.0
require (
github.com/coder/websocket v1.8.15 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.48.0 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+455
View File
@@ -0,0 +1,455 @@
package ctrl
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)
// ───────── запуск команд ─────────
type cmdResult struct {
Stdout, Stderr string
Code int // -1: не запустилась
}
func (r cmdResult) ok() bool { return r.Code == 0 }
func runCmdIn(stdin string, name string, args ...string) cmdResult {
cmd := exec.Command(name, args...)
var so, se bytes.Buffer
cmd.Stdout, cmd.Stderr = &so, &se
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
err := cmd.Run()
res := cmdResult{Stdout: so.String(), Stderr: se.String()}
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
res.Code = ee.ExitCode()
} else {
res.Code = -1
}
}
return res
}
func runCmd(name string, args ...string) cmdResult { return runCmdIn("", name, args...) }
// runShell — аналог execSync(cmd).trim(): через sh, ошибка при ненулевом коде.
func runShell(cmd string) (string, error) {
r := runCmdIn("", "/bin/sh", "-c", cmd)
if !r.ok() {
return "", fmt.Errorf("Command failed: %s\n%s", cmd, r.Stderr)
}
return strings.TrimSpace(r.Stdout), nil
}
func runCtx(timeout time.Duration, name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var so, se bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdout, cmd.Stderr = &so, &se
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s %s: %s", name, strings.Join(args, " "), msg)
}
return so.String(), nil
}
// jsNumber — Number(s): пустая строка = 0, нечисло = NaN.
func jsNumber(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return math.NaN()
}
return f
}
// jsonStamp — new Date().toISOString().replace(/[:.]/g, "-").
func jsonStamp(t time.Time) string {
t = t.UTC()
return t.Format("2006-01-02T15-04-05") + fmt.Sprintf("-%03dZ", t.Nanosecond()/1e6)
}
// ───────── ключи ─────────
func (s *Server) generateKeys() (Keys, error) {
priv, err := runShell("umask 077 && awg genkey")
if err != nil {
return Keys{}, err
}
r := runCmdIn(priv, "awg", "pubkey")
if !r.ok() {
return Keys{}, errors.New("awg pubkey завершился с ошибкой")
}
psk, err := runShell("awg genpsk")
if err != nil {
return Keys{}, err
}
return Keys{Private: priv, Public: strings.TrimSpace(r.Stdout), PSK: psk}, nil
}
func (s *Server) genPSK() (string, error) { return runShell("awg genpsk") }
func (s *Server) getServerPublicKey() (string, error) {
b, err := os.ReadFile(filepath.Join(s.confDir, "server_public.key"))
if err != nil {
return "", errors.New("server_public.key не найден")
}
return strings.TrimSpace(string(b)), nil
}
// ───────── интерфейс ─────────
type peerStat struct {
Online bool
LastHandshake float64
Rx, Tx float64
}
// getPeersData: `awg show awg1 dump`, колонки [0] pub, [4] handshake, [5] rx, [6] tx.
func (s *Server) getPeersData() map[string]peerStat {
out, err := runShell("awg show " + s.iface + " dump")
if err != nil {
s.log.Warn("getPeersData failed", "error", err)
return map[string]peerStat{}
}
res := map[string]peerStat{}
now := float64(time.Now().Unix())
lines := strings.Split(out, "\n")
for i := 1; i < len(lines); i++ {
parts := strings.Split(lines[i], "\t")
pub := parts[0]
if pub == "" {
continue
}
get := func(n int) float64 {
if n < len(parts) {
return jsNumber(parts[n])
}
return math.NaN() // Number(undefined)
}
hs := get(4)
res[pub] = peerStat{
Online: hs > 0 && (now-hs) < 180,
LastHandshake: hs, Rx: get(5), Tx: get(6),
}
}
return res
}
type ifStatus struct {
Up bool `json:"up"`
Peers int `json:"peers"`
PublicKey *string `json:"publicKey"`
}
var (
rePeerLine = regexp.MustCompile(`(?m)^peer:`)
rePubKey = regexp.MustCompile(`public key:\s*(.+)`)
)
func (s *Server) interfaceStatus() ifStatus {
r := runCmd("awg", "show", s.iface)
if !r.ok() {
return ifStatus{}
}
st := ifStatus{Up: true, Peers: len(rePeerLine.FindAllString(r.Stdout, -1))}
if m := rePubKey.FindStringSubmatch(r.Stdout); m != nil {
v := strings.TrimSpace(m[1])
st.PublicKey = &v
}
return st
}
func (s *Server) ensureInterfaceUp() error {
if !s.interfaceStatus().Up {
return fmt.Errorf("Interface %s is not up. Run: awg-quick up %s", s.iface, s.iface)
}
return nil
}
// readVersions: версия загруженного модуля (важнее версии на диске: интерфейс обслуживает
// именно загруженный) и awg-tools. Вызывается на старте и при restartAwg — только тогда
// модуль может смениться; из /health не вызывается (он без авторизации).
func readVersions() (module, tools string) {
if b, err := os.ReadFile("/sys/module/amneziawg/version"); err == nil {
module = strings.TrimSpace(string(b))
} else if v, err := runShell("modinfo -F version amneziawg"); err == nil {
module = v
}
if v, err := runShell("awg --version"); err == nil {
for _, t := range strings.Fields(v) {
if regexp.MustCompile(`^v?\d`).MatchString(t) {
tools = t
break
}
}
}
return
}
// ───────── awg1.conf ─────────
func (s *Server) confPath() string { return filepath.Join(s.confDir, s.iface+".conf") }
func trimEndJS(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) }
// rebuildConf пересобирает [Peer]-секции из БД; [Interface] сохраняется как есть.
// Пиры устройств идут после users; без записей в peers вывод байт-в-байт прежний.
func (s *Server) rebuildConf() error {
users, err := s.db.Query("SELECT name, ip, pub_key, psk_key FROM users")
if err != nil {
return err
}
type pr struct{ label, ip, pub, psk string }
var all []pr
for users.Next() {
var n, ip, pub, psk string
if err := users.Scan(&n, &ip, &pub, &psk); err != nil {
users.Close()
return err
}
all = append(all, pr{n, ip, pub, psk})
}
users.Close()
devices, err := s.peersAll()
if err != nil {
return err
}
if _, err := os.Stat(s.confPath()); err != nil {
return nil
}
raw, err := os.ReadFile(s.confPath())
if err != nil {
return err
}
for _, d := range devices {
label := d.Owner
if label == "" {
label = "device"
}
all = append(all, pr{label, d.IP, d.PubKey, d.PskKey})
}
iface := trimEndJS(pPeerSplit.Split(string(raw), 2)[0])
ka := s.cfg().Keepalive
blocks := make([]string, 0, len(all))
for _, u := range all {
blocks = append(blocks, "\n# "+u.label+"\n[Peer]\nPublicKey = "+u.pub+"\nPresharedKey = "+u.psk+
"\nAllowedIPs = "+u.ip+"/32\nPersistentKeepalive = "+ka)
}
if err := os.WriteFile(s.confPath(), []byte(iface+"\n"+strings.Join(blocks, "\n")+"\n"), 0o600); err != nil {
return err
}
s.log.Info("conf rebuilt", "peers", len(all))
return nil
}
var reAwgQuickOnly = regexp.MustCompile(`(?i)^\s*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)\s*=`)
var rePrivateKeyLine = regexp.MustCompile(`^\s*PrivateKey\s*=`)
var reInterfaceLine = regexp.MustCompile(`^\s*\[Interface\]`)
// buildSyncConf готовит «stripped»-конфиг для `awg syncconf`: без awg-quick-ключей, но
// с [Interface] и PrivateKey.
//
// 🛑 КРИТИЧНО: [Interface] с PrivateKey ОБЯЗАН попасть в этот конфиг. Когда syncconf
// получал только [Peer]-блоки, AmneziaWG обнулял приватный ключ интерфейса и параметры
// обфускации — сервер поднимался с public-key=(none), и ВСЕ клиенты отваливались.
// PrivateKey берём из server_private.key — та же идентичность, что в server_public.key
// (его зашивают в vpn:// ключи) и в PostUp.
func (s *Server) buildSyncConf() (string, error) {
privRaw, err := os.ReadFile(s.serverPrivKeyFile)
if err != nil {
return "", err
}
priv := strings.TrimSpace(string(privRaw))
raw, err := os.ReadFile(s.confPath())
if err != nil {
return "", err
}
var out []string
replaced := false
for _, line := range strings.Split(string(raw), "\n") {
if reAwgQuickOnly.MatchString(line) {
continue
}
if rePrivateKeyLine.MatchString(line) {
out = append(out, "PrivateKey = "+priv)
replaced = true
continue
}
out = append(out, line)
}
if !replaced {
for i, l := range out {
if reInterfaceLine.MatchString(l) {
out = append(out[:i+1], append([]string{"PrivateKey = " + priv}, out[i+1:]...)...)
break
}
}
}
return strings.Join(out, "\n"), nil
}
func (s *Server) syncPeers() error {
if err := s.rebuildConf(); err != nil {
return err
}
cfg, err := s.buildSyncConf()
if err != nil {
return err
}
f, err := os.CreateTemp("", "awg_sync_*.conf") // 0600, O_EXCL, случайное имя
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(cfg); err != nil {
f.Close()
return err
}
f.Close()
if r := runCmd("awg", "syncconf", s.iface, f.Name()); !r.ok() {
s.log.Warn("syncPeers syncconf failed", "stderr", r.Stderr)
}
var n int
s.db.QueryRow("SELECT COUNT(*) AS n FROM users").Scan(&n)
s.log.Info("peers synced", "count", n)
return nil
}
// reloadAwgParams перечитывает параметры после любой правки конфига: иначе ключи
// vpn:// собирались бы на старых параметрах — например, на 2.0 уже после перехода на 3.1.
func (s *Server) reloadAwgParams() string {
p := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
s.cfgMu.Lock()
s.c.Params = p
s.c.Gen = p.Gen()
s.c.Keepalive = keepaliveByGen[s.c.Gen]
g := s.c.Gen
s.cfgMu.Unlock()
s.log.Info("awg params loaded from conf", "Jc", p.Jc, "H1", p.H1, "gen", g)
return g
}
func (s *Server) restartAwg() error {
s.log.Info("AWG restart: down")
if r := runCmd("awg-quick", "down", s.iface); !r.ok() {
s.log.Warn("awg-quick down failed", "stderr", r.Stderr)
}
s.log.Info("AWG restart: up")
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
// Конфиг мог измениться, пока интерфейс лежал (переход на 3.1, ручная правка) —
// перечитываем ДО syncPeers: keepalive из CONFIG уходит в [Peer]-блоки.
gen := s.reloadAwgParams()
if err := s.syncPeers(); err != nil {
return err
}
go s.shaping.Apply() // tc-правила жили на интерфейсе, который мы только что пересоздали
m, t := readVersions()
s.cfgMu.Lock()
s.c.AwgModule, s.c.AwgTools = m, t
s.cfgMu.Unlock()
s.log.Info("AWG restart: done", "module", m, "tools", t, "gen", gen)
return nil
}
func (s *Server) startInterface() (ifStatus, error) {
st := s.interfaceStatus()
if st.Up {
s.log.Info("AWG already up", "peers", st.Peers)
return st, nil
}
if r := runCmd("awg-quick", "up", s.iface); !r.ok() {
return ifStatus{}, fmt.Errorf("awg-quick up failed: %s", r.Stderr)
}
if err := s.syncPeers(); err != nil {
return ifStatus{}, err
}
go s.shaping.Apply()
return s.interfaceStatus(), nil
}
// setPeer: PSK уходит во временный файл, а не в аргументы — иначе он виден в /proc/<pid>/cmdline.
func (s *Server) setPeer(pub, psk, ip string) cmdResult {
f, err := os.CreateTemp("", "awg_psk_*.tmp")
if err != nil {
return cmdResult{Stderr: err.Error(), Code: -1}
}
defer os.Remove(f.Name())
f.WriteString(psk)
f.Close()
return runCmd("awg", "set", s.iface, "peer", pub,
"preshared-key", f.Name(),
"allowed-ips", ip+"/32",
"persistent-keepalive", s.cfg().Keepalive)
}
// kernelPeers — ключи пиров, стоящих в ядре (nil — не удалось спросить).
func (s *Server) kernelPeers() map[string]bool {
r := runCmd("awg", "show", s.iface, "peers")
if !r.ok() {
return nil
}
set := map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l != "" {
set[l] = true
}
}
return set
}
// dropFromKernel снимает пира с интерфейса и убеждается, что его там нет. Порядок во
// всех вызывающих: сначала ядро, потом база — при отказе строка остаётся, и повторная
// попытка (надгробие панели, следующий sync) найдёт пира.
func (s *Server) dropFromKernel(pub string) bool {
r := runCmd("awg", "set", s.iface, "peer", pub, "remove")
if r.ok() {
return true
}
if left := s.kernelPeers(); left != nil && !left[pub] {
return true // ответ странный, но пира уже нет
}
s.log.Error("peer remove from kernel failed", "pub", pub, "stderr", r.Stderr)
return false
}
func randomHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return fmt.Sprintf("%x", b)
}
func b64Std(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
func stdB64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
// lenientB64URL повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/».
func lenientB64URL(s string) ([]byte, error) {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
return base64.RawURLEncoding.DecodeString(s)
}
+233
View File
@@ -0,0 +1,233 @@
package ctrl
import (
"database/sql"
"fmt"
"net/url"
_ "modernc.org/sqlite"
)
// Схема users.db — та же, что у Node-версии: существующие базы открываются как есть.
// PRAGMA не задаём (журнал по умолчанию, без WAL); ждём блокировку 5 с, как better-sqlite3.
type userRow struct {
Name string `json:"name"`
IP string `json:"ip"`
PubKey string `json:"pub_key"`
VpnKey string `json:"vpn_key"`
PskKey string `json:"psk_key"`
KeyGen string `json:"key_gen"`
VpnKeyPrev string `json:"vpn_key_prev"`
}
type peerRow struct {
PubKey string
IP string
PskKey string
Owner string
CreatedAt int64
}
func openDB(path string) (*sql.DB, error) {
dsn := "file:" + (&url.URL{Path: path}).EscapedPath() + "?_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Один соединение: SQLite всё равно пишет по одному, а так нет «database is locked» между
// горутинами и Tx не конкурирует с запросами вне транзакции.
db.SetMaxOpenConns(1)
if err := initSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func initSchema(db *sql.DB) error {
for _, q := range []string{
`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 ''
)`,
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_ip ON users (ip)",
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
// ALTER TABLE идемпотентным не бывает — смотрим фактический список колонок.
cols := map[string]bool{}
rows, err := db.Query("PRAGMA table_info(users)")
if err != nil {
return err
}
for rows.Next() {
var cid int
var name, typ string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
cols[name] = true
}
rows.Close()
if !cols["key_gen"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN key_gen TEXT NOT NULL DEFAULT '2'"); err != nil {
return err
}
}
if !cols["vpn_key_prev"] {
if _, err := db.Exec("ALTER TABLE users ADD COLUMN vpn_key_prev TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
}
for _, q := range []string{
`CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)`,
// Пиры устройств sen://: приватного ключа здесь нет и быть не может.
`CREATE TABLE IF NOT EXISTS peers (
pub_key TEXT PRIMARY KEY,
ip TEXT NOT NULL UNIQUE,
psk_key TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)`,
} {
if _, err := db.Exec(q); err != nil {
return err
}
}
return nil
}
type queryer interface {
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
Exec(query string, args ...any) (sql.Result, error)
}
func (s *Server) getCfg(key, fallback string) string {
var v string
if err := s.db.QueryRow("SELECT value FROM config WHERE key = ?", key).Scan(&v); err != nil {
return fallback
}
return v
}
func (s *Server) setCfg(key, value string) {
if _, err := s.db.Exec("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", key, value); err != nil {
s.log.Error("setCfg failed", "key", key, "error", err)
}
}
const userCols = "name, ip, pub_key, vpn_key, psk_key, key_gen, vpn_key_prev"
func scanUsers(rows *sql.Rows) ([]userRow, error) {
defer rows.Close()
out := []userRow{}
for rows.Next() {
var u userRow
if err := rows.Scan(&u.Name, &u.IP, &u.PubKey, &u.VpnKey, &u.PskKey, &u.KeyGen, &u.VpnKeyPrev); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *Server) usersAll() ([]userRow, error) {
rows, err := s.db.Query("SELECT " + userCols + " FROM users")
if err != nil {
return nil, err
}
return scanUsers(rows)
}
func (s *Server) userGet(name string) (*userRow, error) {
rows, err := s.db.Query("SELECT "+userCols+" FROM users WHERE name = ?", name)
if err != nil {
return nil, err
}
us, err := scanUsers(rows)
if err != nil || len(us) == 0 {
return nil, err
}
return &us[0], nil
}
func scanPeers(rows *sql.Rows) ([]peerRow, error) {
defer rows.Close()
out := []peerRow{}
for rows.Next() {
var p peerRow
if err := rows.Scan(&p.PubKey, &p.IP, &p.PskKey, &p.Owner, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Server) peersAll() ([]peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers ORDER BY created_at, rowid")
if err != nil {
return nil, err
}
return scanPeers(rows)
}
func (s *Server) peerGet(pub string) (*peerRow, error) {
rows, err := s.db.Query("SELECT pub_key, ip, psk_key, owner, created_at FROM peers WHERE pub_key = ?", pub)
if err != nil {
return nil, err
}
ps, err := scanPeers(rows)
if err != nil || len(ps) == 0 {
return nil, err
}
return &ps[0], nil
}
func (s *Server) userPubTaken(q queryer, pub string) (bool, error) {
var n int
if err := q.QueryRow("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?", pub).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// nextIp: первый свободный 10.9.c.d (c = 0..255, d = 2..254) среди users и peers.
func (s *Server) nextIp(q queryer, subnet string) (string, error) {
rows, err := q.Query("SELECT ip FROM users UNION SELECT ip FROM peers")
if err != nil {
return "", err
}
used := map[string]bool{}
for rows.Next() {
var ip string
if err := rows.Scan(&ip); err != nil {
rows.Close()
return "", err
}
used[ip] = true
}
rows.Close()
for c := 0; c <= 255; c++ {
for d := 2; d <= 254; d++ {
ip := fmt.Sprintf("%s.%d.%d", subnet, c, d)
if !used[ip] {
return ip, nil
}
}
}
return "", fmt.Errorf("Подсеть заполнена")
}
+118
View File
@@ -0,0 +1,118 @@
package ctrl
import (
"math"
"regexp"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
var reOwner = regexp.MustCompile(`^[\w/.-]{0,64}$`)
// checkPubKeyAny — checkPubKey для значения из JSON (не строка → тот же 400).
func checkPubKeyAny(v any) (string, error) {
k, ok := v.(string)
if !ok {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(k)
}
func checkPubKeys(arr []any) ([]string, error) {
out := make([]string, 0, len(arr))
for _, v := range arr {
k, err := checkPubKeyAny(v)
if err != nil {
return nil, err
}
out = append(out, k)
}
return out, nil
}
// jsToNumber — Number(v) для значений из JSON.
func jsToNumber(v any) float64 {
switch x := v.(type) {
case nil:
return 0
case float64:
return x
case bool:
if x {
return 1
}
return 0
case string:
return jsNumber(x)
}
return math.NaN()
}
func (s *Server) knownPubKeys() (map[string]bool, error) {
known := map[string]bool{}
rows, err := s.db.Query("SELECT pub_key FROM users")
if err != nil {
return nil, err
}
for rows.Next() {
var k string
rows.Scan(&k)
known[k] = true
}
rows.Close()
peers, err := s.peersAll()
if err != nil {
return nil, err
}
for _, p := range peers {
known[p.PubKey] = true
}
return known, nil
}
func (s *Server) isKnownPub(k string) (bool, error) {
if p, err := s.peerGet(k); err != nil || p != nil {
return p != nil, err
}
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM users WHERE pub_key = ?", k).Scan(&n); err != nil {
return false, err
}
return n > 0, nil
}
// profileAwg — параметры для /api/profile в порядке CONFIG.awgParams (порядок объявления в
// DEFAULT_AWG_PARAMS), без пустых значений; числа — строками.
func profileAwg(p Params) jsjson.Object {
o := jsjson.Object{}
add := func(k, v string) {
if v != "" {
o = append(o, jsjson.KV{Key: k, Val: v})
}
}
itoa := strconv.Itoa
add("Jc", itoa(p.Jc))
add("Jmin", itoa(p.Jmin))
add("Jmax", itoa(p.Jmax))
add("S1", itoa(p.S1))
add("S2", itoa(p.S2))
add("S3", itoa(p.S3))
add("S4", itoa(p.S4))
add("H1", p.H1)
add("H2", p.H2)
add("H3", p.H3)
add("H4", p.H4)
add("I1", p.I1)
add("I2", p.I2)
add("I3", p.I3)
add("I4", p.I4)
add("I5", p.I5)
for _, k := range AWG3Keys {
add(k, p.A3[k])
}
return o
}
var _ = strings.TrimSpace
+236
View File
@@ -0,0 +1,236 @@
package ctrl
import (
"fmt"
"io"
"mime"
"net/http"
"regexp"
"strings"
"unicode"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Минимальный роутер с поведением Express 4, на которое опираются клиенты:
// - тело JSON разбирается ДО авторизации и маршрутизации (битый JSON без токена → 400, не 401);
// - ошибки разбора и неизвестные пути отвечают HTML, как finalhandler Express;
// - пути без учёта регистра и с необязательным «/» в конце; :param декодируется.
// Первое совпавшее объявление выигрывает — порядок ниже важен («reissue» до «:name», «sync» до «:pub»).
type rctx struct {
w http.ResponseWriter
r *http.Request
s *Server
params map[string]string
body any // jsjson.Object, []any или nil (= {})
}
type result struct {
status int
v any
}
type route struct {
method string
segs []string
mw []func(*rctx) error
lock bool
h func(*rctx) (result, error)
}
const (
bodyLimitSmall = 1024
bodyLimitBig = 512 * 1024
)
func writeJSON(w http.ResponseWriter, status int, v any) {
b, err := jsjson.Marshal(v)
if err != nil {
status, b = 500, []byte(`{"error":"Internal server error"}`)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
}
func writeHTMLError(w http.ResponseWriter, status int, text string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
fmt.Fprintf(w, "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Error</title>\n</head>\n<body>\n<pre>%s</pre>\n</body>\n</html>\n", text)
}
// readBody — express.json({limit}): только application/json, объект или массив.
func readBody(r *http.Request, limit int64) (any, int, string) {
mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
if mt != "application/json" || r.ContentLength == 0 && r.Body == http.NoBody {
return nil, 0, ""
}
if r.ContentLength > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
raw, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
if err != nil {
return nil, 400, "BadRequestError: request aborted"
}
if int64(len(raw)) > limit {
return nil, 413, "PayloadTooLargeError: request entity too large"
}
trimmed := strings.TrimLeftFunc(string(raw), unicode.IsSpace)
if trimmed == "" {
return nil, 0, ""
}
if trimmed[0] != '{' && trimmed[0] != '[' {
return nil, 400, "SyntaxError: Unexpected token in JSON"
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil, 400, "SyntaxError: " + err.Error()
}
return v, 0, ""
}
func (c *rctx) obj() jsjson.Object {
o, _ := c.body.(jsjson.Object)
return o
}
func match(rt route, method string, segs []string) map[string]string {
if rt.method != method || len(rt.segs) != len(segs) {
return nil
}
params := map[string]string{}
for i, p := range rt.segs {
if strings.HasPrefix(p, ":") {
v, ok := unescape(segs[i])
if !ok {
return nil
}
params[p[1:]] = v
} else if !strings.EqualFold(p, segs[i]) {
return nil
}
}
return params
}
func unescape(s string) (string, bool) {
if !strings.Contains(s, "%") {
return s, true
}
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '%' && i+2 < len(s)+0 && isHex(s[i+1]) && isHex(s[i+2]) {
b.WriteByte(unhex(s[i+1])<<4 | unhex(s[i+2]))
i += 2
} else if s[i] == '%' {
return "", false
} else {
b.WriteByte(s[i])
}
}
return b.String(), true
}
func isHex(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func unhex(c byte) byte {
switch {
case c >= '0' && c <= '9':
return c - '0'
case c >= 'a' && c <= 'f':
return c - 'a' + 10
}
return c - 'A' + 10
}
func (s *Server) handler() http.Handler {
routes := s.routes()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Тело разбирается первым и для всех путей; лимит выбирает точный путь.
limit := int64(bodyLimitSmall)
p := strings.TrimSuffix(r.URL.EscapedPath(), "/")
if strings.EqualFold(p, "/api/peers/sync") || strings.EqualFold(p, "/api/audit/clean") {
limit = bodyLimitBig
}
body, status, msg := readBody(r, limit)
if status != 0 {
writeHTMLError(w, status, msg)
return
}
segs := strings.Split(strings.Trim(r.URL.EscapedPath(), "/"), "/")
for _, rt := range routes {
params := match(rt, r.Method, segs)
if params == nil {
continue
}
c := &rctx{w: w, r: r, s: s, params: params, body: body}
s.serve(c, rt)
return
}
writeHTMLError(w, 404, "Cannot "+r.Method+" "+r.URL.Path)
})
}
func (s *Server) serve(c *rctx, rt route) {
defer func() {
if p := recover(); p != nil {
s.log.Error("handler panic", "error", fmt.Sprint(p))
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
}()
for _, mw := range rt.mw {
if err := mw(c); err != nil {
s.fail(c, err)
return
}
}
if rt.lock {
s.mu.Lock()
defer s.mu.Unlock()
}
res, err := rt.h(c)
if err != nil {
s.fail(c, err)
return
}
writeJSON(c.w, res.status, res.v)
}
func (s *Server) fail(c *rctx, err error) {
if he, ok := err.(*HTTPError); ok {
writeJSON(c.w, he.Status, jsjson.Object{{Key: "error", Val: he.Msg}})
return
}
s.log.Error("handler error", "error", err)
writeJSON(c.w, 500, jsjson.Object{{Key: "error", Val: "Internal server error"}})
}
// ───────── middleware ─────────
func (s *Server) auth(c *rctx) error {
h := c.r.Header.Get("Authorization")
tok := ""
if strings.HasPrefix(h, "Bearer ") {
tok = h[7:]
}
if tok == "" || !iauth.Verify(s.pub, tok, nowFn()) {
return httpErr(401, "Неверная авторизация")
}
return nil
}
var reName = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`)
func validateName(c *rctx) error {
name, ok := c.params["name"]
if !ok {
name, _ = c.obj().Get2("name").(string)
}
if name == "" || !reName.MatchString(name) {
return httpErr(400, "Имя: буквы, цифры, _ и -, до 32 символов")
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package ctrl
import (
"os"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Метрики для Prometheus. awg-ctrl отдаёт JSON, а текстовый формат собирает awg-ui: на ноде
// агент пропускает только /api/*, и один формат на ядре и нодах проще сверять. Кэш на 5 с —
// несколько скрейперов не должны гонять `awg show` чаще.
const metricsTTL = 5 * time.Second
type metricsCache struct {
mu sync.Mutex
at time.Time
data any
}
type metricsPeer struct {
Kind string `json:"kind"`
Name string `json:"name"`
IP string `json:"ip"`
Online bool `json:"online"`
Rx float64 `json:"rx"`
Tx float64 `json:"tx"`
Priority string `json:"priority"`
}
type metricsHost struct {
CPU map[string]float64 `json:"cpu"`
Cores int `json:"cores"`
Load []float64 `json:"load"`
Mem struct {
Total float64 `json:"total"`
Available float64 `json:"available"`
} `json:"mem"`
}
type linkStats struct {
RxBytes float64 `json:"rx_bytes"`
TxBytes float64 `json:"tx_bytes"`
RxPackets float64 `json:"rx_packets"`
TxPackets float64 `json:"tx_packets"`
RxDropped float64 `json:"rx_dropped"`
TxDropped float64 `json:"tx_dropped"`
RxErrors float64 `json:"rx_errors"`
TxErrors float64 `json:"tx_errors"`
}
type metricsBody struct {
Up bool `json:"up"`
Gen string `json:"gen"`
Peers struct {
Total int `json:"total"`
Online int `json:"online"`
} `json:"peers"`
RxBytes float64 `json:"rx_bytes"`
TxBytes float64 `json:"tx_bytes"`
Link linkStats `json:"link"`
Shaping ShapingStatus `json:"shaping"`
Host *metricsHost `json:"host"`
List []metricsPeer `json:"list"`
}
func (s *Server) linkCounter(name string) float64 {
b, err := os.ReadFile("/sys/class/net/" + s.iface + "/statistics/" + name)
if err != nil {
return 0
}
return orZero(jsNumber(string(b)))
}
var cpuModes = []string{"user", "nice", "system", "idle", "iowait", "irq", "softirq", "steal"}
// readHost: нагрузка хоста из /proc. Счётчики CPU кумулятивные (секунды по режимам, как у
// node_exporter): долю считает Prometheus через rate(). Память — MemAvailable, а не MemFree:
// кэш ядра свободен.
func readHost() *metricsHost {
stat, err := os.ReadFile("/proc/stat")
if err != nil {
return nil
}
first := strings.Fields(strings.SplitN(string(stat), "\n", 2)[0])
if len(first) < 2 {
return nil
}
h := &metricsHost{CPU: map[string]float64{}, Cores: runtime.NumCPU()}
for i, m := range cpuModes {
v := 0.0
if i+1 < len(first) {
v = orZero(jsNumber(first[i+1]))
}
h.CPU[m] = v / 100 // USER_HZ = 100 на всех обычных ядрах
}
mem, err := os.ReadFile("/proc/meminfo")
if err != nil {
return nil
}
kb := func(k string) float64 {
m := regexp.MustCompile(`(?m)^` + k + `:\s+(\d+) kB`).FindStringSubmatch(string(mem))
if m == nil {
return 0
}
f, _ := strconv.ParseFloat(m[1], 64)
return f * 1024
}
h.Mem.Total, h.Mem.Available = kb("MemTotal"), kb("MemAvailable")
if la, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(la))
for i := 0; i < 3 && i < len(f); i++ {
h.Load = append(h.Load, orZero(jsNumber(f[i])))
}
}
return h
}
func (s *Server) collectMetrics() (*metricsBody, error) {
st := s.interfaceStatus()
stats := map[string]peerStat{}
if st.Up {
stats = s.getPeersData()
}
rows, err := s.db.Query("SELECT name, ip, pub_key FROM users WHERE vpn_key != ''")
if err != nil {
return nil, err
}
b := &metricsBody{Up: st.Up, Gen: s.cfg().Gen, List: []metricsPeer{}}
for rows.Next() {
var n, ip, pub string
if err := rows.Scan(&n, &ip, &pub); err != nil {
rows.Close()
return nil, err
}
ps := stats[pub]
b.List = append(b.List, metricsPeer{"user", n, ip, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(ip)})
}
rows.Close()
devs, err := s.peersAll()
if err != nil {
return nil, err
}
for _, p := range devs {
ps := stats[p.PubKey]
b.List = append(b.List, metricsPeer{"device", "", p.IP, ps.Online, orZero(ps.Rx), orZero(ps.Tx), s.shaping.PriorityOf(p.IP)})
}
b.Peers.Total = st.Peers
for _, ps := range stats {
if ps.Online {
b.Peers.Online++
}
b.RxBytes += orZero(ps.Rx)
b.TxBytes += orZero(ps.Tx)
}
b.Link = linkStats{
s.linkCounter("rx_bytes"), s.linkCounter("tx_bytes"), s.linkCounter("rx_packets"), s.linkCounter("tx_packets"),
s.linkCounter("rx_dropped"), s.linkCounter("tx_dropped"), s.linkCounter("rx_errors"), s.linkCounter("tx_errors"),
}
b.Shaping = s.shaping.Status()
b.Host = readHost()
return b, nil
}
// metricsCached отдаёт кэш на metricsTTL; ошибку не кэшируем.
func (s *Server) metricsCached() (any, error) {
m := &s.metrics
m.mu.Lock()
defer m.mu.Unlock()
if m.data != nil && time.Since(m.at) <= metricsTTL {
return m.data, nil
}
d, err := s.collectMetrics()
if err != nil {
m.data = nil
return nil, err
}
m.at, m.data = time.Now(), d
return d, nil
}
+104
View File
@@ -0,0 +1,104 @@
package ctrl
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
)
// Установка и удаление встроенного мониторинга (Prometheus) по кнопке в панели. Действие
// одно из двух и без параметров: awg-ctrl запускает ту же команду, что оператор набрал бы
// руками (`awg-ctrl monitor install|remove`, root), так что код один. Панель сама пакетов
// не ставит и не удаляет — она только просит об этом здесь.
type monitorJob struct {
mu sync.Mutex
active bool
action string
exit *int
}
var reANSI = regexp.MustCompile("\x1b\\[[0-9;]*[A-Za-z]")
func (s *Server) logTail() string {
b, err := os.ReadFile(s.cliLog)
if err != nil {
return ""
}
t := string(b)
if len(t) > 4000 { // последние 4000 символов (в JS — UTF-16 единиц; для лога разницы нет)
t = t[len(t)-4000:]
}
return strings.ReplaceAll(reANSI.ReplaceAllString(t, ""), "\r", "\n")
}
// startMonitorJob запускает CLI в фоне; возвращает 409, если уже идёт.
func (s *Server) startMonitorJob(action string) error {
j := &s.monitor
j.mu.Lock()
if j.active {
j.mu.Unlock()
return httpErr(409, "Операция уже идёт")
}
if _, err := os.Stat(s.cliPath); err != nil {
j.mu.Unlock()
return httpErr(501, "CLI не найден ("+s.cliPath+"): выполните на сервере awg-ctrl monitor "+action)
}
os.MkdirAll(filepath.Dir(s.cliLog), 0o755)
f, err := os.OpenFile(s.cliLog, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
j.mu.Unlock()
return err
}
cmd := exec.Command(s.cliPath, "monitor", action)
cmd.Stdout, cmd.Stderr = f, f
j.active, j.action, j.exit = true, action, nil
if err := cmd.Start(); err != nil {
s.log.Error("monitor: не запустился", "action", action, "error", err.Error())
code := 127
j.active, j.exit = false, &code
f.Close()
j.mu.Unlock()
return nil
}
j.mu.Unlock()
go func() {
err := cmd.Wait()
code := 0
if err != nil {
code = 1
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
}
}
f.Close()
s.log.Info("monitor: завершён", "action", action, "code", code)
j.mu.Lock()
j.active, j.exit = false, &code
j.mu.Unlock()
}()
return nil
}
type jobView struct {
Action *string `json:"action"`
Running bool `json:"running"`
Exit *int `json:"exit"`
Log string `json:"log"`
}
func (s *Server) monitorView() jobView {
j := &s.monitor
j.mu.Lock()
defer j.mu.Unlock()
v := jobView{Running: j.active, Exit: j.exit}
if j.action != "" {
a := j.action
v.Action = &a
v.Log = s.logTail()
}
return v
}
+605
View File
@@ -0,0 +1,605 @@
package ctrl
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
// HTTPError — ошибка с кодом ответа; любая другая ошибка превращается в 500.
type HTTPError struct {
Status int
Msg string
}
func (e *HTTPError) Error() string { return e.Msg }
func httpErr(status int, msg string) error { return &HTTPError{status, msg} }
// ───────── пользователи (vpn://) ─────────
func (s *Server) addUser(name string) (*userRow, error) {
keys, err := s.generateKeys()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
cfg := s.cfg()
var ip string
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
ip, err = s.nextIp(tx, cfg.Subnet)
if err == nil {
_, err = tx.Exec("INSERT INTO users (name, ip, pub_key, vpn_key, psk_key, key_gen) VALUES (?, ?, ?, ?, ?, ?)",
name, ip, keys.Public, "", keys.PSK, cfg.Gen)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
vpnKey, err := EncodeVpnKey(s.keyEnv(), keys, ip, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key = ? WHERE name = ?", vpnKey, name); err != nil {
return nil, err
}
if r := s.setPeer(keys.Public, keys.PSK, ip); !r.ok() {
s.db.Exec("DELETE FROM users WHERE name = ?", name)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("user created", "name", name, "ip", ip, "gen", cfg.Gen)
return &userRow{Name: name, IP: ip, PubKey: keys.Public, VpnKey: vpnKey, PskKey: keys.PSK, KeyGen: cfg.Gen}, nil
}
func (s *Server) removeUser(name string) error {
u, err := s.userGet(name)
if err != nil {
return err
}
if u == nil {
return errors.New("Пользователь не найден") // в Node — обычный Error → 500
}
if !s.dropFromKernel(u.PubKey) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM users WHERE name = ?", name); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
for _, ext := range []string{".conf", ".key"} {
f := filepath.Join(s.confDir, "clients", name+ext)
if _, err := os.Stat(f); err == nil {
os.Remove(f)
}
}
s.log.Info("user removed", "name", name)
return nil
}
type reissueResult struct {
Total int `json:"total"`
Reissued int `json:"reissued"`
Regenerated []string `json:"regenerated"`
Backup string `json:"backup"`
}
// reissueAll перевыпускает все vpn:// ключи на текущих параметрах интерфейса — после смены
// поколения старые перестают работать. IP, pub_key и psk_key сохраняются: на проводе ничего
// не меняется, клиенту достаточно заново импортировать ключ.
func (s *Server) reissueAll() (*reissueResult, error) {
users, err := s.usersAll()
if err != nil {
return nil, err
}
serverPub, err := s.getServerPublicKey()
if err != nil {
return nil, err
}
backup := s.dbPath + ".bak-" + jsonStamp(time.Now())
if _, err := s.db.Exec("VACUUM INTO ?", backup); err != nil {
return nil, err
}
s.log.Info("reissue: db backed up", "backup", backup, "users", len(users))
res := &reissueResult{Total: len(users), Regenerated: []string{}, Backup: backup}
cfg := s.cfg()
for _, u := range users {
var keys Keys
if priv := clientPrivKeyFrom(u.VpnKey); priv != "" {
keys = Keys{Private: priv, Public: u.PubKey, PSK: u.PskKey}
} else {
// Блоб не разобрался — личность пира восстановить неоткуда, выдаём новую.
s.log.Warn("reissue: vpn_key не декодируется, генерируем новую пару", "name", u.Name)
keys, err = s.generateKeys()
if err != nil {
return nil, err
}
runCmd("awg", "set", s.iface, "peer", u.PubKey, "remove")
if r := s.setPeer(keys.Public, keys.PSK, u.IP); !r.ok() {
s.log.Error("reissue: awg set failed", "name", u.Name, "stderr", r.Stderr)
continue
}
res.Regenerated = append(res.Regenerated, u.Name)
}
vk, err := EncodeVpnKey(s.keyEnv(), keys, u.IP, serverPub)
if err != nil {
return nil, err
}
if _, err := s.db.Exec("UPDATE users SET vpn_key_prev = vpn_key, vpn_key = ?, pub_key = ?, psk_key = ?, key_gen = ? WHERE name = ?",
vk, keys.Public, keys.PSK, cfg.Gen, u.Name); err != nil {
return nil, err
}
res.Reissued++
}
if err := s.syncPeers(); err != nil {
return nil, err
}
s.log.Info("reissue: done", "total", len(users), "reissued", res.Reissued, "regenerated", len(res.Regenerated))
return res, nil
}
// ───────── пиры устройств (sen://) ─────────
// awg-ctrl про мастер-ключи ничего не знает: ему приходит «добавь пира с этим публичным
// ключом». Приватного ключа устройства здесь нет и быть не может.
var rePub = regexp.MustCompile(`^[A-Za-z0-9+/]{43}=$`)
// checkPubKey: base64 от 32 байт (44 символа, последний «=»).
func checkPubKey(k string) (string, error) {
if !rePub.MatchString(k) {
return "", httpErr(400, "Неверный публичный ключ")
}
b, err := b64Std(k)
if err != nil || len(b) != 32 {
return "", httpErr(400, "Неверный публичный ключ")
}
return k, nil
}
// pubKeyFromParam: в пути base64url — «/» и «+» обычного base64 ломают маршрут.
func pubKeyFromParam(p string) (string, error) {
b, err := lenientB64URL(p)
if err != nil {
return "", httpErr(400, "Неверный публичный ключ")
}
return checkPubKey(stdB64(b))
}
type ipPsk struct {
IP string `json:"ip"`
PSK string `json:"psk_key"`
}
func (s *Server) assertPubKeyFree(q queryer, pub string) error {
var n int
if err := q.QueryRow("SELECT COUNT(*) FROM peers WHERE pub_key = ?", pub).Scan(&n); err != nil {
return err
}
taken, err := s.userPubTaken(q, pub)
if err != nil {
return err
}
if n > 0 || taken {
return httpErr(409, "Публичный ключ уже используется")
}
if sp, err := s.getServerPublicKey(); err != nil {
return err
} else if pub == sp {
return httpErr(409, "Публичный ключ уже используется")
}
return nil
}
func (s *Server) addPeer(pub, owner string) (*ipPsk, error) {
if _, err := checkPubKey(pub); err != nil {
return nil, err
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
var ip string
err = s.assertPubKeyFree(tx, pub)
if err == nil {
ip, err = s.nextIp(tx, s.cfg().Subnet)
}
if err == nil {
_, err = tx.Exec("INSERT INTO peers (pub_key, ip, psk_key, owner, created_at) VALUES (?, ?, ?, ?, ?)",
pub, ip, psk, owner, time.Now().Unix())
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, ip); !r.ok() {
s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer added", "owner", owner, "ip", ip)
return &ipPsk{ip, psk}, nil
}
// replacePeer — смена ключа устройства: IP и PSK остаются, меняется только pub_key.
func (s *Server) replacePeer(oldPub, newPub string) (*ipPsk, error) {
if _, err := checkPubKey(newPub); err != nil {
return nil, err
}
old, err := s.peerGet(oldPub)
if err != nil {
return nil, err
}
if old == nil {
return nil, httpErr(404, "Пир не найден")
}
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
if err = s.assertPubKeyFree(tx, newPub); err == nil {
_, err = tx.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", newPub, oldPub)
}
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
if r := s.setPeer(newPub, old.PskKey, old.IP); !r.ok() {
s.db.Exec("UPDATE peers SET pub_key = ? WHERE pub_key = ?", oldPub, newPub)
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
// Старый ключ не снялся — пир остаётся «призраком» в ядре до аудита панели; смену ключа
// не откатываем: новый уже стоит, а клиент к этому моменту получил бы ошибку.
if !s.dropFromKernel(oldPub) {
s.log.Error("peer rekey: старый ключ остался в ядре", "owner", old.Owner)
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer rekeyed", "owner", old.Owner, "ip", old.IP)
return &ipPsk{old.IP, old.PskKey}, nil
}
func (s *Server) rotatePeerPSK(pub string) (*ipPsk, error) {
p, err := s.peerGet(pub)
if err != nil {
return nil, err
}
if p == nil {
return nil, httpErr(404, "Пир не найден")
}
psk, err := s.genPSK()
if err != nil {
return nil, err
}
if r := s.setPeer(pub, psk, p.IP); !r.ok() {
return nil, fmt.Errorf("awg set failed: %s", r.Stderr)
}
if _, err := s.db.Exec("UPDATE peers SET psk_key = ? WHERE pub_key = ?", psk, pub); err != nil {
return nil, err
}
if err := s.rebuildConf(); err != nil {
return nil, err
}
s.log.Info("peer psk rotated", "owner", p.Owner)
return &ipPsk{p.IP, psk}, nil
}
func (s *Server) removePeer(pub string) error {
p, err := s.peerGet(pub)
if err != nil {
return err
}
if p == nil {
return httpErr(404, "Пир не найден")
}
if !s.dropFromKernel(pub) {
return errors.New("Не удалось снять пира с интерфейса")
}
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", pub); err != nil {
return err
}
if err := s.rebuildConf(); err != nil {
return err
}
s.log.Info("peer removed", "owner", p.Owner)
return nil
}
var (
rePanelID = regexp.MustCompile(`^[a-z0-9]{8,16}$`)
reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`)
)
const syncMax = 10_000
type legacyPeer struct {
PubKey string `json:"pub_key"`
Owner string `json:"owner"`
IP string `json:"ip"`
}
type syncResult struct {
Present []string `json:"present"`
Removed int `json:"removed"`
Legacy []legacyPeer `json:"legacy"`
}
// syncOwnedPeers: панель присылает полный список ключей, которые ей нужны на этом сервере,
// — всё остальное из её области («<panel>/…») снимается. Удаляем только по такому явному
// списку, никогда по времени или неактивности: панель не на связи — ничего не происходит.
// Пиры без области (owner «m1/d2», выданы до областей) панель забирает себе, только если их
// ключ есть в её списке; остальные не трогаем — чьи они, уже не разобрать.
func (s *Server) syncOwnedPeers(panel string, keep []string) (*syncResult, error) {
want := map[string]bool{}
for _, k := range keep {
want[k] = true
}
prefix := panel + "/"
peers, err := s.peersAll()
if err != nil {
return nil, err
}
var removed []peerRow
claimed := 0
for _, p := range peers {
switch {
case strings.HasPrefix(p.Owner, prefix):
// Ядро первым: не снялся — строка остаётся, следующий sync попробует снова.
if !want[p.PubKey] && s.dropFromKernel(p.PubKey) {
if _, err := s.db.Exec("DELETE FROM peers WHERE pub_key = ?", p.PubKey); err != nil {
return nil, err
}
removed = append(removed, p)
}
case reLegacyOwner.MatchString(p.Owner) && want[p.PubKey]:
if _, err := s.db.Exec("UPDATE peers SET owner = ? WHERE pub_key = ?", prefix+p.Owner, p.PubKey); err != nil {
return nil, err
}
claimed++
}
}
if len(removed) > 0 || claimed > 0 {
if err := s.rebuildConf(); err != nil {
return nil, err
}
}
if len(removed) > 0 {
owners := make([]string, len(removed))
for i, p := range removed {
owners[i] = p.Owner
}
s.log.Info("peers synced out", "panel", panel, "removed", owners)
}
all, err := s.peersAll()
if err != nil {
return nil, err
}
res := &syncResult{Present: []string{}, Removed: len(removed), Legacy: []legacyPeer{}}
for _, p := range all {
if strings.HasPrefix(p.Owner, prefix) {
res.Present = append(res.Present, p.PubKey)
}
if reLegacyOwner.MatchString(p.Owner) {
res.Legacy = append(res.Legacy, legacyPeer{p.PubKey, p.Owner, p.IP})
}
}
return res, nil
}
// ───────── переход 2.0 → 3.1 ─────────
// Header protection использует библиотечный chacha-API, которого нет до ядра 5.5 — там
// модуль 3.x просто не соберётся (upstream issue #210).
func kernelRelease() string {
r := runCmd("uname", "-r")
return strings.TrimSpace(r.Stdout)
}
func kernelSupportsHeaderProtection(release string) bool {
parts := strings.Split(release, ".")
if len(parts) < 2 {
return false
}
maj, e1 := strconv.Atoi(leadingDigits(parts[0]))
min, e2 := strconv.Atoi(leadingDigits(parts[1]))
if e1 != nil || e2 != nil {
return false
}
return maj > 5 || (maj == 5 && min >= 5)
}
func leadingDigits(s string) string {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return s[:i]
}
// checkGen31Support решает ЗАГРУЖЕННЫЙ модуль, а не тот, что лежит на диске после apt.
func (s *Server) checkGen31Support() string {
module, _ := readVersions()
if !strings.HasPrefix(module, "3.") {
m := module
if m == "" {
m = "неизвестной версии"
}
return fmt.Sprintf("В ядре загружен модуль amneziawg %s — для 3.1 нужна 3.x. ", m) +
"Обнови пакет (apt) и перезагрузи сервер, затем повтори переход."
}
if rel := kernelRelease(); !kernelSupportsHeaderProtection(rel) {
return fmt.Sprintf("Ядро %s старше 5.5 — header protection в нём не работает (upstream issue #210).", rel)
}
return ""
}
var reAwg3KeyLine = regexp.MustCompile(`^\s*(` + strings.Join(AWG3Keys, "|") + `)\s*=`)
// confWithGen31 готовит текст awg1.conf для 3.1: выкидывает старые 3.x-строки (конфиг могли
// править руками), поднимает S1–S4 до допустимых при header protection и дописывает 3.x-блок
// в конец [Interface]. [Peer]-блоки не трогаются — личности пиров переход не меняет.
func confWithGen31(conf, headerKey string) (string, []string) {
var head, rest string
if loc := pPeerSplit.FindStringIndex(conf); loc != nil {
head, rest = conf[:loc[0]], conf[loc[0]:]
} else {
head = conf
}
var lines []string
for _, l := range strings.Split(head, "\n") {
if !reAwg3KeyLine.MatchString(l) {
lines = append(lines, l)
}
}
// Пустые строки и «# имя» в хвосте [Interface] относятся уже к первому [Peer] (их пишет
// rebuildConf) — отделяем, чтобы 3.x-строки встали внутрь [Interface].
var tail []string
for len(lines) > 0 {
t := strings.TrimSpace(lines[len(lines)-1])
if t != "" && !strings.HasPrefix(t, "#") {
break
}
tail = append([]string{lines[len(lines)-1]}, tail...)
lines = lines[:len(lines)-1]
}
if tt := strings.TrimSpace(strings.Join(tail, "\n")); tt != "" {
rest = tt + "\n" + rest
}
iface := strings.Join(lines, "\n")
// S ниже 12 (или вовсе отсутствующий) ядро с header protection не примет. Правка безопасна
// ровно потому, что следом идёт перевыпуск ключей: обе стороны получают новые значения
// одновременно. (Регулярка с \s*, как в Node-версии: захватывает и переводы строк.)
def := DefaultParams()
defaults := map[string]int{"S1": def.S1, "S2": def.S2, "S3": def.S3, "S4": def.S4}
bumped := []string{}
for _, k := range []string{"S1", "S2", "S3", "S4"} {
re := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)\s*$`)
m := re.FindStringSubmatch(iface)
cur := -1
if m != nil {
cur, _ = strconv.Atoi(m[1])
}
if m != nil && cur >= minSForHeaderProtection {
continue
}
val := defaults[k]
if minSForHeaderProtection > val {
val = minSForHeaderProtection
}
if m != nil {
// как iface.replace(re, …) без флага g: заменяется только первое совпадение
loc := re.FindStringIndex(iface)
iface = iface[:loc[0]] + fmt.Sprintf("%s = %d", k, val) + iface[loc[1]:]
} else {
iface = trimEndJS(iface) + fmt.Sprintf("\n%s = %d", k, val)
}
was := "нет"
if m != nil {
was = strconv.Itoa(cur)
}
bumped = append(bumped, fmt.Sprintf("%s: %s → %d", k, was, val))
}
a3 := []string{"HeaderProtectionKey = " + headerKey}
for _, kv := range AWG31Defaults {
a3 = append(a3, kv[0]+" = "+kv[1])
}
out := trimEndJS(iface) + "\n" + strings.Join(a3, "\n") + "\n"
if rest != "" {
out += "\n" + rest
}
return out, bumped
}
type upgradeResult struct {
Gen string `json:"gen"`
Backup string `json:"backup"`
BumpedS []string `json:"bumpedS"`
Reissue *reissueResult `json:"reissue"`
}
// upgradeToGen31: единственная настоящая проверка, что ядро приняло параметры, — подъём
// интерфейса, поэтому шаги такие: бэкап конфига → запись 3.1 → restartAwg() → перевыпуск
// ключей. Если ядро отказало, возвращаем прежний конфиг и поднимаем интерфейс обратно:
// операция либо применяется целиком, либо не оставляет следов.
func (s *Server) upgradeToGen31() (*upgradeResult, error) {
before, err := os.ReadFile(s.confPath())
if err != nil {
return nil, err
}
backup := s.confPath() + ".bak-" + jsonStamp(time.Now())
if err := os.WriteFile(backup, before, 0o600); err != nil {
return nil, err
}
hk, err := s.genPSK()
if err != nil {
return nil, err
}
conf, bumped := confWithGen31(string(before), hk)
if err := os.WriteFile(s.confPath(), []byte(conf), 0o600); err != nil {
return nil, err
}
s.log.Info("upgrade: conf rewritten for 3.1", "backup", backup, "bumpedS", bumped)
rerr := s.restartAwg()
if rerr == nil && s.cfg().Gen != "3.1" {
rerr = errors.New("интерфейс поднялся, но конфиг всё ещё 2.0")
}
if rerr != nil {
s.log.Error("upgrade: kernel rejected 3.1, rolling back", "error", rerr)
os.WriteFile(s.confPath(), before, 0o600)
if e2 := s.restartAwg(); e2 != nil {
s.log.Error("upgrade: rollback restart failed", "error", e2)
}
return nil, fmt.Errorf("Ядро не приняло конфиг 3.1 — вернули прежний (копия: %s). "+
"Причина видна в dmesg после `echo \"module amneziawg +p\" > /sys/kernel/debug/dynamic_debug/control`.", backup)
}
// Ключи, выданные на 2.0-параметрах, после смены поколения не подключатся, поэтому
// перевыпускаем сразу — сервер не должен оставаться в состоянии «3.1 поднят, у всех
// нерабочие ключи». Личности пиров сохраняются.
re, err := s.reissueAll()
if err != nil {
return nil, err
}
s.log.Info("upgrade: done", "gen", s.cfg().Gen, "reissued", re.Reissued, "bumpedS", bumped)
return &upgradeResult{Gen: s.cfg().Gen, Backup: backup, BumpedS: bumped, Reissue: re}, nil
}
var _ = jsjson.Undefined
+152
View File
@@ -0,0 +1,152 @@
package ctrl
import (
"os"
"regexp"
"strconv"
"strings"
)
// AWG3Keys — ключи AmneziaWG 3.0/3.1. Порядок — порядок строк в клиентском .conf
// (client/server_scripts/awg/template.conf в amnezia-client). Пустое значение =
// ключ не задан: он не попадает ни в conf, ни в vpn://, и 2.0 остаётся побайтно прежним.
var AWG3Keys = []string{
"HeaderProtectionKey",
"ContentPaddingAddition",
"RekeyAfterTime",
"RekeyTimeout",
"RejectAfterTime",
"KeepaliveTimeout",
"MaxHandshakeAttempts",
"RandomTrailers",
"DisableCookies",
}
// Params — параметры обфускации из [Interface] awg1.conf. Числовые — числа (в
// vpn:// они уходят строками), остальные — строки.
type Params struct {
Jc, Jmin, Jmax int
S1, S2, S3, S4 int
H1, H2, H3, H4 string
I1, I2, I3, I4, I5 string
A3 map[string]string // AWG3Keys → значение ("" = не задано)
}
// DefaultParams — запасные значения на случай, когда awg1.conf не найден.
func DefaultParams() Params {
return Params{
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>",
A3: map[string]string{},
}
}
func (p Params) a3(k string) string { return p.A3[k] }
// Gen выводится из параметров, отдельного флага нет: conf — единственный источник
// правды. Header protection — то, что ломает совместимость с 2.0.
func (p Params) Gen() string {
if p.a3("HeaderProtectionKey") != "" {
return "3.1"
}
return "2"
}
var keepaliveByGen = map[string]string{"2": "25", "3.1": "25-35"}
// AWG31Defaults — значения 3.1-параметров для перехода 2.0 → 3.1 (как у install.sh),
// в порядке записи. HeaderProtectionKey генерируется на месте.
var AWG31Defaults = [][2]string{
{"ContentPaddingAddition", "10-100"},
{"RekeyAfterTime", "100-120"},
{"RekeyTimeout", "3-7"},
{"RejectAfterTime", "150-180"},
{"KeepaliveTimeout", "5-15"},
{"MaxHandshakeAttempts", "15-20"},
}
// При заданном HeaderProtectionKey ни один из S1–S4 не может быть меньше 12.
const minSForHeaderProtection = 12
var (
pPeerSplit = regexp.MustCompile(`(?m)^\[Peer\]`)
numKeys = []string{"Jc", "Jmin", "Jmax", "S1", "S2", "S3", "S4"}
strKeys = append([]string{"H1", "H2", "H3", "H4", "I1", "I2", "I3", "I4", "I5"}, AWG3Keys...)
)
// ReadParams читает параметры из [Interface] awg1.conf (всё до первого [Peer]).
//
// Повторяет регулярки Node-версии: `^\s*K\s*=\s*(\d+)` с флагом m. `\s*` в JS
// захватывает и перевод строки, так что строка «I2 = » без значения «съела» бы
// следующую — install.sh таких не пишет, но поведение сохранено намеренно.
func ReadParams(confFile string, warn func(string)) Params {
p := DefaultParams()
raw, err := os.ReadFile(confFile)
if err != nil {
if warn != nil {
warn("awg1.conf не найден — параметры обфускации по умолчанию")
}
return p
}
iface := pPeerSplit.Split(string(raw), 2)[0]
for _, k := range numKeys {
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*(\d+)`).FindStringSubmatch(iface)
if m == nil {
continue
}
n, _ := strconv.Atoi(m[1])
switch k {
case "Jc":
p.Jc = n
case "Jmin":
p.Jmin = n
case "Jmax":
p.Jmax = n
case "S1":
p.S1 = n
case "S2":
p.S2 = n
case "S3":
p.S3 = n
case "S4":
p.S4 = n
}
}
for _, k := range strKeys {
// В JS `.` не совпадает с \n, \r, U+2028, U+2029; в Go-режиме (?m) без (?s) — только с \n.
m := regexp.MustCompile(`(?m)^\s*` + k + `\s*=\s*([^\n\r\x{2028}\x{2029}]*)$`).FindStringSubmatch(iface)
if m == nil {
continue
}
v := strings.TrimSpace(m[1])
switch k {
case "H1":
p.H1 = v
case "H2":
p.H2 = v
case "H3":
p.H3 = v
case "H4":
p.H4 = v
case "I1":
p.I1 = v
case "I2":
p.I2 = v
case "I3":
p.I3 = v
case "I4":
p.I4 = v
case "I5":
p.I5 = v
default:
p.A3[k] = v
}
}
return p
}
+392
View File
@@ -0,0 +1,392 @@
package ctrl
import (
"math"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func nowFn() time.Time { return time.Now() }
type ob = jsjson.Object
type kv = jsjson.KV
func ok(v any) (result, error) { return result{200, v}, nil }
func created(v any) (result, error) { return result{201, v}, nil }
func (s *Server) routes() []route {
a := []func(*rctx) error{s.auth}
an := []func(*rctx) error{s.auth, validateName}
R := func(method, path string, lock bool, mw []func(*rctx) error, h func(*rctx) (result, error)) route {
segs := strings.Split(strings.Trim(path, "/"), "/")
return route{method: method, segs: segs, mw: mw, lock: lock, h: h}
}
return []route{
R("GET", "/health", false, nil, func(c *rctx) (result, error) {
st := s.interfaceStatus()
cfg := s.cfg()
status, word := 200, "ok"
awgSt := "ok"
if !st.Up {
status, word, awgSt = 503, "degraded", "down"
}
return result{status, ob{{"status", word}, {"server", cfg.ServerName}, {"ip", cfg.ServerIP}, {"gen", cfg.Gen},
{"awg", ob{{"status", awgSt}, {"peers", st.Peers}, {"module", cfg.AwgModule}, {"tools", cfg.AwgTools}}}}}, nil
}),
R("POST", "/api/users", true, an, func(c *rctx) (result, error) {
name, _ := c.obj().Get2("name").(string)
if u, err := s.userGet(name); err != nil {
return result{}, err
} else if u != nil {
return result{}, httpErr(409, "Пользователь уже существует")
}
u, err := s.addUser(name)
if err != nil {
return result{}, err
}
return created(u)
}),
R("GET", "/api/users", true, a, func(c *rctx) (result, error) {
rows, err := s.db.Query("SELECT name, ip, pub_key, vpn_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, vk, kg string
if err := rows.Scan(&n, &ip, &pub, &vk, &kg); err != nil {
rows.Close()
return result{}, err
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"pub_key", pub}, {"vpn_key", vk}, {"key_gen", kg},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
R("GET", "/api/users/stats", true, a, func(c *rctx) (result, error) {
rows, err := s.db.Query("SELECT name, ip, pub_key, key_gen FROM users WHERE vpn_key != ''")
if err != nil {
return result{}, err
}
stats := s.getPeersData()
list := []any{}
for rows.Next() {
var n, ip, pub, kg string
if err := rows.Scan(&n, &ip, &pub, &kg); err != nil {
rows.Close()
return result{}, err
}
ps := stats[pub]
list = append(list, ob{{"name", n}, {"ip", ip}, {"key_gen", kg}, {"online", ps.Online},
{"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx}, {"priority", s.shaping.PriorityOf(ip)}})
}
rows.Close()
return ok(ob{{"users", list}})
}),
// ⚠️ До «/api/users/:name», иначе тот примет «reissue» за имя пользователя.
R("POST", "/api/users/reissue", true, a, func(c *rctx) (result, error) {
r, err := s.reissueAll()
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/users/:name", true, an, func(c *rctx) (result, error) {
u, err := s.userGet(c.params["name"])
if err != nil {
return result{}, err
}
if u == nil {
return result{}, httpErr(404, "Пользователь не найден")
}
return ok(u)
}),
R("DELETE", "/api/users/:name", true, an, func(c *rctx) (result, error) {
if err := s.removeUser(c.params["name"]); err != nil {
return result{}, err
}
return ok(ob{{"success", true}, {"name", c.params["name"]}})
}),
R("POST", "/api/peers", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !reOwner.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
pub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.addPeer(pub, owner)
if err != nil {
return result{}, err
}
return created(r)
}),
R("GET", "/api/peers", true, a, func(c *rctx) (result, error) {
stats := s.getPeersData()
peers, err := s.peersAll()
if err != nil {
return result{}, err
}
list := []any{}
for _, p := range peers {
ps := stats[p.PubKey]
list = append(list, ob{{"pub_key", p.PubKey}, {"ip", p.IP}, {"psk_key", p.PskKey}, {"owner", p.Owner},
{"online", ps.Online}, {"lastHandshake", ps.LastHandshake}, {"rx", ps.Rx}, {"tx", ps.Tx},
{"priority", s.shaping.PriorityOf(p.IP)}})
}
return ok(ob{{"peers", list}})
}),
// Пиры, которые стоят в ядре, но неизвестны awg-ctrl (нет ни в users, ни в peers): снять
// их после сбоя `awg set … remove` некому. Только чтение — панель сама решает, что делать.
R("GET", "/api/audit", true, a, func(c *rctx) (result, error) {
r := runCmd("awg", "show", s.iface, "dump")
if !r.ok() {
return result{}, httpErr(503, "awg show не ответил")
}
known, err := s.knownPubKeys()
if err != nil {
return result{}, err
}
now := float64(time.Now().Unix())
ghosts := []any{}
lines := strings.Split(r.Stdout, "\n")
for _, l := range lines[1:] {
f := strings.Split(l, "\t")
if f[0] == "" || known[f[0]] {
continue
}
num := func(i int) float64 {
if i < len(f) {
return orZero(jsNumber(f[i]))
}
return 0
}
hs := num(4)
ghosts = append(ghosts, ob{{"pub_key", f[0]}, {"lastHandshake", hs}, {"online", hs > 0 && now-hs < 180}, {"rx", num(5)}, {"tx", num(6)}})
}
return ok(ob{{"ghosts", ghosts}})
}),
// Снять «призраков» из ядра. Ключи присылает панель, но здесь каждый перепроверяется:
// пир, который успел появиться в users/peers, не трогаем. Строк в базе у «призраков»
// нет — снимается только ядро.
R("POST", "/api/audit/clean", true, a, func(c *rctx) (result, error) {
arr, isArr := c.obj().Get2("pub_keys").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keys, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
removed, skipped := []string{}, []string{}
for _, k := range keys {
known, err := s.isKnownPub(k)
if err != nil {
return result{}, err
}
skip := known
if !skip {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
kp := s.kernelPeers()
skip = k == sp || kp == nil || !kp[k]
}
if skip {
skipped = append(skipped, k)
} else if s.dropFromKernel(k) {
removed = append(removed, k)
} else {
skipped = append(skipped, k)
}
}
if len(removed) > 0 {
s.log.Warn("ghost peers removed from kernel", "count", len(removed), "removed", removed)
}
return ok(ob{{"removed", removed}, {"skipped", skipped}})
}),
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
R("PUT", "/api/peers/sync", true, a, func(c *rctx) (result, error) {
owner, isStr := c.obj().Get2("owner").(string)
if !isStr || !rePanelID.MatchString(owner) {
return result{}, httpErr(400, "Неверный owner")
}
arr, isArr := c.obj().Get2("keep").([]any)
if !isArr || len(arr) > syncMax {
return result{}, httpErr(400, "Неверный список ключей")
}
keep, err := checkPubKeys(arr)
if err != nil {
return result{}, err
}
r, err := s.syncOwnedPeers(owner, keep)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("PUT", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
oldPub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
newPub, err := checkPubKeyAny(c.obj().Get2("pub_key"))
if err != nil {
return result{}, err
}
r, err := s.replacePeer(oldPub, newPub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("POST", "/api/peers/:pub/psk", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
r, err := s.rotatePeerPSK(pub)
if err != nil {
return result{}, err
}
return ok(r)
}),
R("DELETE", "/api/peers/:pub", true, a, func(c *rctx) (result, error) {
pub, err := pubKeyFromParam(c.params["pub"])
if err != nil {
return result{}, err
}
if err := s.removePeer(pub); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Общая часть клиентского конфига для подписки. Ключи obfuscation — именами .conf;
// пустые (I2–I5, весь 3.x-блок на 2.0) не выводятся, как и в самом conf.
R("GET", "/api/profile", true, a, func(c *rctx) (result, error) {
sp, err := s.getServerPublicKey()
if err != nil {
return result{}, err
}
cfg := s.cfg()
return ok(ob{{"name", cfg.ServerName}, {"endpoint", cfg.ServerIP + ":" + strconv.Itoa(cfg.ServerPort)},
{"server_pub", sp}, {"gen", cfg.Gen}, {"dns", []string{cfg.DNS1, cfg.DNS2}},
{"keepalive", cfg.Keepalive}, {"mtu", cfg.MTU}, {"awg", profileAwg(cfg.Params)}})
}),
R("POST", "/awg/restart", true, a, func(c *rctx) (result, error) {
if err := s.restartAwg(); err != nil {
return result{}, err
}
return ok(ob{{"success", true}})
}),
// Перевод сервера с 2.0 на 3.1 — включая перевыпуск всех vpn:// ключей. Ошибку отдаём
// текстом: панель показывает её оператору, а причины отказа (старый модуль, старое ядро,
// отказ ядра на подъёме) требуют разных действий.
R("POST", "/awg/upgrade", true, a, func(c *rctx) (result, error) {
if s.cfg().Gen == "3.1" {
return result{}, httpErr(409, "Сервер уже работает на AmneziaWG 3.1")
}
if blocker := s.checkGen31Support(); blocker != "" {
return result{}, httpErr(409, blocker)
}
r, err := s.upgradeToGen31()
if err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(r)
}),
// Деление канала между устройствами. Путь под /api/ — иначе awg-agent на ноде его не
// пропустит (и без PATCH: агент отвечает на него 400).
R("GET", "/api/shaping", false, a, func(c *rctx) (result, error) { return ok(s.shaping.Status()) }),
R("PUT", "/api/shaping", false, a, func(c *rctx) (result, error) {
b := c.obj()
enabled, e1 := b.Get2("enabled").(bool)
auto, e2 := b.Get2("auto").(bool)
if !e1 || !e2 {
return result{}, httpErr(400, "enabled и auto должны быть true/false")
}
mbit := func(v any, what string) (int, error) {
n := jsToNumber(v)
if math.IsNaN(n) || n != math.Trunc(n) || n < 0 || n > 100_000 {
return 0, httpErr(400, what+": целое число Мбит/с от 1 до 100000")
}
return int(n), nil
}
down, err := mbit(b.Get2("downMbit"), "Загрузка")
if err != nil {
return result{}, err
}
up, err := mbit(b.Get2("upMbit"), "Отдача")
if err != nil {
return result{}, err
}
if enabled && (down < 1 || up < 1) {
return result{}, httpErr(400, "Для включения укажите ширину канала в обе стороны")
}
// tc/nft/модуль не нашлись или ядро отказало: оператору нужна причина, а не 500.
if err := s.shaping.Update(ShapingSettings{Enabled: enabled, DownMbit: down, UpMbit: up, Auto: auto}); err != nil {
return result{}, httpErr(500, err.Error())
}
return ok(s.shaping.Status())
}),
R("GET", "/api/metrics", false, a, func(c *rctx) (result, error) {
d, err := s.metricsCached()
if err != nil {
return result{}, err
}
return ok(d)
}),
R("POST", "/api/monitor/install", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("install"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "install"}}}, nil
}),
R("POST", "/api/monitor/remove", false, a, func(c *rctx) (result, error) {
if err := s.startMonitorJob("remove"); err != nil {
return result{}, err
}
return result{202, ob{{"running", true}, {"action", "remove"}}}, nil
}),
R("GET", "/api/monitor/job", false, a, func(c *rctx) (result, error) { return ok(s.monitorView()) }),
R("GET", "/awg/status", true, a, func(c *rctx) (result, error) { return ok(s.interfaceStatus()) }),
R("POST", "/awg/start", true, a, func(c *rctx) (result, error) {
st, err := s.startInterface()
if err != nil {
return result{}, err
}
return ok(st)
}),
}
}
+186
View File
@@ -0,0 +1,186 @@
// Package ctrl — awg-ctrl: привилегированный REST API, который управляет интерфейсом
// AmneziaWG (`awg`, `awg-quick`, `ip`, `tc`, `nft`) и базой пользователей users.db.
// Слушает только 127.0.0.1; снаружи до него добирается лишь awg-ui (и агент ноды, и CLI)
// с подписанным Ed25519-токеном.
package ctrl
import (
"crypto/ed25519"
"database/sql"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"sync"
"time"
"github.com/maeneko/forgetting/internal/iauth"
"github.com/maeneko/forgetting/internal/logx"
)
// Config — то, что в Node-версии лежало в CONFIG. Параметры и поколение меняются при
// restartAwg/переходе на 3.1 — читать через Server.cfg().
type Config struct {
ServerIP string
ServerPort int
ServerName string
DNS1, DNS2 string
MTU int
Keepalive string
Subnet string
Params Params
Gen string
AwgModule string
AwgTools string
}
type Server struct {
iface string
confDir string
dbPath string
serverPrivKeyFile string
port int
db *sql.DB
log *logx.Logger
pub ed25519.PublicKey
// mu делает последовательности «БД + awg + awg1.conf» неделимыми. В Node весь код между
// await атомарен (один поток); в Go без этого два запроса перемешали бы шаги.
mu sync.Mutex
cfgMu sync.RWMutex
c Config
shaping *Shaping
metrics metricsCache
monitor monitorJob
cliPath string
cliLog string
}
func (s *Server) cfg() Config {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.c
}
func (s *Server) keyEnv() keyEnv {
c := s.cfg()
return keyEnv{P: c.Params, ServerIP: c.ServerIP, ServerPort: c.ServerPort, ServerName: c.ServerName,
DNS1: c.DNS1, DNS2: c.DNS2, MTU: c.MTU, Keepalive: c.Keepalive, Gen: c.Gen, Subnet: c.Subnet}
}
func envOr(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func getLocalIP() string {
r := runCmd("ip", "route", "show", "default")
m := regexp.MustCompile(`dev\s+(\S+)`).FindStringSubmatch(r.Stdout)
if m == nil {
return ""
}
a := runCmd("ip", "addr", "show", m[1])
if m := regexp.MustCompile(`inet\s+([\d.]+)`).FindStringSubmatch(a.Stdout); m != nil {
return m[1]
}
return ""
}
// New открывает базу и ключи, читает параметры из awg1.conf. Не слушает порт.
func New() (*Server, error) {
s := &Server{
iface: "awg1",
confDir: envOr("AWG_CONF_DIR", "/etc/amnezia/amneziawg"),
serverPrivKeyFile: envOr("AWG_SERVER_PRIV_KEY_FILE", "/etc/amnezia/server_private.key"),
cliPath: envOr("AWG_CLI", "/usr/local/bin/awg-ctrl"),
}
s.dbPath = envOr("AWG_DB_PATH", filepath.Join(s.confDir, "users.db"))
port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 {
port = 3005
}
s.port = port
cwd, _ := os.Getwd()
s.log = logx.New(os.Stdout, filepath.Join(cwd, "logs"))
s.cliLog = envOr("MONITOR_LOG", filepath.Join(filepath.Dir(cwd), "logs", "monitor-job.log"))
pubFile := envOr("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")
pub, err := iauth.LoadPublic(pubFile)
if err != nil {
s.log.Error("FATAL: публичный ключ внутренней авторизации не найден: " + pubFile)
return nil, fmt.Errorf("FATAL: публичный ключ внутренней авторизации не найден: %s", pubFile)
}
s.pub = pub
db, err := openDB(s.dbPath)
if err != nil {
return nil, err
}
s.db = db
// Адрес/порт/имя: БД, затем env, затем (для адреса) маршрут по умолчанию. Как в Node,
// запасной адрес вычисляется всегда, даже если в БД уже есть значение.
fallbackIP := ""
if v, ok := os.LookupEnv("SERVER_IP"); ok {
fallbackIP = v
} else {
fallbackIP = getLocalIP()
}
ip := s.getCfg("serverIp", fallbackIP)
portStr := s.getCfg("serverPort", envOr("SERVER_PORT", "51820"))
name := s.getCfg("serverName", envOr("SERVER_NAME", "VPN"))
if ip == "" {
return nil, fmt.Errorf("SERVER_IP не задан — передай через env при первом запуске")
}
s.setCfg("serverIp", ip)
s.setCfg("serverPort", portStr)
s.setCfg("serverName", name)
sp, _ := strconv.Atoi(portStr)
params := ReadParams(s.confPath(), func(m string) { s.log.Warn(m) })
gen := params.Gen()
module, tools := readVersions()
s.c = Config{
ServerIP: ip, ServerPort: sp, ServerName: name,
DNS1: "1.1.1.1", DNS2: "1.0.0.1", MTU: 1376, Subnet: "10.9",
Keepalive: keepaliveByGen[gen], Params: params, Gen: gen,
AwgModule: module, AwgTools: tools,
}
s.log.Info("awg params loaded from conf", "Jc", params.Jc, "H1", params.H1, "gen", gen)
s.shaping = newShaping(s)
return s, nil
}
// Run слушает 127.0.0.1:PORT и выполняет стартовую последовательность Node-версии:
// listen → ensureInterfaceUp → syncPeers → шейпинг. Нет интерфейса — завершаемся с ошибкой:
// health-gate CLI опирается на это (без поднятого awg1 awg-ctrl не нужен).
func (s *Server) Run() error {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", s.port))
if err != nil {
return err
}
srv := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 15 * time.Second}
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
c := s.cfg()
s.log.Info("Server started", "port", s.port, "host", "127.0.0.1", "serverName", c.ServerName, "serverIp", c.ServerIP)
if err := s.ensureInterfaceUp(); err != nil {
return err
}
if err := s.syncPeers(); err != nil {
return err
}
// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre),
// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает.
go s.shaping.Apply()
s.shaping.Start()
return <-errc
}
+605
View File
@@ -0,0 +1,605 @@
package ctrl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// Честное деление канала между устройствами (CAKE) + автоподстройка по RFC 6057.
//
// Схема:
// загрузка (к клиенту) — CAKE root на awg1, dual-dsthost, diffserv3
// отдача (от клиента) — ingress awg1 → ifb-awg, CAKE dual-srchost, besteffort
// тяжёлые при перегрузке — DSCP CS1 (bulk-tin CAKE) через отдельную nft-таблицу
//
// Ничего не пишет в awg1.conf (PostUp не трогаем): всё применяется из процесса и
// идемпотентно. Правила tc пропадают вместе с интерфейсом, поэтому Apply зовётся на
// старте, после restartAwg()/startInterface() и из страховочной проверки в цикле.
//
// Сэмплер только КЛАССИФИЦИРУЕТ. Он никого не удаляет и не отключает — ни по времени,
// ни по неактивности (правило проекта: удаление только по явному списку от панели).
type ShapingSettings struct {
Enabled bool `json:"enabled"`
DownMbit int `json:"downMbit"`
UpMbit int `json:"upMbit"`
Auto bool `json:"auto"`
}
type QdiscStats struct {
Packets float64 `json:"packets"`
Bytes float64 `json:"bytes"`
Drops float64 `json:"drops"`
Backlog float64 `json:"backlog"`
}
type ShapingStatus struct {
Settings ShapingSettings `json:"settings"`
Active bool `json:"active"`
Congested bool `json:"congested"`
Heavy []string `json:"heavy"`
Devices int `json:"devices"`
RateDown float64 `json:"rateDown"`
RateUp float64 `json:"rateUp"`
Down *QdiscStats `json:"down,omitempty"`
Up *QdiscStats `json:"up,omitempty"`
}
const (
congestOn = 0.85 // доля ширины, выше которой канал считается перегруженным
congestOff = 0.70 // и ниже которой перегрузка снимается (гистерезис)
activeMbit = 0.1 // меньше — устройство не считается активным
heavyMult = 1.5 // тяжёлое: ≥ 1.5 честных долей…
heavyMinFrac = 0.05 // …и не меньше 5% ширины канала
checkEvery = 3 // страховочная проверка qdisc — раз в N тиков
ifbName = "ifb-awg"
nftTable = "awg_shape"
)
// Окна и пороги; переопределение через env — только для тестов на стенде.
func envNum(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
return f
}
}
return def
}
type tick struct{ dt, bytes float64 }
type peerCounters struct {
IP string
Rx, Tx float64
}
// orderedSet — множество с порядком вставки (JS Set): порядок попадает в nft-скрипт.
type orderedSet struct {
keys []string
m map[string]bool
}
func newOrderedSet() *orderedSet { return &orderedSet{m: map[string]bool{}} }
func (o *orderedSet) has(k string) bool { return o.m[k] }
func (o *orderedSet) add(k string) {
if !o.m[k] {
o.m[k] = true
o.keys = append(o.keys, k)
}
}
func (o *orderedSet) del(k string) {
if !o.m[k] {
return
}
delete(o.m, k)
for i, x := range o.keys {
if x == k {
o.keys = append(o.keys[:i], o.keys[i+1:]...)
break
}
}
}
func (o *orderedSet) clear() { o.keys, o.m = nil, map[string]bool{} }
func (o *orderedSet) list() []string { return append([]string(nil), o.keys...) }
func (o *orderedSet) size() int { return len(o.keys) }
type Shaping struct {
s *Server
opMu sync.Mutex // сериализует tc/nft: два параллельных Apply дали бы полусобранные qdisc
mu sync.Mutex // состояние сэмплера
prev map[string][2]float64
history map[string][]tick
histOrder []string
totals []tick
heavy *orderedSet
congested bool
lastRateDn float64
lastRateUp float64
lastTickAt time.Time
tickNo int
lastError string
windowTicks int
sampleS float64
started bool
}
func newShaping(s *Server) *Shaping {
sampleS := envNum("SHAPE_SAMPLE_S", 10)
windowS := envNum("SHAPE_WINDOW_S", 600)
return &Shaping{
s: s, prev: map[string][2]float64{}, history: map[string][]tick{}, heavy: newOrderedSet(),
sampleS: sampleS, windowTicks: int(math.Max(3, math.Round(windowS/sampleS))),
}
}
func (h *Shaping) Settings() ShapingSettings {
s := h.s
down, _ := strconv.Atoi(s.getCfg("shapeDownMbit", "0"))
up, _ := strconv.Atoi(s.getCfg("shapeUpMbit", "0"))
return ShapingSettings{
Enabled: s.getCfg("shapeEnabled", "0") == "1",
DownMbit: down, UpMbit: up,
Auto: s.getCfg("shapeAuto", "1") == "1",
}
}
func (h *Shaping) save(st ShapingSettings) {
b := func(v bool) string {
if v {
return "1"
}
return "0"
}
h.s.setCfg("shapeEnabled", b(st.Enabled))
h.s.setCfg("shapeDownMbit", strconv.Itoa(st.DownMbit))
h.s.setCfg("shapeUpMbit", strconv.Itoa(st.UpMbit))
h.s.setCfg("shapeAuto", b(st.Auto))
}
func (h *Shaping) noteError(err error) {
h.mu.Lock()
defer h.mu.Unlock()
if m := err.Error(); m != h.lastError { // одинаковую ошибку в лог не спамим
h.s.log.Warn("shaping: " + m)
h.lastError = m
}
}
func (h *Shaping) overhead() int {
if v := os.Getenv("SHAPE_OVERHEAD"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 {
return int(f)
}
}
return 60 + h.s.cfg().Params.S4
}
func shExec(name string, args ...string) (string, error) {
return runCtx(15*time.Second, name, args...)
}
func shExecStdin(name string, args []string, input string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var se bytes.Buffer
cmd.Stderr = &se
cmd.Stdin = strings.NewReader(input)
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(se.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s: %s", name, msg)
}
return nil
}
func (h *Shaping) nftApply(heavy []string) error {
elements := ""
if len(heavy) > 0 {
elements = "\n elements = { " + strings.Join(heavy, ", ") + " }"
}
// add+delete в одной транзакции: таблица пересоздаётся атомарно, без окна без правил.
script := "\nadd table inet " + nftTable + "\ndelete table inet " + nftTable + "\ntable inet " + nftTable + " {\n" +
" set heavy {\n type ipv4_addr" + elements + "\n }\n" +
" chain postrouting {\n type filter hook postrouting priority -150; policy accept;\n" +
" oifname \"" + h.s.iface + "\" ip daddr @heavy ip dscp set cs1\n }\n}\n"
return shExecStdin("nft", []string{"-f", "-"}, script)
}
func (h *Shaping) hasCake(dev string) bool {
out, err := shExec("tc", "qdisc", "show", "dev", dev, "root")
return err == nil && strings.Contains(out, "cake")
}
func (h *Shaping) hasIngressRedirect() bool {
out, err := shExec("tc", "filter", "show", "dev", h.s.iface, "parent", "ffff:")
return err == nil && strings.Contains(out, "mirred")
}
func (h *Shaping) applyNow() error {
st := h.Settings()
if !st.Enabled || st.DownMbit <= 0 || st.UpMbit <= 0 {
h.removeNow()
return nil
}
iface := h.s.iface
oh := strconv.Itoa(h.overhead())
// Загрузка (к клиенту). replace = change, если qdisc уже стоит: скорость меняется на лету.
if _, err := shExec("tc", "qdisc", "replace", "dev", iface, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.DownMbit), "diffserv3", "dual-dsthost", "overhead", oh); err != nil {
return err
}
// Отдача (от клиента): зеркалим входящий трафик awg1 на ifb и делим там.
if _, err := shExec("ip", "link", "show", ifbName); err != nil {
if _, err := shExec("ip", "link", "add", ifbName, "type", "ifb"); err != nil {
return err
}
}
if _, err := shExec("ip", "link", "set", ifbName, "up"); err != nil {
return err
}
if _, err := shExec("tc", "qdisc", "replace", "dev", ifbName, "root", "cake",
"bandwidth", fmt.Sprintf("%dmbit", st.UpMbit), "besteffort", "dual-srchost", "overhead", oh); err != nil {
return err
}
if !h.hasIngressRedirect() {
shExec("tc", "qdisc", "del", "dev", iface, "ingress") // ошибки игнорируем
if _, err := shExec("tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"); err != nil {
return err
}
if _, err := shExec("tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "all", "prio", "1",
"matchall", "action", "mirred", "egress", "redirect", "dev", ifbName); err != nil {
return err
}
}
h.mu.Lock()
heavy := h.heavy.list()
h.mu.Unlock()
if err := h.nftApply(heavy); err != nil {
return err
}
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
h.s.log.Info("shaping applied", "down", st.DownMbit, "up", st.UpMbit, "overhead", oh, "heavy", len(heavy))
return nil
}
func (h *Shaping) removeNow() {
iface := h.s.iface
shExec("tc", "qdisc", "del", "dev", iface, "root")
shExec("tc", "qdisc", "del", "dev", iface, "ingress")
shExec("ip", "link", "del", ifbName)
shExec("nft", "delete", "table", "inet", nftTable)
h.mu.Lock()
h.heavy.clear()
h.history, h.histOrder, h.prev = map[string][]tick{}, nil, map[string][2]float64{}
h.totals = nil
h.congested = false
h.mu.Unlock()
}
// Apply не бросает: отсутствие tc/nft/модуля не должно валить awg-ctrl (в отличие от
// ensureInterfaceUp).
func (h *Shaping) Apply() {
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
// Update сохраняет настройки и применяет их; ошибку применения отдаёт вызывающему —
// пользователю нужна честная причина, а не молчаливый warn.
func (h *Shaping) Update(st ShapingSettings) error {
h.save(st)
h.opMu.Lock()
err := h.applyNow()
h.opMu.Unlock()
if err == nil {
h.mu.Lock()
h.lastError = ""
h.mu.Unlock()
}
return err
}
// ───────── сэмплер (RFC 6057) ─────────
func (h *Shaping) pushTick(arr []tick, t tick) []tick {
arr = append(arr, t)
for len(arr) > h.windowTicks {
arr = arr[1:]
}
return arr
}
func mbit(arr []tick) float64 {
var dt, by float64
for _, t := range arr {
dt += t.dt
by += t.bytes
}
if dt <= 0 {
return 0
}
return by * 8 / dt / 1e6
}
func (h *Shaping) readPeers() ([]peerCounters, error) {
out, err := runCtx(10*time.Second, "awg", "show", h.s.iface, "dump")
if err != nil {
return nil, err
}
var rows []peerCounters
lines := strings.Split(out, "\n")
if len(lines) > 0 {
lines = lines[1:]
}
for _, l := range lines {
f := strings.Split(l, "\t")
if len(f) < 4 || f[0] == "" || f[3] == "" {
continue
}
ip := strings.SplitN(strings.SplitN(f[3], ",", 2)[0], "/", 2)[0]
rx, tx := 0.0, 0.0
if len(f) > 5 {
rx = orZero(jsNumber(f[5]))
}
if len(f) > 6 {
tx = orZero(jsNumber(f[6]))
}
rows = append(rows, peerCounters{ip, rx, tx})
}
return rows, nil
}
func orZero(f float64) float64 {
if math.IsNaN(f) {
return 0
}
return f
}
func (h *Shaping) tick() {
st := h.Settings()
if !st.Enabled {
return
}
now := time.Now()
h.mu.Lock()
dt := h.sampleS
if !h.lastTickAt.IsZero() {
dt = now.Sub(h.lastTickAt).Seconds()
}
h.lastTickAt = now
h.tickNo++
tickNo := h.tickNo
h.mu.Unlock()
// Страховка: интерфейс могли поднять руками (awg-quick down/up) — qdisc тогда исчез.
if tickNo%checkEvery == 0 && !h.hasCake(h.s.iface) {
h.s.log.Warn("shaping: qdisc на интерфейсе пропал, применяю заново")
h.Apply()
}
peers, err := h.readPeers()
if err != nil {
h.noteError(err)
return
}
h.mu.Lock()
var totalDown, totalUp float64
seen := map[string]bool{}
for _, p := range peers {
seen[p.IP] = true
before, had := h.prev[p.IP]
// Счётчики ядра сбрасываются при down/up интерфейса и замене ключа пира →
// отрицательная дельта = 0.
dTx, dRx := 0.0, 0.0
if had && p.Tx >= before[1] {
dTx = p.Tx - before[1]
}
if had && p.Rx >= before[0] {
dRx = p.Rx - before[0]
}
h.prev[p.IP] = [2]float64{p.Rx, p.Tx}
totalDown += dTx
totalUp += dRx
if _, ok := h.history[p.IP]; !ok {
h.histOrder = append(h.histOrder, p.IP)
}
h.history[p.IP] = h.pushTick(h.history[p.IP], tick{dt, dTx})
}
var kept []string
for _, ip := range h.histOrder {
if seen[ip] {
kept = append(kept, ip)
continue
}
delete(h.prev, ip)
delete(h.history, ip)
h.heavy.del(ip)
}
h.histOrder = kept
h.totals = h.pushTick(h.totals, tick{dt, totalDown})
h.lastRateDn = totalDown * 8 / dt / 1e6
h.lastRateUp = totalUp * 8 / dt / 1e6
if !st.Auto {
changed := h.heavy.size() > 0 || h.congested
h.heavy.clear()
h.congested = false
h.mu.Unlock()
if changed {
h.opMu.Lock()
err := h.nftApply(nil)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
return
}
if len(h.totals) < h.windowTicks { // пока окно не набралось, решений не принимаем
h.mu.Unlock()
return
}
avg := mbit(h.totals)
wasCongested := h.congested
if !h.congested && avg >= congestOn*float64(st.DownMbit) {
h.congested = true
}
if h.congested && avg < congestOff*float64(st.DownMbit) {
h.congested = false
}
next := newOrderedSet()
if h.congested {
type rate struct {
ip string
r float64
}
var rates []rate
for _, ip := range h.histOrder {
if hh := h.history[ip]; len(hh) >= h.windowTicks {
rates = append(rates, rate{ip, mbit(hh)})
}
}
active := 0
for _, r := range rates {
if r.r >= activeMbit {
active++
}
}
share := float64(st.DownMbit) / math.Max(1, float64(active))
for _, r := range rates {
wasHeavy := h.heavy.has(r.ip)
// Входим на 1.5 доли, выходим ниже 1.0 доли — без дребезга на границе.
if r.r >= math.Max(heavyMult*share, heavyMinFrac*float64(st.DownMbit)) || (wasHeavy && r.r >= share) {
next.add(r.ip)
}
}
}
changed := next.size() != h.heavy.size()
if !changed {
for _, ip := range next.keys {
if !h.heavy.has(ip) {
changed = true
break
}
}
}
var apply []string
if changed || wasCongested != h.congested {
h.heavy = next
apply = next.list()
h.s.log.Info("shaping: класс тяжёлых обновлён", "congested", h.congested, "avgMbit", math.Round(avg), "heavy", apply)
}
h.mu.Unlock()
if changed || wasCongested != h.congested {
h.opMu.Lock()
err := h.nftApply(apply)
h.opMu.Unlock()
if err != nil {
h.noteError(err)
}
}
}
func (h *Shaping) Start() {
h.mu.Lock()
if h.started {
h.mu.Unlock()
return
}
h.started = true
h.mu.Unlock()
go func() {
t := time.NewTicker(time.Duration(h.sampleS * float64(time.Second)))
defer t.Stop()
var busy sync.Mutex
for range t.C {
if !busy.TryLock() { // предыдущий тик ещё идёт — пропускаем, а не копим очередь
continue
}
go func() {
defer busy.Unlock()
h.tick()
}()
}
}()
}
// ───────── состояние ─────────
func (h *Shaping) qdiscStats(dev string) *QdiscStats {
out, err := shExec("tc", "-s", "-j", "qdisc", "show", "dev", dev, "root")
if err != nil {
return nil
}
var arr []map[string]any
if json.Unmarshal([]byte(out), &arr) != nil {
return nil
}
for _, q := range arr {
if q["kind"] == "cake" {
n := func(k string) float64 { f, _ := q[k].(float64); return f }
return &QdiscStats{n("packets"), n("bytes"), n("drops"), n("backlog")}
}
}
return nil
}
func (h *Shaping) PriorityOf(ip string) string {
h.mu.Lock()
defer h.mu.Unlock()
if h.heavy.has(ip) {
return "background"
}
return "normal"
}
func (h *Shaping) Status() ShapingStatus {
st := h.Settings()
var active bool
var down, up *QdiscStats
if st.Enabled {
active = h.hasCake(h.s.iface)
down, up = h.qdiscStats(h.s.iface), h.qdiscStats(ifbName)
}
h.mu.Lock()
defer h.mu.Unlock()
devices := 0
for _, hh := range h.history {
if mbit(hh) >= activeMbit {
devices++
}
}
heavy := h.heavy.list()
if heavy == nil {
heavy = []string{}
}
round1 := func(f float64) float64 { return math.Round(f*10) / 10 }
return ShapingStatus{
Settings: st, Active: active, Congested: h.congested, Heavy: heavy, Devices: devices,
RateDown: round1(h.lastRateDn), RateUp: round1(h.lastRateUp), Down: down, Up: up,
}
}
+218
View File
@@ -0,0 +1,218 @@
package ctrl
import (
"bytes"
"compress/zlib"
"encoding/base64"
"encoding/binary"
"io"
"strconv"
"strings"
"github.com/maeneko/forgetting/internal/jsjson"
)
// Keys — пара ключей клиента и PSK.
type Keys struct{ Private, Public, PSK string }
// Подмножество CONFIG, нужное для сборки ключа.
type keyEnv struct {
P Params
ServerIP string
ServerPort int
ServerName string
DNS1, DNS2 string
MTU int
Keepalive string
Gen string
Subnet string
}
// buildClientConf — клиентский .conf. Порядок: PrivateKey → AWG params (Jc,S,H,I) →
// 3.x-ключи → Address → DNS.
//
// 🛑 Пустые I2–I5 выводятся как «I2 = » с ОДНИМ хвостовым пробелом (так в рабочих ключах
// Amnezia). Пробел дан отдельной константой, чтобы его не срезала ни IDE, ни инструмент
// правки. Не «чистить».
func buildClientConf(e keyEnv, k Keys, ip, serverPub string) string {
const sp = " "
var awg3 strings.Builder
for _, key := range AWG3Keys {
if v := e.P.a3(key); v != "" {
awg3.WriteString(key + " = " + v + "\n")
}
}
var b strings.Builder
b.WriteString("[Interface]\n")
b.WriteString("PrivateKey = " + k.Private + "\n")
b.WriteString("Jc = " + strconv.Itoa(e.P.Jc) + "\n")
b.WriteString("Jmin = " + strconv.Itoa(e.P.Jmin) + "\n")
b.WriteString("Jmax = " + strconv.Itoa(e.P.Jmax) + "\n")
b.WriteString("S1 = " + strconv.Itoa(e.P.S1) + "\n")
b.WriteString("S2 = " + strconv.Itoa(e.P.S2) + "\n")
b.WriteString("S3 = " + strconv.Itoa(e.P.S3) + "\n")
b.WriteString("S4 = " + strconv.Itoa(e.P.S4) + "\n")
b.WriteString("H1 = " + e.P.H1 + "\n")
b.WriteString("H2 = " + e.P.H2 + "\n")
b.WriteString("H3 = " + e.P.H3 + "\n")
b.WriteString("H4 = " + e.P.H4 + "\n")
b.WriteString("I1 = " + e.P.I1 + "\n")
b.WriteString("I2 =" + sp + "\n")
b.WriteString("I3 =" + sp + "\n")
b.WriteString("I4 =" + sp + "\n")
b.WriteString("I5 =" + sp + "\n")
b.WriteString(awg3.String())
b.WriteString("Address = " + ip + "/32\n")
b.WriteString("DNS = " + e.DNS1 + ", " + e.DNS2 + "\n")
b.WriteString("\n[Peer]\n")
b.WriteString("PublicKey = " + serverPub + "\n")
b.WriteString("PresharedKey = " + k.PSK + "\n")
b.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
b.WriteString("Endpoint = " + e.ServerIP + ":" + strconv.Itoa(e.ServerPort) + "\n")
b.WriteString("PersistentKeepalive = " + e.Keepalive + "\n")
return b.String()
}
// awgObject собирает объект параметров в ASCII-алфавитном порядке, в каком его
// сериализует QJsonObject клиента AmneziaVPN. Незаданные 3.x-ключи выбрасываются.
func awgObject(p Params) jsjson.Object {
a := func(k string) any {
if p.a3(k) == "" {
return jsjson.Undefined
}
return p.a3(k)
}
return jsjson.Object{
{"ContentPaddingAddition", a("ContentPaddingAddition")},
{"DisableCookies", a("DisableCookies")},
{"H1", p.H1}, {"H2", p.H2}, {"H3", p.H3}, {"H4", p.H4},
{"HeaderProtectionKey", a("HeaderProtectionKey")},
{"I1", p.I1}, {"I2", ""}, {"I3", ""}, {"I4", ""}, {"I5", ""},
{"Jc", strconv.Itoa(p.Jc)},
{"Jmax", strconv.Itoa(p.Jmax)},
{"Jmin", strconv.Itoa(p.Jmin)},
{"KeepaliveTimeout", a("KeepaliveTimeout")},
{"MaxHandshakeAttempts", a("MaxHandshakeAttempts")},
{"RandomTrailers", a("RandomTrailers")},
{"RejectAfterTime", a("RejectAfterTime")},
{"RekeyAfterTime", a("RekeyAfterTime")},
{"RekeyTimeout", a("RekeyTimeout")},
{"S1", strconv.Itoa(p.S1)}, {"S2", strconv.Itoa(p.S2)},
{"S3", strconv.Itoa(p.S3)}, {"S4", strconv.Itoa(p.S4)},
}
}
// EncodeVpnKey собирает vpn://-ключ в формате AmneziaVPN: JSON → zlib → 4 байта длины
// (BE, несжатой) → base64url без «=».
//
// Содержимое после распаковки совпадает с Node-версией байт в байт (порядок ключей,
// типы: port — число внутри last_config и строка снаружи). Сам поток deflate у Go и
// у C-zlib разный, поэтому строка vpn:// не совпадает побайтно — решение владельца
// проекта: AmneziaVPN читает оба одинаково (qUncompress).
func EncodeVpnKey(e keyEnv, k Keys, ip, serverPub string) (string, error) {
clientConf := buildClientConf(e, k, ip, serverPub)
lastConfig := append(awgObject(e.P),
jsjson.KV{"allowed_ips", []string{"0.0.0.0/0", "::/0"}},
jsjson.KV{"clientId", k.Public},
jsjson.KV{"client_ip", ip},
jsjson.KV{"client_priv_key", k.Private},
jsjson.KV{"client_pub_key", k.Public},
jsjson.KV{"config", clientConf},
jsjson.KV{"hostName", e.ServerIP},
jsjson.KV{"mtu", strconv.Itoa(e.MTU)},
jsjson.KV{"persistent_keep_alive", e.Keepalive},
jsjson.KV{"port", e.ServerPort},
jsjson.KV{"psk_key", k.PSK},
jsjson.KV{"server_pub_key", serverPub},
)
lastConfigJSON, err := jsjson.MarshalIndent(lastConfig, " ")
if err != nil {
return "", err
}
awg := append(awgObject(e.P),
jsjson.KV{"last_config", string(lastConfigJSON)},
jsjson.KV{"port", strconv.Itoa(e.ServerPort)},
jsjson.KV{"protocol_version", e.Gen},
jsjson.KV{"subnet_address", e.Subnet + ".0.0"},
jsjson.KV{"transport_proto", "udp"},
)
outer := jsjson.Object{
{"containers", []any{jsjson.Object{{"container", "amnezia-awg2"}, {"awg", awg}}}},
{"defaultContainer", "amnezia-awg2"},
{"description", e.ServerName},
{"dns1", e.DNS1},
{"dns2", e.DNS2},
{"hostName", e.ServerIP},
{"nameOverriddenByUser", true},
}
js, err := jsjson.Marshal(outer)
if err != nil {
return "", err
}
var zb bytes.Buffer
zw, _ := zlib.NewWriterLevel(&zb, zlib.DefaultCompression)
if _, err := zw.Write(js); err != nil {
return "", err
}
if err := zw.Close(); err != nil {
return "", err
}
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(js)))
return "vpn://" + base64.RawURLEncoding.EncodeToString(append(hdr[:], zb.Bytes()...)), nil
}
// DecodeVpnKey — обратная к EncodeVpnKey. Как Buffer.from(…, "base64url") в Node,
// терпим паддинг, «+» и «/». Любая ошибка → nil.
func DecodeVpnKey(key string) jsjson.Object {
s := strings.TrimPrefix(key, "vpn://")
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
buf, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(buf) <= 4 {
return nil
}
zr, err := zlib.NewReader(bytes.NewReader(buf[4:]))
if err != nil {
return nil
}
defer zr.Close()
raw, err := io.ReadAll(zr)
if err != nil {
return nil
}
v, err := jsjson.Decode(raw)
if err != nil {
return nil
}
o, _ := v.(jsjson.Object)
return o
}
// clientPrivKeyFrom достаёт client_priv_key из last_config: приватный ключ клиента
// нигде больше не хранится, и только так перевыпуск сохраняет личность пира.
func clientPrivKeyFrom(vpnKey string) string {
o := DecodeVpnKey(vpnKey)
if o == nil {
return ""
}
containers, _ := o.Get2("containers").([]any)
if len(containers) == 0 {
return ""
}
c, _ := containers[0].(jsjson.Object)
awg, _ := c.Get2("awg").(jsjson.Object)
lc, ok := awg.Get2("last_config").(string)
if !ok {
return ""
}
v, err := jsjson.Decode([]byte(lc))
if err != nil {
return ""
}
obj, _ := v.(jsjson.Object)
priv, _ := obj.Get2("client_priv_key").(string)
return priv
}
+122
View File
@@ -0,0 +1,122 @@
// Package iauth — внутренние токены между awg-ui / awg-agent / CLI и awg-ctrl.
//
// Формат — «JWT» с EdDSA: header {"alg":"EdDSA","typ":"JWT"}, payload {iss,iat,exp},
// подпись Ed25519 над ASCII "h.p". Ключи — PEM от openssl: PKCS#8 (закрытый) и SPKI
// (открытый). Проверка намеренно такая же нестрогая, как у Node-версии: только подпись
// и exp (alg/iss/лишние сегменты не смотрим) — ужесточение отдельным шагом (zero trust).
package iauth
import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"os"
"strconv"
"strings"
"time"
"github.com/maeneko/forgetting/internal/jsjson"
)
func decodePEM(file string) ([]byte, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
blk, _ := pem.Decode(raw)
if blk == nil {
return nil, errors.New("iauth: not a PEM file: " + file)
}
return blk.Bytes, nil
}
// LoadPublic читает открытый ключ (SPKI PEM).
func LoadPublic(file string) (ed25519.PublicKey, error) {
der, err := decodePEM(file)
if err != nil {
return nil, err
}
k, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil, err
}
pub, ok := k.(ed25519.PublicKey)
if !ok {
return nil, errors.New("iauth: not an Ed25519 public key")
}
return pub, nil
}
// LoadPrivate читает закрытый ключ (PKCS#8 PEM).
func LoadPrivate(file string) (ed25519.PrivateKey, error) {
der, err := decodePEM(file)
if err != nil {
return nil, err
}
k, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, err
}
priv, ok := k.(ed25519.PrivateKey)
if !ok {
return nil, errors.New("iauth: not an Ed25519 private key")
}
return priv, nil
}
var b64 = base64.RawURLEncoding
// Mint выпускает токен на 60 с.
func Mint(priv ed25519.PrivateKey, iss string, now time.Time) string {
h := b64.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`))
t := now.Unix()
p := b64.EncodeToString(jsjson.MustMarshal(jsjson.Object{{Key: "iss", Val: iss}, {Key: "iat", Val: t}, {Key: "exp", Val: t + 60}}))
sig := ed25519.Sign(priv, []byte(h+"."+p))
return h + "." + p + "." + b64.EncodeToString(sig)
}
// lenient повторяет Buffer.from(s, "base64url") в Node: терпит паддинг, «+», «/».
func lenient(s string) ([]byte, error) {
s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "="))
return b64.DecodeString(s)
}
// Verify — подпись верна и exp в будущем.
func Verify(pub ed25519.PublicKey, token string, now time.Time) bool {
parts := strings.Split(token, ".")
if len(parts) < 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return false
}
sig, err := lenient(parts[2])
if err != nil || !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
return false
}
raw, err := lenient(parts[1])
if err != nil {
return false
}
v, err := jsjson.Decode(raw)
if err != nil {
return false
}
obj, ok := v.(jsjson.Object)
if !ok {
return false
}
var exp float64
switch e := obj.Get2("exp").(type) {
case float64:
exp = e
case string: // JS сравнил бы числовую строку как число
f, err := strconv.ParseFloat(strings.TrimSpace(e), 64)
if err != nil {
return false
}
exp = f
default:
return false
}
return exp > float64(now.Unix())
}
+86
View File
@@ -0,0 +1,86 @@
package jsjson
import (
"bytes"
"encoding/json"
"errors"
"io"
)
// Decode разбирает JSON с сохранением порядка ключей: объекты — Object, массивы —
// []any, числа — float64, как JSON.parse. Нужен, чтобы JSON.stringify(JSON.parse(x))
// воспроизводился точно (например, сохранённый профиль сервера с ключами awg).
func Decode(data []byte) (any, error) {
d := json.NewDecoder(bytes.NewReader(data))
d.UseNumber()
v, err := decodeValue(d)
if err != nil {
return nil, err
}
if _, err := d.Token(); err != io.EOF {
return nil, errors.New("jsjson: trailing data")
}
return v, nil
}
func decodeValue(d *json.Decoder) (any, error) {
t, err := d.Token()
if err != nil {
return nil, err
}
switch x := t.(type) {
case json.Delim:
switch x {
case '{':
obj := Object{}
for d.More() {
kt, err := d.Token()
if err != nil {
return nil, err
}
key, ok := kt.(string)
if !ok {
return nil, errors.New("jsjson: object key is not a string")
}
val, err := decodeValue(d)
if err != nil {
return nil, err
}
// повторный ключ в JS перезаписывает значение, оставаясь на первом месте
replaced := false
for i := range obj {
if obj[i].Key == key {
obj[i].Val = val
replaced = true
break
}
}
if !replaced {
obj = append(obj, KV{key, val})
}
}
if _, err := d.Token(); err != nil {
return nil, err
}
return obj, nil
case '[':
arr := []any{}
for d.More() {
val, err := decodeValue(d)
if err != nil {
return nil, err
}
arr = append(arr, val)
}
if _, err := d.Token(); err != nil {
return nil, err
}
return arr, nil
}
return nil, errors.New("jsjson: unexpected delimiter")
case json.Number:
return x.Float64()
default:
return x, nil // string, bool, nil
}
}
+393
View File
@@ -0,0 +1,393 @@
// Package jsjson повторяет JSON.stringify из JavaScript байт в байт.
//
// Зачем: выход Node-версии зашит в форматы, которые проверяют клиенты и хэши —
// распакованное содержимое vpn://, rev конфига sen:// (sha256 от строки),
// подписанные тела ответов /sub/v1, /api/profile. encoding/json отличается
// (экранирует < > & и U+2028/2029, иначе пишет числа, добавляет перевод
// строки, сортирует ключи map), поэтому здесь свой сериализатор.
//
// Порядок ключей — порядок объявления: Object хранит пары как есть, у структур
// берутся поля по порядку (тег json задаёт имя, "-" — пропуск, omitempty —
// пропуск нулевого значения, как undefined в JS).
package jsjson
import (
"bytes"
"encoding/json"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"unicode/utf8"
)
// KV — пара ключ/значение упорядоченного объекта.
type KV struct {
Key string
Val any
}
// Object — JS-объект с сохранённым порядком ключей.
type Object []KV
// Get возвращает значение по ключу.
func (o Object) Get(key string) (any, bool) {
for _, kv := range o {
if kv.Key == key {
return kv.Val, true
}
}
return nil, false
}
// Raw — уже готовый JSON, вставляется как есть (без проверки).
type Raw []byte
// Marshal — аналог JSON.stringify(v).
func Marshal(v any) ([]byte, error) {
var b bytes.Buffer
if err := encode(&b, reflect.ValueOf(v), "", ""); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// MarshalIndent — аналог JSON.stringify(v, null, indent) (у нас indent = " ").
func MarshalIndent(v any, indent string) ([]byte, error) {
var b bytes.Buffer
if err := encode(&b, reflect.ValueOf(v), indent, ""); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// MustMarshal — для значений, которые заведомо сериализуемы.
func MustMarshal(v any) []byte {
b, err := Marshal(v)
if err != nil {
panic(err)
}
return b
}
var (
objectType = reflect.TypeOf(Object(nil))
rawType = reflect.TypeOf(Raw(nil))
jsonRaw = reflect.TypeOf(json.RawMessage(nil))
jsonNumber = reflect.TypeOf(json.Number(""))
)
func encode(b *bytes.Buffer, v reflect.Value, indent, cur string) error {
if !v.IsValid() {
b.WriteString("null")
return nil
}
switch v.Type() {
case objectType:
return encodeObject(b, v.Interface().(Object), indent, cur)
case rawType:
b.Write(v.Bytes())
return nil
case jsonRaw:
raw := v.Bytes()
if len(raw) == 0 {
b.WriteString("null")
return nil
}
// JSON.stringify(JSON.parse(x)): нормализуем, не доверяя форме исходника.
val, err := Decode(raw)
if err != nil {
return err
}
return encode(b, reflect.ValueOf(val), indent, cur)
case jsonNumber:
f, err := strconv.ParseFloat(v.String(), 64)
if err != nil {
return err
}
b.WriteString(Number(f))
return nil
}
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
if v.IsNil() {
b.WriteString("null")
return nil
}
return encode(b, v.Elem(), indent, cur)
case reflect.Bool:
if v.Bool() {
b.WriteString("true")
} else {
b.WriteString("false")
}
case reflect.String:
writeString(b, v.String())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
b.WriteString(Number(float64(v.Int())))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
b.WriteString(Number(float64(v.Uint())))
case reflect.Float32, reflect.Float64:
b.WriteString(Number(v.Float()))
case reflect.Slice:
if v.IsNil() {
// nil-срез — это пустой массив, а не null: в JS неоткуда взяться null вместо []
b.WriteString("[]")
return nil
}
fallthrough
case reflect.Array:
return encodeArray(b, v, indent, cur)
case reflect.Map:
// В JS порядок ключей — порядок вставки; у Go-map его нет. Разрешаем только
// map[string]..., сортируя ключи, — вызывающий отвечает за то, что это совпадает.
if v.Type().Key().Kind() != reflect.String {
return fmt.Errorf("jsjson: map key must be string, got %s", v.Type().Key())
}
keys := v.MapKeys()
ks := make([]string, len(keys))
for i, k := range keys {
ks[i] = k.String()
}
sortStrings(ks)
obj := make(Object, 0, len(ks))
for _, k := range ks {
obj = append(obj, KV{k, v.MapIndex(reflect.ValueOf(k).Convert(v.Type().Key())).Interface()})
}
return encodeObject(b, obj, indent, cur)
case reflect.Struct:
return encodeObject(b, structObject(v), indent, cur)
default:
return fmt.Errorf("jsjson: unsupported kind %s", v.Kind())
}
return nil
}
func encodeArray(b *bytes.Buffer, v reflect.Value, indent, cur string) error {
n := v.Len()
if n == 0 {
b.WriteString("[]")
return nil
}
b.WriteByte('[')
inner := cur + indent
for i := 0; i < n; i++ {
if i > 0 {
b.WriteByte(',')
}
if indent != "" {
b.WriteByte('\n')
b.WriteString(inner)
}
// undefined/функция в массиве — null; у нас таких нет, кроме nil
if err := encode(b, v.Index(i), indent, inner); err != nil {
return err
}
}
if indent != "" {
b.WriteByte('\n')
b.WriteString(cur)
}
b.WriteByte(']')
return nil
}
// Omit — значение, которое JSON.stringify пропускает (undefined): ключ не выводится.
type omit struct{}
// Undefined — маркер «ключа нет», как undefined в JS-объекте.
var Undefined = omit{}
func encodeObject(b *bytes.Buffer, o Object, indent, cur string) error {
first := true
inner := cur + indent
b.WriteByte('{')
for _, kv := range o {
if _, skip := kv.Val.(omit); skip {
continue
}
if !first {
b.WriteByte(',')
}
first = false
if indent != "" {
b.WriteByte('\n')
b.WriteString(inner)
}
writeString(b, kv.Key)
b.WriteByte(':')
if indent != "" {
b.WriteByte(' ')
}
if err := encode(b, reflect.ValueOf(kv.Val), indent, inner); err != nil {
return err
}
}
if !first && indent != "" {
b.WriteByte('\n')
b.WriteString(cur)
}
b.WriteByte('}')
return nil
}
func structObject(v reflect.Value) Object {
t := v.Type()
obj := make(Object, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if !f.IsExported() {
continue
}
name := f.Name
omitEmpty := false
if tag, ok := f.Tag.Lookup("json"); ok {
if tag == "-" {
continue
}
parts := strings.Split(tag, ",")
if parts[0] != "" {
name = parts[0]
}
for _, p := range parts[1:] {
if p == "omitempty" {
omitEmpty = true
}
}
}
fv := v.Field(i)
if omitEmpty && isEmpty(fv) {
continue
}
obj = append(obj, KV{name, fv.Interface()})
}
return obj
}
func isEmpty(v reflect.Value) bool {
switch v.Kind() {
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
return v.Len() == 0
case reflect.Bool:
return !v.Bool()
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() == 0
case reflect.Float32, reflect.Float64:
return v.Float() == 0
case reflect.Interface, reflect.Pointer:
return v.IsNil()
}
return false
}
// writeString экранирует строку как JSON.stringify (ES2019): " \ и управляющие
// символы; \b \f \n \r \t коротко, прочие < 0x20 — \u00xx строчными. Не трогает
// / < > & и U+2028/2029. Неверный UTF-8 заменяется на U+FFFD (в JS его не бывает).
func writeString(b *bytes.Buffer, s string) {
b.WriteByte('"')
for i := 0; i < len(s); {
c := s[i]
if c < utf8.RuneSelf {
switch c {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\b':
b.WriteString(`\b`)
case '\f':
b.WriteString(`\f`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if c < 0x20 {
fmt.Fprintf(b, `\u%04x`, c)
} else {
b.WriteByte(c)
}
}
i++
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
if r == utf8.RuneError && size == 1 {
b.WriteString("�")
} else {
b.WriteString(s[i : i+size])
}
i += size
}
b.WriteByte('"')
}
// Number форматирует число как String(x) в JS (Number::toString), а NaN/±Infinity —
// как null, что делает JSON.stringify.
func Number(x float64) string {
if math.IsNaN(x) || math.IsInf(x, 0) {
return "null"
}
if x == 0 {
return "0" // и -0
}
neg := x < 0
if neg {
x = -x
}
// Кратчайшие цифры, однозначно восстанавливающие x: d.ddddde±n
e := strconv.FormatFloat(x, 'e', -1, 64)
mant, expStr, _ := strings.Cut(e, "e")
digits := strings.Replace(mant, ".", "", 1)
exp, _ := strconv.Atoi(expStr)
k := len(digits) // число значащих цифр
n := exp + 1 // позиция десятичной точки (ECMA-262 Number::toString)
var out string
switch {
case k <= n && n <= 21:
out = digits + strings.Repeat("0", n-k)
case 0 < n && n <= 21:
out = digits[:n] + "." + digits[n:]
case -6 < n && n <= 0:
out = "0." + strings.Repeat("0", -n) + digits
default:
sign := "+"
if n-1 < 0 {
sign = "-"
}
ee := n - 1
if ee < 0 {
ee = -ee
}
if k == 1 {
out = digits + "e" + sign + strconv.Itoa(ee)
} else {
out = digits[:1] + "." + digits[1:] + "e" + sign + strconv.Itoa(ee)
}
}
if neg {
return "-" + out
}
return out
}
func sortStrings(s []string) {
// вставками: ключей мало, а сортировка строк в JS-порядке — по UTF-16, что для
// наших ASCII-ключей совпадает с побайтовой
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
// Get2 — Get без признака наличия (nil, если ключа нет); для цепочек разбора.
func (o Object) Get2(key string) any {
v, _ := o.Get(key)
return v
}
+84
View File
@@ -0,0 +1,84 @@
package jsjson
import (
"encoding/json"
"os"
"testing"
)
type fixture struct {
Strings []struct{ In, Out string } `json:"strings"`
Numbers []struct {
In json.RawMessage `json:"in"`
Out string `json:"out"`
Str string `json:"str"`
} `json:"numbers"`
Objects []struct {
In json.RawMessage `json:"in"`
Compact string `json:"compact"`
Indent2 string `json:"indent2"`
} `json:"objects"`
}
// Эталон — вывод JSON.stringify самого Node (tools/golden/jsjson.mjs).
func TestAgainstNode(t *testing.T) {
raw, err := os.ReadFile("../../testdata/golden/jsjson.json")
if err != nil {
t.Fatal(err)
}
var f fixture
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
for _, c := range f.Strings {
if got := string(MustMarshal(c.In)); got != c.Out {
t.Errorf("string %q: got %s want %s", c.In, got, c.Out)
}
}
for _, c := range f.Numbers {
v, err := Decode(c.In)
if err != nil {
t.Fatal(err)
}
if s, ok := v.(string); ok { // NaN / Infinity переданы строкой
_ = s
continue
}
x := v.(float64)
if got := Number(x); got != c.Str {
t.Errorf("String(%s): got %s want %s", c.In, got, c.Str)
}
if got := string(MustMarshal(x)); got != c.Out {
t.Errorf("JSON.stringify(%s): got %s want %s", c.In, got, c.Out)
}
}
for _, c := range f.Objects {
v, err := Decode(c.In)
if err != nil {
t.Fatal(err)
}
if got := string(MustMarshal(v)); got != c.Compact {
t.Errorf("compact:\n got %s\nwant %s", got, c.Compact)
}
got, _ := MarshalIndent(v, " ")
if string(got) != c.Indent2 {
t.Errorf("indent:\n got %s\nwant %s", got, c.Indent2)
}
}
}
func TestStructOrderAndOmit(t *testing.T) {
type S struct {
B string `json:"b"`
A int `json:"a"`
Opt string `json:"opt,omitempty"`
Skip string `json:"-"`
}
if got := string(MustMarshal(S{B: "x", A: 1})); got != `{"b":"x","a":1}` {
t.Fatal(got)
}
o := Object{{"x", 1}, {"gone", Undefined}, {"y", []string(nil)}}
if got := string(MustMarshal(o)); got != `{"x":1,"y":[]}` {
t.Fatal(got)
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package logx — простой логгер: человекочитаемые строки в консоль и JSON-строки в
// файл (как winston в Node-версии: console «<ts> level: msg», файл {level,message,timestamp,…}).
package logx
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
type Logger struct {
mu sync.Mutex
out io.Writer
file *os.File
}
// New пишет в stdout и (если dir не пуст) в dir/app.log.
func New(out io.Writer, dir string) *Logger {
l := &Logger{out: out}
if dir != "" {
if err := os.MkdirAll(dir, 0o755); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "app.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
l.file = f
}
}
}
return l
}
func (l *Logger) log(level, msg string, kv []any) {
l.mu.Lock()
defer l.mu.Unlock()
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if l.out != nil {
fmt.Fprintf(l.out, "%s %s: %s\n", ts, level, msg)
}
if l.file != nil {
m := map[string]any{"level": level, "message": msg, "timestamp": ts}
for i := 0; i+1 < len(kv); i += 2 {
if k, ok := kv[i].(string); ok {
if e, isErr := kv[i+1].(error); isErr {
m[k] = e.Error()
} else {
m[k] = kv[i+1]
}
}
}
b, _ := json.Marshal(m)
l.file.Write(append(b, '\n'))
}
}
func (l *Logger) Info(msg string, kv ...any) { l.log("info", msg, kv) }
func (l *Logger) Warn(msg string, kv ...any) { l.log("warn", msg, kv) }
func (l *Logger) Error(msg string, kv ...any) { l.log("error", msg, kv) }
func (l *Logger) Debug(msg string, kv ...any) { l.log("debug", msg, kv) }
+274
View File
@@ -0,0 +1,274 @@
{
"strings": [
{
"in": "",
"out": "\"\""
},
{
"in": "plain",
"out": "\"plain\""
},
{
"in": "кириллица",
"out": "\"кириллица\""
},
{
"in": "emoji 😀",
"out": "\"emoji 😀\""
},
{
"in": "<r 2><b 0x85>",
"out": "\"<r 2><b 0x85>\""
},
{
"in": "a&b<c>d",
"out": "\"a&b<c>d\""
},
{
"in": "slash/и\\обратный",
"out": "\"slash/и\\\\обратный\""
},
{
"in": "quote\"q",
"out": "\"quote\\\"q\""
},
{
"in": "line\nbreak",
"out": "\"line\\nbreak\""
},
{
"in": "tab\tcr\r",
"out": "\"tab\\tcr\\r\""
},
{
"in": "\b\f",
"out": "\"\\b\\f\""
},
{
"in": "\u0000\u0001\u001f",
"out": "\"\\u0000\\u0001\\u001f\""
},
{
"in": "

",
"out": "\"

\""
},
{
"in": "I2 = ",
"out": "\"I2 = \""
},
{
"in": " spaces ",
"out": "\" spaces \""
},
{
"in": "ü",
"out": "\"ü\""
},
{
"in": "日本語",
"out": "\"日本語\""
}
],
"numbers": [
{
"in": 0,
"out": "0",
"str": "0"
},
{
"in": 0,
"out": "0",
"str": "0"
},
{
"in": 1,
"out": "1",
"str": "1"
},
{
"in": -1,
"out": "-1",
"str": "-1"
},
{
"in": 1376,
"out": "1376",
"str": "1376"
},
{
"in": 47619,
"out": "47619",
"str": "47619"
},
{
"in": 0.1,
"out": "0.1",
"str": "0.1"
},
{
"in": 0.5,
"out": "0.5",
"str": "0.5"
},
{
"in": 1.5,
"out": "1.5",
"str": "1.5"
},
{
"in": 2.5,
"out": "2.5",
"str": "2.5"
},
{
"in": 100.25,
"out": "100.25",
"str": "100.25"
},
{
"in": 1e+21,
"out": "1e+21",
"str": "1e+21"
},
{
"in": 100000000000000000000,
"out": "100000000000000000000",
"str": "100000000000000000000"
},
{
"in": 123456789012345680000,
"out": "123456789012345680000",
"str": "123456789012345680000"
},
{
"in": 1e-7,
"out": "1e-7",
"str": "1e-7"
},
{
"in": 0.000001,
"out": "0.000001",
"str": "0.000001"
},
{
"in": 0.000001234,
"out": "0.000001234",
"str": "0.000001234"
},
{
"in": 1.7976931348623157e+308,
"out": "1.7976931348623157e+308",
"str": "1.7976931348623157e+308"
},
{
"in": 5e-324,
"out": "5e-324",
"str": "5e-324"
},
{
"in": 3.14159,
"out": "3.14159",
"str": "3.14159"
},
{
"in": -273.15,
"out": "-273.15",
"str": "-273.15"
},
{
"in": 9007199254740992,
"out": "9007199254740992",
"str": "9007199254740992"
},
{
"in": 9007199254740994,
"out": "9007199254740994",
"str": "9007199254740994"
},
{
"in": 0.30000000000000004,
"out": "0.30000000000000004",
"str": "0.30000000000000004"
},
{
"in": 250.5,
"out": "250.5",
"str": "250.5"
},
{
"in": "NaN",
"out": "null",
"str": "NaN"
},
{
"in": "Infinity",
"out": "null",
"str": "Infinity"
},
{
"in": "-Infinity",
"out": "null",
"str": "-Infinity"
}
],
"objects": [
{
"in": {
"a": 1,
"b": "x",
"c": [
1,
2,
{
"d": null
}
],
"e": true,
"f": false
},
"compact": "{\"a\":1,\"b\":\"x\",\"c\":[1,2,{\"d\":null}],\"e\":true,\"f\":false}",
"indent2": "{\n \"a\": 1,\n \"b\": \"x\",\n \"c\": [\n 1,\n 2,\n {\n \"d\": null\n }\n ],\n \"e\": true,\n \"f\": false\n}"
},
{
"in": {
"rekey": false,
"endpoints": [
"1.2.3.4:443"
],
"servers": [
{
"id": 0,
"name": "VPN & <co>
",
"awg": {
"Jc": "6",
"I1": "<r 2>"
}
}
]
},
"compact": "{\"rekey\":false,\"endpoints\":[\"1.2.3.4:443\"],\"servers\":[{\"id\":0,\"name\":\"VPN & <co>
\",\"awg\":{\"Jc\":\"6\",\"I1\":\"<r 2>\"}}]}",
"indent2": "{\n \"rekey\": false,\n \"endpoints\": [\n \"1.2.3.4:443\"\n ],\n \"servers\": [\n {\n \"id\": 0,\n \"name\": \"VPN & <co>
\",\n \"awg\": {\n \"Jc\": \"6\",\n \"I1\": \"<r 2>\"\n }\n }\n ]\n}"
},
{
"in": {
"empty": {},
"arr": [],
"nested": {
"x": []
}
},
"compact": "{\"empty\":{},\"arr\":[],\"nested\":{\"x\":[]}}",
"indent2": "{\n \"empty\": {},\n \"arr\": [],\n \"nested\": {\n \"x\": []\n }\n}"
},
{
"in": [],
"compact": "[]",
"indent2": "[]"
},
{
"in": {},
"compact": "{}",
"indent2": "{}"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
# Дифференциальный стенд: TS- и Go-версии awg-ctrl против одних и тех же заглушек.
FROM node:20-bookworm
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends openssl sqlite3 >/dev/null && rm -rf /var/lib/apt/lists/*
COPY tools/diffbench/stubs/stub.mjs /opt/stub/stub.mjs
RUN for c in awg awg-quick ip tc nft modinfo; do printf '#!/bin/sh\nexec node /opt/stub/stub.mjs %s "$@"\n' "$c" > /usr/local/bin/$c; chmod +x /usr/local/bin/$c; done
# TS-эталон: тот же код, что в репозитории, со своими node_modules под linux
COPY awg-ctrl/package.json /ts/awg-ctrl/package.json
RUN cd /ts/awg-ctrl && npm install --no-audit --no-fund --loglevel=error
COPY awg-ctrl/index.ts awg-ctrl/shaping.ts awg-ctrl/tsconfig.json /ts/awg-ctrl/
COPY tools/diffbench/setup.sh tools/diffbench/run.sh tools/diffbench/scenario-ctrl.mjs tools/diffbench/collect.mjs /bench/
WORKDIR /bench
+20
View File
@@ -0,0 +1,20 @@
// Состояние после сценария: conf, файлы каталога, строки баз, журнал и состояние заглушек.
import fs from "fs";
import { execSync } from "child_process";
const out = process.argv[2];
const t = JSON.parse(fs.readFileSync(out, "utf8"));
const D = "/etc/amnezia/amneziawg";
const q = sql => JSON.parse(execSync(`sqlite3 -json ${D}/users.db "${sql}"`).toString() || "[]");
t.state = {
conf: fs.readFileSync(`${D}/awg1.conf`, "utf8"),
confMode: (fs.statSync(`${D}/awg1.conf`).mode & 0o777).toString(8),
files: fs.readdirSync(D).map(f => f.replace(/\d{4}-\d\d-\d\dT[\d-]+Z/, "<ts>")).sort(),
users: q("select * from users order by rowid"),
peers: q("select * from peers order by rowid"),
config: q("select * from config order by key"),
schema: execSync(`sqlite3 ${D}/users.db .schema`).toString(),
stubLog: fs.readFileSync("/var/lib/stub/log.jsonl", "utf8").split("\n").filter(Boolean).map(l => JSON.parse(l)),
stubState: JSON.parse(fs.readFileSync("/var/lib/stub/state.json", "utf8")),
};
fs.writeFileSync(out, JSON.stringify(t, null, 1));
console.log(`collect: done`);
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# run.sh <ts|go> <имя-прогона>; окружение: GEN, WITH_I1, STUB_MODULE
set -e
IMPL="$1"; NAME="$2"
./setup.sh
export PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key \
SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl
cd /ts/awg-ctrl
if [ "$IMPL" = "ts" ]; then
node node_modules/.bin/tsx index.ts > /run/bench/ctrl.log 2>&1 &
else
/go/awg-control ctrl > /run/bench/ctrl.log 2>&1 &
fi
PID=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 100 ] && { cat /run/bench/ctrl.log; exit 1; }; sleep 0.1; done
sleep 1 # стартовый apply шейпинга асинхронный — даём ему отработать до сценария
cd /bench
node scenario-ctrl.mjs "/out/$NAME.json"
kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true
node collect.mjs "/out/$NAME.json"
cp /run/bench/ctrl.log "/out/$NAME.log"
+112
View File
@@ -0,0 +1,112 @@
// Сценарий для awg-ctrl: одинаковая последовательность запросов к любой реализации.
// Пишет транскрипт (запрос → статус, тип, тело) в JSON; collect.mjs дописывает состояние.
import fs from "fs";
import crypto from "crypto";
import { execSync } from "child_process";
const BASE = "http://127.0.0.1:3005";
const KEY = crypto.createPrivateKey(fs.readFileSync("/etc/amnezia/amneziawg/internal_auth_private.key"));
const out = process.argv[2];
function token(iss = "bench") {
const h = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url");
const t = Math.floor(Date.now() / 1000);
const p = Buffer.from(JSON.stringify({ iss, iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), KEY).toString("base64url")}`;
}
const key = s => crypto.createHash("sha256").update(s).digest("base64");
const url64 = k => Buffer.from(k, "base64").toString("base64url");
const P1 = key("dev1"), P2 = key("dev2"), P3 = key("dev3"), GHOST = key("ghost");
const steps = [];
async function call(name, method, path, body, { auth = true, raw, headers = {} } = {}) {
const h = { ...headers };
if (auth) h.authorization = `Bearer ${typeof auth === "string" ? auth : token()}`;
let data;
if (raw !== undefined) { h["content-type"] = "application/json"; data = raw; }
else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); }
const r = await fetch(BASE + path, { method, headers: h, body: data });
const ctype = (r.headers.get("content-type") ?? "").split(";")[0];
const text = await r.text();
let parsed = text;
if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch {} }
steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined });
return parsed;
}
const gen = process.env.GEN ?? "2";
await call("health", "GET", "/health", undefined, { auth: false });
await call("no token", "GET", "/api/users", undefined, { auth: false });
await call("bad token", "GET", "/api/users", undefined, { auth: "a.b.c" });
await call("expired token", "GET", "/api/users", undefined, { auth: (() => {
const h = Buffer.from(JSON.stringify({ alg: "EdDSA", typ: "JWT" })).toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "x", iat: 1, exp: 2 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), KEY).toString("base64url")}`;
})() });
await call("users empty", "GET", "/api/users");
await call("add alice", "POST", "/api/users", { name: "alice" });
await call("add bob", "POST", "/api/users", { name: "bob" });
await call("add alice again", "POST", "/api/users", { name: "alice" });
await call("add bad name", "POST", "/api/users", { name: "a b" });
await call("add long name", "POST", "/api/users", { name: "x".repeat(33) });
await call("add no body", "POST", "/api/users");
await call("add too big", "POST", "/api/users", undefined, { raw: JSON.stringify({ name: "y", pad: "z".repeat(2000) }) });
await call("add broken json", "POST", "/api/users", undefined, { raw: "{\"name\":" });
await call("users", "GET", "/api/users");
await call("stats", "GET", "/api/users/stats");
await call("fetch alice", "POST", "/api/users/alice");
await call("fetch nobody", "POST", "/api/users/nobody");
await call("fetch bad name", "POST", "/api/users/a%20b");
await call("add peer", "POST", "/api/peers", { pub_key: P1, owner: "abcdef123456/m1/d1" });
await call("add peer dup", "POST", "/api/peers", { pub_key: P1, owner: "abcdef123456/m1/d1" });
await call("add peer bad owner", "POST", "/api/peers", { pub_key: P2, owner: "bad owner!" });
await call("add peer bad key", "POST", "/api/peers", { pub_key: "nope", owner: "abcdef123456/m1/d2" });
await call("add peer legacy", "POST", "/api/peers", { pub_key: P3, owner: "m7/d9" });
await call("peers", "GET", "/api/peers");
await call("rekey peer", "PUT", `/api/peers/${url64(P1)}`, { pub_key: P2 });
await call("rekey missing", "PUT", `/api/peers/${url64(key("missing"))}`, { pub_key: key("other") });
await call("rekey to taken", "PUT", `/api/peers/${url64(P2)}`, { pub_key: P3 });
await call("rotate psk", "POST", `/api/peers/${url64(P2)}/psk`);
await call("rotate psk missing", "POST", `/api/peers/${url64(key("missing"))}/psk`);
await call("profile", "GET", "/api/profile");
await call("add peer 4", "POST", "/api/peers", { pub_key: key("dev4"), owner: "abcdef123456/m1/d4" });
await call("sync bad owner", "PUT", "/api/peers/sync", { owner: "BAD", keep: [] });
await call("sync bad keep", "PUT", "/api/peers/sync", { owner: "abcdef123456", keep: "x" });
await call("sync keep P2 + legacy P3", "PUT", "/api/peers/sync", { owner: "abcdef123456", keep: [P2, P3] });
await call("peers after sync", "GET", "/api/peers");
execSync(`awg set awg1 peer ${GHOST} allowed-ips 10.9.9.9/32`);
await call("audit", "GET", "/api/audit");
await call("audit clean bad", "POST", "/api/audit/clean", { pub_keys: "x" });
await call("audit clean", "POST", "/api/audit/clean", { pub_keys: [GHOST, P2] });
await call("delete peer", "DELETE", `/api/peers/${url64(P2)}`);
await call("delete peer missing", "DELETE", `/api/peers/${url64(P2)}`);
await call("delete bob", "DELETE", "/api/users/bob");
await call("delete nobody", "DELETE", "/api/users/nobody");
await call("shaping", "GET", "/api/shaping");
await call("shaping bad", "PUT", "/api/shaping", { enabled: "yes", auto: true });
await call("shaping bad mbit", "PUT", "/api/shaping", { enabled: true, auto: true, downMbit: 1.5, upMbit: 10 });
await call("shaping missing width", "PUT", "/api/shaping", { enabled: true, auto: true, downMbit: 0, upMbit: 10 });
await call("shaping on", "PUT", "/api/shaping", { enabled: true, auto: false, downMbit: 285, upMbit: 280 });
await call("shaping get on", "GET", "/api/shaping");
await call("stats priority", "GET", "/api/users/stats");
await call("metrics", "GET", "/api/metrics");
await call("shaping off", "PUT", "/api/shaping", { enabled: false, auto: true, downMbit: 0, upMbit: 0 });
await call("monitor job", "GET", "/api/monitor/job");
await call("monitor install (no cli)", "POST", "/api/monitor/install");
await call("awg status", "GET", "/awg/status");
await call("awg start (already up)", "POST", "/awg/start");
await call("reissue", "POST", "/api/users/reissue");
await call("users after reissue", "GET", "/api/users");
await call("awg restart", "POST", "/awg/restart");
await call("health after restart", "GET", "/health", undefined, { auth: false });
await call("upgrade", "POST", "/awg/upgrade");
await call("profile after upgrade", "GET", "/api/profile");
await call("users after upgrade", "GET", "/api/users");
await call("fetch alice after upgrade", "POST", "/api/users/alice");
await call("health final", "GET", "/health", undefined, { auth: false });
await call("unknown route", "GET", "/api/nope");
await call("wrong method", "PATCH", "/api/users");
fs.writeFileSync(out, JSON.stringify({ gen, steps }, null, 1));
console.log(`scenario: ${steps.length} steps → ${out}`);
+53
View File
@@ -0,0 +1,53 @@
#!/bin/sh
# Чистое состояние «сервера»: awg1.conf нужного поколения, ключи, пустые базы, заглушки.
# GEN=2|3.1, WITH_I1=1 — добавить I1 в conf (install.sh его не пишет).
set -e
GEN="${GEN:-2}"
D=/etc/amnezia/amneziawg
rm -rf /etc/amnezia /var/lib/stub /run/bench
mkdir -p "$D" /var/lib/stub /run/bench
node -e 'const c=require("crypto");process.stdout.write(c.createHash("sha256").update("server").digest("base64"))' > /etc/amnezia/server_private.key
chmod 600 /etc/amnezia/server_private.key
node -e 'const c=require("crypto");const p=require("fs").readFileSync("/etc/amnezia/server_private.key","utf8").trim();process.stdout.write(c.createHash("sha256").update("pub:"+p).digest("base64")+"\n")' > "$D/server_public.key"
openssl genpkey -algorithm ed25519 -out "$D/internal_auth_private.key" 2>/dev/null
openssl pkey -in "$D/internal_auth_private.key" -pubout -out "$D/internal_auth_public.key"
PRIV=$(cat /etc/amnezia/server_private.key)
cat > "$D/awg1.conf" <<CONF
[Interface]
Address = 10.9.0.1/16
MTU = 1376
PostUp = awg set awg1 private-key /etc/amnezia/server_private.key; iptables -A FORWARD -i awg1 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PreDown = iptables -D FORWARD -i awg1 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
ListenPort = 47619
PrivateKey = ${PRIV}
Jc = 6
Jmin = 10
Jmax = 50
S1 = 90
S2 = 45
S3 = 37
S4 = ${S4:-14}
H1 = 1224800044-2116730834
H2 = 2122053282-2133204808
H3 = 2133604274-2140756116
H4 = 2143656228-2147444225
CONF
if [ "${WITH_I1:-0}" = "1" ]; then
echo 'I1 = <r 2><b 0x858000010001000000000669636c6f756403636f6d0000010001c00c000100010000105a00044d583737>' >> "$D/awg1.conf"
fi
if [ "$GEN" = "3.1" ]; then
cat >> "$D/awg1.conf" <<CONF
HeaderProtectionKey = AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
ContentPaddingAddition = 10-100
RekeyAfterTime = 100-120
RekeyTimeout = 3-7
RejectAfterTime = 150-180
KeepaliveTimeout = 5-15
MaxHandshakeAttempts = 15-20
CONF
fi
chmod 600 "$D/awg1.conf"
echo '{"up":false,"n":0,"peers":{}}' > /var/lib/stub/state.json
: > /var/lib/stub/log.jsonl
awg-quick up awg1
: > /var/lib/stub/log.jsonl
+143
View File
@@ -0,0 +1,143 @@
// Заглушки системных команд для дифференциального стенда: awg, awg-quick, ip, tc, nft, modinfo.
// Детерминированные ключи (счётчик), состояние интерфейса в JSON, журнал каждого вызова.
// Одинаково ведут себя для TS- и Go-версии — сравнивается то, что версии с ними делают.
import fs from "fs";
import crypto from "crypto";
const DIR = "/var/lib/stub";
const STATE = `${DIR}/state.json`;
const LOG = `${DIR}/log.jsonl`;
const [, , cmd, ...args] = process.argv;
const load = () => { try { return JSON.parse(fs.readFileSync(STATE, "utf8")); } catch { return { up: false, n: 0, peers: {} }; } };
const save = s => fs.writeFileSync(STATE, JSON.stringify(s, null, 1));
const b64 = s => crypto.createHash("sha256").update(s).digest("base64");
const pubOf = priv => b64("pub:" + priv.trim());
const readStdin = () => { try { return fs.readFileSync(0, "utf8"); } catch { return ""; } };
let stdin = "";
const needsStdin = (cmd === "awg" && args[0] === "pubkey") || (cmd === "nft" && args.includes("-"));
if (needsStdin) stdin = readStdin();
const entry = { cmd, args };
if (stdin) entry.stdin = stdin;
// файлы, которые передают по пути (PSK, syncconf), пишем в журнал содержимым: имя у них со временем
const fileArgs = [];
for (let i = 0; i < args.length; i++) {
if (["preshared-key", "private-key"].includes(args[i]) || (cmd === "awg" && args[0] === "syncconf" && i === 2)) {
try { fileArgs.push(fs.readFileSync(args[i + (args[0] === "syncconf" ? 0 : 1)], "utf8")); } catch { fileArgs.push("<unreadable>"); }
}
}
if (fileArgs.length) entry.files = fileArgs;
fs.appendFileSync(LOG, JSON.stringify(entry) + "\n");
const out = s => process.stdout.write(s);
const st = load();
function parsePeers(text) {
const peers = {};
for (const block of text.split(/^\[Peer\]/m).slice(1)) {
const g = k => block.match(new RegExp(`^\\s*${k}\\s*=\\s*(.*)$`, "m"))?.[1]?.trim();
const pub = g("PublicKey");
if (pub) peers[pub] = { psk: g("PresharedKey") ?? "", allowed: g("AllowedIPs") ?? "", ka: g("PersistentKeepalive") ?? "" };
}
return peers;
}
function serverPub() {
try { return pubOf(fs.readFileSync("/etc/amnezia/server_private.key", "utf8")); } catch { return "(none)"; }
}
switch (cmd) {
case "awg": {
const [sub, iface, ...rest] = args;
if (sub === "--version") { out("awg-tools v1.0.20210914 - https://amnezia.org\n"); break; }
if (sub === "genkey") { st.n++; save(st); out(b64("priv" + st.n) + "\n"); break; }
if (sub === "genpsk") { st.n++; save(st); out(b64("psk" + st.n) + "\n"); break; }
if (sub === "pubkey") { out(pubOf(stdin) + "\n"); break; }
if (sub === "show") {
if (!st.up) { process.stderr.write(`Unable to access interface: No such device\n`); process.exit(1); }
const pubs = Object.keys(st.peers);
if (rest[0] === "dump") {
out(`(hidden)\t${serverPub()}\t47619\toff\n`);
for (const p of pubs) out(`${p}\t(hidden)\t(none)\t${st.peers[p].allowed}\t0\t0\t0\t${st.peers[p].ka || "off"}\n`);
} else if (rest[0] === "peers") {
for (const p of pubs) out(p + "\n");
} else {
out(`interface: ${iface}\n public key: ${serverPub()}\n private key: (hidden)\n listening port: 47619\n`);
for (const p of pubs) out(`\npeer: ${p}\n preshared key: (hidden)\n allowed ips: ${st.peers[p].allowed}\n`);
}
break;
}
if (sub === "set") {
if (!st.up) { process.stderr.write("Unable to modify interface: No such device\n"); process.exit(1); }
const i = rest.indexOf("peer");
if (i >= 0) {
const pub = rest[i + 1];
if (rest.includes("remove")) { delete st.peers[pub]; }
else {
const p = st.peers[pub] ?? { psk: "", allowed: "", ka: "" };
const k = rest.indexOf("allowed-ips"); if (k >= 0) p.allowed = rest[k + 1];
const a = rest.indexOf("persistent-keepalive"); if (a >= 0) p.ka = rest[a + 1];
const s = rest.indexOf("preshared-key"); if (s >= 0) { try { p.psk = fs.readFileSync(rest[s + 1], "utf8").trim(); } catch {} }
st.peers[pub] = p;
}
}
save(st);
break;
}
if (sub === "syncconf") {
if (!st.up) { process.stderr.write("Unable to modify interface: No such device\n"); process.exit(1); }
st.peers = parsePeers(fs.readFileSync(rest[0], "utf8"));
save(st);
break;
}
process.stderr.write(`stub awg: unsupported ${args.join(" ")}\n`); process.exit(1);
}
case "awg-quick": {
const [sub] = args;
if (sub === "up") {
if (st.up) { process.stderr.write("awg-quick: `awg1' already exists\n"); process.exit(1); }
if (process.env.STUB_UP_FAIL === "1") { process.stderr.write("RTNETLINK answers: Invalid argument\n"); process.exit(1); }
st.up = true;
try { st.peers = parsePeers(fs.readFileSync("/etc/amnezia/amneziawg/awg1.conf", "utf8")); } catch { st.peers = {}; }
} else if (sub === "down") {
if (!st.up) { process.stderr.write("awg-quick: `awg1' is not a WireGuard interface\n"); process.exit(1); }
st.up = false; st.peers = {};
}
save(st);
break;
}
case "ip": {
const a = args.join(" ");
if (a === "route show default") out("default via 203.0.113.1 dev eth0 proto static\n");
else if (a.startsWith("addr show")) out("2: eth0: <BROADCAST,MULTICAST,UP> mtu 1500\n inet 203.0.113.7/24 brd 203.0.113.255 scope global eth0\n");
else if (a === "link show ifb-awg") { if (!st.ifb) process.exit(1); out("5: ifb-awg: <BROADCAST,NOARP,UP> mtu 1500\n"); }
else if (a.startsWith("link add ifb-awg")) { st.ifb = true; save(st); }
else if (a.startsWith("link del ifb-awg")) { if (!st.ifb) process.exit(1); st.ifb = false; save(st); }
break;
}
case "tc": {
const a = args.join(" ");
st.tc ??= {};
if (a.startsWith("qdisc replace dev")) { st.tc[args[3] + ":root"] = a; save(st); }
else if (a.startsWith("qdisc add dev") && a.includes("ingress")) { st.tc[args[3] + ":ingress"] = a; save(st); }
else if (a.startsWith("filter add dev")) { st.tc[args[3] + ":filter"] = a; save(st); }
else if (a.startsWith("qdisc del dev")) {
const key = args[3] + ":" + (args[4] === "root" ? "root" : "ingress");
if (!st.tc[key]) { process.stderr.write("Error: Cannot delete qdisc with handle of zero.\n"); process.exit(2); }
delete st.tc[key]; if (args[4] === "ingress") delete st.tc[args[3] + ":filter"]; save(st);
}
else if (a.startsWith("filter show dev")) { if (st.tc[args[3] + ":filter"]) out("filter parent ffff: protocol all pref 1 matchall\n\taction order 1: mirred (Egress Redirect to device ifb-awg) stolen\n"); }
else if (args[0] === "-s" && args[1] === "-j") { const dev = args[5]; out(st.tc[dev + ":root"] ? `[{"kind":"cake","handle":"8001:","root":true,"bytes":1000,"packets":10,"drops":2,"overlimits":0,"requeues":0,"backlog":0,"qlen":0}]\n` : "[]\n"); }
else if (a.startsWith("qdisc show dev")) { if (st.tc[args[3] + ":root"]) out(`qdisc cake 8001: root refcnt 2 bandwidth 285Mbit\n`); }
break;
}
case "nft": {
if (args[0] === "delete") { if (!st.nft) { process.stderr.write("Error: No such file or directory\n"); process.exit(1); } st.nft = false; save(st); }
else if (args.includes("-")) { st.nft = true; save(st); }
break;
}
case "modinfo": out((process.env.STUB_MODULE ?? "1.0.0") + "\n"); break;
default: process.stderr.write(`stub: unknown ${cmd}\n`); process.exit(127);
}
+23
View File
@@ -0,0 +1,23 @@
// Эталон для internal/jsjson: вывод JSON.stringify самого Node на каверзных значениях.
// Запуск: node tools/golden/jsjson.mjs > testdata/golden/jsjson.json
const strings = [
"", "plain", "кириллица", "emoji 😀", "<r 2><b 0x85>", "a&b<c>d", "slash/и\\обратный",
"quote\"q", "line\nbreak", "tab\tcr\r", "\b\f", "\u0000\u0001\u001f\u007f", "

",
"I2 = ", " spaces ", "ü", "日本語",
];
const numbers = [0, -0, 1, -1, 1376, 47619, 0.1, 0.5, 1.5, 2.5, 100.25, 1e21, 1e20, 123456789012345680000,
1e-7, 1e-6, 0.000001234, 1.7976931348623157e308, 5e-324, 3.14159, -273.15, 2 ** 53, 2 ** 53 + 2, 0.1 + 0.2, 250.5,
NaN, Infinity, -Infinity];
const objects = [
{ a: 1, b: "x", c: [1, 2, { d: null }], e: true, f: false },
{ rekey: false, endpoints: ["1.2.3.4:443"], servers: [{ id: 0, name: "VPN & <co>
", awg: { Jc: "6", I1: "<r 2>" } }] },
{ empty: {}, arr: [], nested: { x: [] } },
[],
{},
];
const out = {
strings: strings.map(s => ({ in: s, out: JSON.stringify(s) })),
numbers: numbers.map(n => ({ in: Number.isFinite(n) ? n : String(n), out: JSON.stringify(n), str: String(n) })),
objects: objects.map(o => ({ in: o, compact: JSON.stringify(o), indent2: JSON.stringify(o, null, 2) })),
};
process.stdout.write(JSON.stringify(out, null, 1));