From e9df363dd28beaba9a50de96c47eb6d88e0ff0d9 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Wed, 14 Oct 2026 15:27:05 +0300 Subject: [PATCH] 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 --- .gitignore | 5 +- awg-ctrl/index.ts | 257 ++++++++++++- awg-ctrl/shaping.ts | 338 ++++++++++++++++++ awg-ui/metrics.ts | 235 ++++++++++++ awg-ui/mock-server.ts | 100 +++++- awg-ui/monitor.ts | 165 +++++++++ awg-ui/nodes.ts | 15 +- awg-ui/server.ts | 11 + awg-ui/src/App.css | 15 + awg-ui/src/App.tsx | 36 +- awg-ui/src/components/LineChart.tsx | 194 ++++++++++ awg-ui/src/components/PriorityChip.tsx | 25 ++ awg-ui/src/components/ServerBar.tsx | 15 +- awg-ui/src/components/ServerSettingsModal.tsx | 151 ++++++++ awg-ui/src/components/icons.tsx | 14 + awg-ui/src/lib/shared.ts | 15 + awg-ui/src/tabs/apikeys/apikeys.css | 2 + awg-ui/src/tabs/apikeys/index.tsx | 14 +- awg-ui/src/tabs/apikeys/metadata.json | 1 + awg-ui/src/tabs/audit/audit.css | 39 ++ awg-ui/src/tabs/audit/index.tsx | 193 ++++++++++ awg-ui/src/tabs/audit/metadata.json | 11 + awg-ui/src/tabs/index.ts | 27 +- awg-ui/src/tabs/masterkeys/index.tsx | 6 +- awg-ui/src/tabs/masterkeys/metadata.json | 1 + awg-ui/src/tabs/monitor/index.tsx | 328 +++++++++++++++++ awg-ui/src/tabs/monitor/metadata.json | 11 + awg-ui/src/tabs/monitor/monitor.css | 142 ++++++++ awg-ui/src/tabs/users/index.tsx | 8 +- awg-ui/src/tabs/users/metadata.json | 1 + awg-ui/sub.ts | 204 ++++++++++- cli/src/index.ts | 8 +- cli/src/monitor.ts | 170 +++++++++ 33 files changed, 2708 insertions(+), 49 deletions(-) create mode 100644 awg-ctrl/shaping.ts create mode 100644 awg-ui/metrics.ts create mode 100644 awg-ui/monitor.ts create mode 100644 awg-ui/src/components/LineChart.tsx create mode 100644 awg-ui/src/components/PriorityChip.tsx create mode 100644 awg-ui/src/components/ServerSettingsModal.tsx create mode 100644 awg-ui/src/tabs/audit/audit.css create mode 100644 awg-ui/src/tabs/audit/index.tsx create mode 100644 awg-ui/src/tabs/audit/metadata.json create mode 100644 awg-ui/src/tabs/monitor/index.tsx create mode 100644 awg-ui/src/tabs/monitor/metadata.json create mode 100644 awg-ui/src/tabs/monitor/monitor.css create mode 100644 cli/src/monitor.ts diff --git a/.gitignore b/.gitignore index 48af737..0f3cacd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ yarn-error.log* # Все markdown, кроме README.md *.md -!README.md \ No newline at end of file +!README.md +# Go: бинарники и прогоны дифференциального стенда +tools/diffbench/bin/ +tools/diffbench/out/ diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts index 71b0f8d..2693251 100644 --- a/awg-ctrl/index.ts +++ b/awg-ctrl/index.ts @@ -1,15 +1,16 @@ // Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. -import fs, { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import fs, { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, closeSync } from "fs"; import path from "path"; import os from "os"; import crypto from "crypto"; -import { execSync, spawnSync } from "child_process"; +import { execSync, spawnSync, execFile, spawn } from "child_process"; import * as zlib from "zlib"; import express, { Request, Response, NextFunction } from "express"; import Database from "better-sqlite3"; import winston from "winston"; +import { createShaping } from "./shaping"; const logsDir = path.join(process.cwd(), "logs"); if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); @@ -575,6 +576,26 @@ function getPeersData(): Record 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() { const users = db.prepare("SELECT name, ip, pub_key, psk_key FROM users").all() as UserRow[]; const devices = stmts.peersAll.all(); @@ -680,6 +701,7 @@ function restartAwg() { // в [Peer]-блоки, которые rebuildConf() пересобирает прямо сейчас. const gen = reloadAwgParams(); syncPeers(); + void shaping.apply(); // tc-правила жили на интерфейсе, который мы только что пересоздали const versions = readAwgVersions(); CONFIG.awgModule = versions.module; CONFIG.awgTools = versions.tools; @@ -803,6 +825,7 @@ function startInterface() { const r = spawnSync("awg-quick", ["up", CONFIG.interface]); if (r.status !== 0) throw new Error(`awg-quick up failed: ${r.stderr?.toString()}`); syncPeers(); + void shaping.apply(); return getInterfaceStatus(); } @@ -849,12 +872,30 @@ function addUser(username: string): UserRow { }; } +// Снять пира с интерфейса и убедиться, что его там нет. Раньше результат `awg set … remove` +// не проверялся: строку из базы удаляли, а пир в ядре оставался живым, и про него уже никто не +// знал. Теперь порядок такой: сначала ядро, потом база — при отказе строка остаётся, и +// повторная попытка (надгробие панели, следующий sync) найдёт пира. +function kernelPeers(): Set | 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) { const user = stmts.get.get(username); if (!user) throw new Error("Пользователь не найден"); + if (!dropFromKernel(user.pub_key)) throw new Error("Не удалось снять пира с интерфейса"); stmts.delete.run(username); - spawnSync("awg", ["set", CONFIG.interface, "peer", user.pub_key, "remove"]); rebuildConf(); 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); 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(); logger.info("peer rekeyed", { owner: old.owner, ip: old.ip }); 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) { const peer = stmts.peerGet.get(pub); if (!peer) throw new HttpError(404, "Пир не найден"); + if (!dropFromKernel(pub)) throw new Error("Не удалось снять пира с интерфейса"); stmts.peerDelete.run(pub); - spawnSync("awg", ["set", CONFIG.interface, "peer", pub, "remove"]); rebuildConf(); logger.info("peer removed", { owner: peer.owner }); } @@ -964,18 +1007,15 @@ function syncOwnedPeers(panel: string, keep: string[]): { const prefix = panel + "/"; const removed: PeerRow[] = []; let claimed = 0; - db.transaction(() => { - for (const p of stmts.peersAll.all()) { - if (p.owner.startsWith(prefix)) { - if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); } - } else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) { - stmts.peerOwner.run(prefix + p.owner, p.pub_key); - claimed++; - } + for (const p of stmts.peersAll.all()) { + if (p.owner.startsWith(prefix)) { + // Ядро первым: не снялся — строка остаётся, следующий sync попробует снова. + 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)) { + stmts.peerOwner.run(prefix + p.owner, p.pub_key); + claimed++; } - })(); - - for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]); + } if (removed.length || claimed) rebuildConf(); 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 jsonSmall = express.json({ limit: "1kb" }); 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 { try { @@ -1075,6 +1117,8 @@ app.get("/api/users/stats", auth, handler((_req, res) => { lastHandshake: peers[u.pub_key]?.lastHandshake ?? 0, rx: peers[u.pub_key]?.rx ?? 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, rx: stats[p.pub_key]?.rx ?? 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([ + ...(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» за ключ. app.put(SYNC_PATH, auth, handler((req, res) => { 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; + 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 } | 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 = {}; + 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) => { res.json(getInterfaceStatus()); })); @@ -1188,3 +1407,7 @@ app.listen(SERVER.port, "127.0.0.1", () => { }); ensureInterfaceUp(); syncPeers(); +// Обязательно и на старте: при загрузке интерфейс поднимает systemd (ExecStartPre), +// ни один из путей awg-quick up внутри awg-ctrl тогда не срабатывает. +void shaping.apply(); +shaping.start(); diff --git a/awg-ctrl/shaping.ts b/awg-ctrl/shaping.ts new file mode 100644 index 0000000..c92fb2e --- /dev/null +++ b/awg-ctrl/shaping.ts @@ -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; + // Байт на пакет, которые туннель добавляет на проводе (CAKE видит только внутренний IP-пакет). + overhead: () => number; + peers: () => Promise; +} + +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 { + 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 { + 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 = Promise.resolve(); + const serial = (fn: () => Promise): Promise => { + 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 { + try { return /\bcake\b/.test(await exec("tc", ["qdisc", "show", "dev", dev, "root"])); } + catch { return false; } + } + + async function hasIngressRedirect(): Promise { + 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 { + return serial(applyNow).catch(noteError); + } + + async function update(s: ShapingSettings): Promise { + save(s); + await serial(applyNow); // пользователю нужна честная ошибка, а не молчаливый warn + lastError = ""; + } + + // ───────────── сэмплер (RFC 6057) ───────────── + const prev = new Map(); + const history = new Map(); // загрузка по IP + const totals: Tick[] = []; // суммарная загрузка (всё, что ушло клиентам) + const heavySet = new Set(); + 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(); + 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(); + 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 { + try { + const arr = JSON.parse(await exec("tc", ["-s", "-j", "qdisc", "show", "dev", dev, "root"])) as Record[]; + 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 { + 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; diff --git a/awg-ui/metrics.ts b/awg-ui/metrics.ts new file mode 100644 index 0000000..5bddcb4 --- /dev/null +++ b/awg-ui/metrics.ts @@ -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; +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(); + 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 { + 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((_, 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 { + 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 }; +} diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index cabd5c8..b617d4f 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -57,7 +57,7 @@ const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семь 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 } : {}) }); // Мок: устройство «догнано» на всех серверах ключа, кроме 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); 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.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 = { "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=): ядро (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 = {}; +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/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", (q, r) => { 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 }); }); +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.post("/ui/masterkeys", (q, r) => { const limit = q.body.device_limit ?? 3; diff --git a/awg-ui/monitor.ts b/awg-ui/monitor.ts new file mode 100644 index 0000000..dbdef08 --- /dev/null +++ b/awg-ui/monitor.ts @@ -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 = { + "1h": { seconds: 3600, step: 15 }, + "6h": { seconds: 21600, step: 60 }, + "24h": { seconds: 86400, step: 300 }, +}; + +type Point = [number, number]; +type Series = Map; // 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 { + 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; 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(); + 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 => { + 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 { + 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=). + 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; + + // Список серверов — из любого ряда, где они есть; «последнее значение» берём с конца окна. + const ids = new Set(); + 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 }; +} diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index 47ba3bc..f053110 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -305,9 +305,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const router = Router(); - router.get("/", async (_req: Request, res: Response) => { - if (LOCAL) await refreshHealth(0); - const nodes: unknown[] = []; + // Серверы со всем, что известно без запросов (health — из кэша). Общий вид для GET / и для метрик. + const list = () => { + const nodes: { id: number; name: string; local: boolean; online: boolean; pending: boolean; last_seen: number | null; version: string | null; health: Health | null }[] = []; if (LOCAL) { 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 }); @@ -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, }); } - 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) => { @@ -379,5 +384,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba 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 }; } diff --git a/awg-ui/server.ts b/awg-ui/server.ts index b22c7f4..ed2fc4a 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -9,6 +9,8 @@ import axios from "axios"; import Database from "better-sqlite3"; import { createSub } from "./sub"; import { createNodes } from "./nodes"; +import { createMetrics } from "./metrics"; +import { createMonitor } from "./monitor"; const app = express(); 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 }); app.use("/ui/masterkeys", requireAuth, sub.masterKeys); 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/v1 до JWT-прокси, роуты в нём можно добавлять и позже. ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? [])); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index bcd10d2..df5885c 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -332,6 +332,8 @@ body { cursor: default; 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 { background: var(--primary-container); color: var(--on-primary-container); @@ -433,6 +435,19 @@ body { user-select: all; } .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 { display: flex; flex-direction: column; diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 5567d1e..122f135 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -11,7 +11,8 @@ import { import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import ServerBar from './components/ServerBar'; import AddServerModal from './components/AddServerModal'; -import { TABS } from './tabs'; +import ServerSettingsModal from './components/ServerSettingsModal'; +import { TABS, TABS_BY_GROUP } from './tabs'; // Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, // список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит @@ -26,6 +27,7 @@ export default function App() { const [panelVersion, setPanelVersion] = useState(null); const [serverId, setServerIdState] = useState(null); const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null); + const [settingsFor, setSettingsFor] = useState(null); const [msg, setMsg] = useState(''); const [drawerOpen, setDrawerOpen] = useState(false); const [activeTab, setActiveTab] = useState(TABS[0].id); @@ -288,14 +290,19 @@ export default function App() {
- {TABS.map(t => ( -
{ setActiveTab(t.id); setDrawerOpen(false); }} - > - {t.label} -
+ {TABS_BY_GROUP.map(g => ( + ))}
@@ -318,6 +325,8 @@ export default function App() { onAdd={() => setModal({})} onJoin={n => setModal({ node: n })} onDelete={deleteNode} + // Сначала выбираем сервер: запросы модалки идут с X-Server-Id выбранного + onSettings={n => { pickServer(n.id); setSettingsFor(n); }} onRestartAwg={restartAwg} restarting={restarting} showRestart={showRestart} @@ -346,6 +355,15 @@ export default function App() { /> )} + {settingsFor && ( + setSettingsFor(null)} + showMsg={showMsg} + /> + )} + {msg &&
{msg}
}
); diff --git a/awg-ui/src/components/LineChart.tsx b/awg-ui/src/components/LineChart.tsx new file mode 100644 index 0000000..0a0bf4a --- /dev/null +++ b/awg-ui/src/components/LineChart.tsx @@ -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(null); // содержимое карточки без её padding: от него зависит ширина svg + const [w, setW] = useState(640); + const [hover, setHover] = useState(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(); + 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) => { + 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 ( +
+
+ {title} + {series.length >= 2 && ( + + {series.map(s => ( + +