// 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. // // sen://-подписка: реестр мастер-ключей/устройств (ui.db), админ-роуты панели // (/ui/masterkeys, /ui/devices, за JWT) и публичный листенер /sub/v1/* на // отдельном порту SUB_PORT. Протокол и формат ссылки — docs/sen-link.md. // // awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по // публичному ключу (/api/peers*) и общий профиль сервера (/api/profile). // // Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный // awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он // становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers). // Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers // хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить, // а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться // (сразу при действии, при подключении ноды и раз в минуту). // // Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl // получает полный список ключей, которые этой панели нужны на сервере, и снимает из её // области всё остальное. Это страховка от пиров, чья судьба потерялась (ответ ноды не // дошёл, устройство удалили посреди прохода). Область — id панели в owner пира // («/m1/d2»): у ноды может быть своя панель на том же awg-ctrl, чужих не трогаем. // Удаляем только по такому списку, никогда по времени: панель не на связи — ничего не // происходит, неактивные устройства никто не снимает. import crypto from "crypto"; import fs from "fs"; import http from "http"; import https from "https"; import path from "path"; import express, { Request, Response, NextFunction, Router } from "express"; import type Database from "better-sqlite3"; import { encodeSenLink, rawPublicKey, publicKeyFromRaw, spkiPin, signResponse, verifyRequest, } from "./sen"; type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>; /** Что подписке нужно от реестра серверов (awg-ui/nodes.ts). */ export interface Servers { ctrlFor(id: number): Ctrl; exists(id: number): boolean; isOnline(id: number): boolean; ids(): number[]; defaultId(): number; onOnline(cb: (id: number) => void): void; beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void; afterDelete(cb: (id: number) => void): void; } interface MasterRow { id: number; uuid: string; label: string; secret: Buffer; device_limit: number; server_id: number; created_at: number; revoked_at: number | null; } interface DeviceRow { id: number; master_id: number; device_id: string; device_name: string; platform: string; pub_key: string; auth_pub: string; created_at: number; last_seen: number | null; rekey_requested: number; version: string; } interface LinkRow { device_id: number; server_id: number; peer_pub: string | null; ip: string | null; psk: string | null; psk_pending: number; } interface Profile { name: string; endpoint: string; server_pub: string; gen: string; dns: string[]; keepalive: string; mtu: number; awg: Record; } const TS_WINDOW = 300; // секунд, ± const DEFAULT_LIMIT = 3; const MAX_SERVERS = 32; // серверов в одном мастер-ключе 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 CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная // pub_key WireGuard в пути к awg-ctrl — base64url (в обычном base64 есть «/»). const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url"); // ── Простой лимитер по IP ────────────────────────────────────────────────── // onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало. function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) { const hits = new Map(); return (ip: string): boolean => { const now = Date.now(); const e = hits.get(ip); if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; } if (e.n >= max) { if (e.n === max) { e.n++; onLimited?.(ip); } return false; } e.n++; return true; }; } export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) { const { uidb, servers } = deps; // ── Настройка из окружения ───────────────────────────────────────────── const SUB_PORT = Number(process.env.SUB_PORT) || 0; const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on"; // Публичный адрес панели для ссылок и endpoints. Без него — адрес локального сервера, // а у панели без своего VPN — тот, по которому к ней пришли. const SUB_HOST = (process.env.SUB_HOST ?? "").trim(); const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name); let signKey: crypto.KeyObject | null = null; let signPub: Buffer | null = null; try { signKey = crypto.createPrivateKey(fs.readFileSync(file(process.env.SUB_SIGN_KEY_FILE, "sub_sign.key"))); signPub = rawPublicKey(crypto.createPublicKey(signKey)); } catch { console.warn("sub: ключ подписи не найден — sen://-подписка отключена"); } let tlsMaterial: { key: Buffer; cert: Buffer } | null = null; let tlsPin: Buffer | null = null; if (SUB_TLS) { try { tlsMaterial = { key: fs.readFileSync(file(process.env.SUB_TLS_KEY_FILE, "sub_tls.key")), cert: fs.readFileSync(file(process.env.SUB_TLS_CERT_FILE, "sub_tls.crt")), }; tlsPin = spkiPin(tlsMaterial.cert.toString()); } catch { console.warn("sub: SUB_TLS=on, но сертификат не найден — подписка отключена"); signKey = null; } } // ── Схема ────────────────────────────────────────────────────────────── uidb.exec(` CREATE TABLE IF NOT EXISTS master_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, secret BLOB NOT NULL UNIQUE, device_limit INTEGER NOT NULL DEFAULT ${DEFAULT_LIMIT}, server_id INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, revoked_at INTEGER ); CREATE TABLE IF NOT EXISTS devices ( id INTEGER PRIMARY KEY AUTOINCREMENT, master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE, device_id TEXT NOT NULL, device_name TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', pub_key TEXT NOT NULL UNIQUE, auth_pub TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL, last_seen INTEGER, rekey_requested INTEGER NOT NULL DEFAULT 0, UNIQUE (master_id, device_id) ); -- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и -- дальше не читается (кроме переноса ниже). CREATE TABLE IF NOT EXISTS master_servers ( master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE, server_id INTEGER NOT NULL, PRIMARY KEY (master_id, server_id) ); -- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере -- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока -- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера. CREATE TABLE IF NOT EXISTS device_peers ( device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE, server_id INTEGER NOT NULL, peer_pub TEXT, ip TEXT, psk TEXT, psk_pending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (device_id, server_id) ); -- Пиры, которые надо удалить с сервера, когда до него дотянемся. CREATE TABLE IF NOT EXISTS peer_tombstones ( server_id INTEGER NOT NULL, pub_key TEXT NOT NULL, PRIMARY KEY (server_id, pub_key) ); -- Последний известный профиль сервера: config собирается из него, даже когда -- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва. CREATE TABLE IF NOT EXISTS server_profiles ( server_id INTEGER PRIMARY KEY, profile TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sub_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); // Версия клиента — необязательное поле, добавленное позже: у уже созданной // таблицы колонки может не быть. if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version")) uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''"); // uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки, // ни при правке метки. Существующим ключам выдаётся здесь один раз. if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid")) uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT"); { const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?"); for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[]) setUuid.run(crypto.randomUUID(), id); } uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)"); uidb.pragma("foreign_keys = ON"); // Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства // уже стоит там под devices.pub_key (ip/psk подтянет reconcile). У ключа всегда есть хотя // бы один сервер, поэтому «ни одной строки» бывает только у ключей из старой схемы. uidb.exec(` INSERT OR IGNORE INTO master_servers (master_id, server_id) SELECT id, server_id FROM master_keys WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers); INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub) SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id WHERE d.id NOT IN (SELECT device_id FROM device_peers); `); // Id панели — область её пиров на серверах. Живёт, пока живёт ui.db; копия в panel.id // нужна CLI (у него нет SQLite), чтобы отличать пиров этой панели на awg-ctrl. uidb.prepare("INSERT OR IGNORE INTO sub_meta (key, value) VALUES ('panel_id', ?)").run(crypto.randomBytes(6).toString("hex")); const panelId = (uidb.prepare("SELECT value FROM sub_meta WHERE key = 'panel_id'").get() as { value: string }).value; try { fs.writeFileSync(path.join(deps.baseDir, "panel.id"), panelId + "\n"); } catch (e) { console.warn(`sub: не удалось записать panel.id: ${(e as Error).message}`); } const q = { // И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление. masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id"), masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"), masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"), masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"), masterInsert: uidb.prepare<[string, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"), masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"), masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"), masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"), masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"), // Сервер сведён и надгробий на нём нет — отозванным ключам он больше не нужен. revokedServerDone: uidb.prepare<[number]>( "DELETE FROM master_servers WHERE server_id = ? AND master_id IN (SELECT id FROM master_keys WHERE revoked_at IS NOT NULL)"), revokedGone: uidb.prepare("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)"), liveOnServer: uidb.prepare<[number]>( "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL"), revokedOnServer: uidb.prepare<[number]>( "SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL"), serverRelease: uidb.prepare<[number]>("DELETE FROM master_servers WHERE server_id = ?"), serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"), serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"), serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"), usedServers: uidb.prepare("SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones"), devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"), deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"), deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"), deviceCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM devices WHERE master_id = ?"), deviceInsert: uidb.prepare<[number, string, string, string, string, string, number, string]>( "INSERT INTO devices (master_id, device_id, device_name, platform, pub_key, auth_pub, created_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"), deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"), deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"), deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"), devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"), deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"), linksOf: uidb.prepare<[number]>("SELECT * FROM device_peers WHERE device_id = ? ORDER BY server_id"), linkAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)"), linkDel: uidb.prepare<[number, number]>("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?"), linkSet: uidb.prepare<[string | null, string | null, string | null, number, number, number]>( "UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?"), linkPskDue: uidb.prepare<[number]>("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL"), // Что на сервере расходится с нужным: пира нет, стоит старый ключ, не знаем ip/psk, ждёт новый PSK. linksToFix: uidb.prepare<[number]>(` SELECT l.*, d.pub_key AS dev_pub, d.master_id FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`), tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"), tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"), tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"), tombCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM peer_tombstones WHERE server_id = ?"), // Что панели нужно на сервере: текущий ключ каждого устройства и ключ, который там стоит // сейчас (расходится с текущим, пока смена ключа не догнала сервер). keepOf: uidb.prepare<[number, number]>(` SELECT d.pub_key AS pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ? UNION SELECT peer_pub FROM device_peers WHERE server_id = ? AND peer_pub IS NOT NULL`), // Связи, которые считаются сведёнными: на сервере должен стоять текущий ключ устройства. linksSettled: uidb.prepare<[number]>(` SELECT l.device_id, l.peer_pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ? AND l.peer_pub = d.pub_key`), profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"), profileSet: uidb.prepare<[number, string, number]>( "INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"), }; const now = () => Math.floor(Date.now() / 1000); const hasCtrlOk = (s: number) => s >= 200 && s < 300; const serverIdsOf = (masterId: number) => (q.serversOf.all(masterId) as { server_id: number }[]).map(r => r.server_id); // ── Сведение серверов ────────────────────────────────────────────────── function storedProfile(id: number): Profile | null { const row = q.profileGet.get(id) as { profile: string } | undefined; try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; } } async function refreshProfile(id: number, ctrl: Ctrl): Promise { const r = await ctrl("GET", "/api/profile"); if (!hasCtrlOk(r.status) || typeof r.data?.endpoint !== "string") return false; const json = JSON.stringify(r.data); const row = q.profileGet.get(id) as { profile: string } | undefined; if (row?.profile !== json) q.profileSet.run(id, json, now()); return true; } // Один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды. const locks = new Map>(); function reconcile(id: number): Promise { const next = (locks.get(id) ?? Promise.resolve()).then(() => reconcileNow(id)).catch(e => { console.warn(`sub: сервер #${id} не сведён: ${(e as Error).message}`); }); locks.set(id, next); return next; } const reconcileAll = (ids: Iterable) => Promise.all([...new Set(ids)].map(reconcile)); async function reconcileNow(id: number) { if (!servers.exists(id) || !servers.isOnline(id)) return; const ctrl = servers.ctrlFor(id); if (!await refreshProfile(id, ctrl)) return; // сервер не отвечает — попробуем позже // Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk). let listed: Map | null = null; const onServer = async (pub: string) => { if (!listed) { const r = await ctrl("GET", "/api/peers"); if (!hasCtrlOk(r.status)) throw new Error(`peers ${r.status}`); listed = new Map((r.data.peers as { pub_key: string; ip: string; psk_key: string }[]).map(p => [p.pub_key, p])); } return listed.get(pub) ?? null; }; const gone = (s: number) => s === 502 || s === 504; // связь пропала посреди прохода for (const { pub_key } of q.tombsOf.all(id) as { pub_key: string }[]) { const r = await ctrl("DELETE", `/api/peers/${pubParam(pub_key)}`); if (hasCtrlOk(r.status) || r.status === 404) q.tombDel.run(id, pub_key); else if (gone(r.status)) return; } type Peer = { ip: string; psk_key: string }; for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) { // null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем. const add = async (): Promise => { const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `${panelId}/m${l.master_id}/d${l.device_id}` }); if (hasCtrlOk(r.status)) return r.data; if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся return gone(r.status) ? "gone" : null; }; let peer: Peer | null | "gone"; if (l.peer_pub === null) { peer = await add(); } else if (l.peer_pub !== l.dev_pub) { // Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ. const r = await ctrl("PUT", `/api/peers/${pubParam(l.peer_pub)}`, { pub_key: l.dev_pub }); peer = hasCtrlOk(r.status) ? r.data : r.status === 404 ? await add() // старого пира на сервере уже нет : r.status === 409 ? await onServer(l.dev_pub) : gone(r.status) ? "gone" : null; } else { peer = await onServer(l.dev_pub) ?? await add(); // ip/psk не знаем или пир пропал с сервера } if (peer === "gone") return; if (!peer) continue; let pending = 0; if (l.psk_pending === 1) { const r = await ctrl("POST", `/api/peers/${pubParam(l.dev_pub)}/psk`); if (hasCtrlOk(r.status)) peer = r.data as Peer; else if (gone(r.status)) return; else pending = 1; // пир на месте, PSK сменим позже } q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id); } await syncPeers(id, ctrl); } // Сведение пиров панели на сервере — в конце прохода и под его блокировкой: список строится // из ui.db сейчас, поэтому устройство, удалённое посреди прохода, в него уже не попадёт, и // ключ, поставленный для него этим проходом, снимется здесь же. async function syncPeers(id: number, ctrl: Ctrl) { const keep = (q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub); const r = await ctrl("PUT", "/api/peers/sync", { owner: panelId, keep }); if (hasCtrlOk(r.status)) { // Самолечение: ключ считается стоящим, а его нет (откат смены ключа после потерянного // ответа, пир сняли руками) — забываем и ставим заново следующим проходом. const present = new Set(r.data?.present as string[]); let lost = 0; for (const l of q.linksSettled.all(id) as { device_id: number; peer_pub: string }[]) { if (present.has(l.peer_pub)) continue; q.linkSet.run(null, null, null, 0, l.device_id, id); lost++; } if (lost) void reconcile(id); } else if (r.status !== 400 && r.status !== 413) { return; // 400/413 — awg-ctrl старше sync: чистим, как раньше, одними надгробиями } if ((q.tombCount.get(id) as { n: number }).n > 0) return; q.revokedServerDone.run(id); 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 = (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("audit", null); let auditSettings: AuditSettings = { ...AUDIT_DEFAULTS, ...metaGet>("audit_settings", {}) }; let cleanedLog: CleanedPeer[] = metaGet("audit_cleaned", []); async function auditNow(id: number): Promise { 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 { 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 { 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 | null = null; // auto — плановый проход: очищает сам, если включено. Ручной только смотрит и обновляет «видели раньше». function auditAll(auto = false): Promise { 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 { 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 { 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[] { const links = q.linksOf.all(dev.id) as LinkRow[]; uidb.transaction(() => { for (const l of links) if (l.peer_pub) q.tombAdd.run(l.server_id, l.peer_pub); q.deviceDelete.run(dev.id); // device_peers уйдут каскадом })(); return links.map(l => l.server_id); } // ── config ───────────────────────────────────────────────────────────── const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":")); function subHost(req?: Request): string { if (SUB_HOST) return SUB_HOST; const local = servers.exists(0) ? storedProfile(0) : null; if (local) return hostOf(local.endpoint); return (req?.hostname ?? "").replace(/^\[|\]$/g, ""); } // В servers[] — только серверы, где уже стоит текущий ключ устройства и известны ip/psk. // Сервер, который ещё не догнали (нода не в сети при регистрации или смене ключа), появится, // когда его сведут, — rev при этом сменится, и клиент подтянет. function buildConfig(dev: DeviceRow, req?: Request) { const list = []; for (const l of q.linksOf.all(dev.id) as LinkRow[]) { const prof = storedProfile(l.server_id); if (!prof || l.peer_pub !== dev.pub_key || !l.ip || !l.psk) continue; list.push({ id: l.server_id, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub, psk: l.psk, address: `${l.ip}/32`, dns: prof.dns, keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg, }); } const cfg = { rekey: dev.rekey_requested === 1, endpoints: [`${subHost(req)}:${SUB_PORT}`], servers: list, }; // rev меняется при любом изменении; клиент по нему решает, трогать ли туннель. const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16); return { rev, ...cfg }; } // Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель // из-за временной недоступности нод — пусть лучше живёт со старым конфигом. async function readyConfig(dev: DeviceRow, req: Request) { let cfg = buildConfig(dev, req); if (cfg.servers.length === 0) { await reconcileAll((q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id)); cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req); } if (cfg.servers.length === 0) throw new Error("no servers"); return cfg; } // ── Публичная часть: /sub/v1 ─────────────────────────────────────────── const sub = express(); sub.disable("x-powered-by"); sub.use(express.json({ limit: "4kb", verify: (req, _res, buf) => { (req as any).rawBody = buf; }, })); const limited = (what: string) => (ip: string) => console.warn(`sub: 429 ${what} ip=${ip} — лимит исчерпан на 15 минут`); const registerLimit = makeLimiter(30, 15 * 60 * 1000, limited("register")); const failLimit = makeLimiter(30, 15 * 60 * 1000, limited("неудачные запросы")); const seenSigs = new Map(); // подпись → когда забыть const ipOf = (req: Request) => req.socket.remoteAddress ?? "unknown"; // Ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Клиент сверяет // их со своим запросом — иначе подсунуть ему можно было бы чужой подписанный ответ (чужой // revoked, старый config), подпись ведь на запрос не завязана. function send(req: Request, res: Response, status: number, payload: object) { const echo: { device?: number; ts?: number } = {}; const device = Number(req.headers["x-sen-device"]), ts = Number(req.headers["x-sen-ts"]); if (req.headers["x-sen-device"] !== undefined && Number.isSafeInteger(device)) echo.device = device; if (req.headers["x-sen-ts"] !== undefined && Number.isSafeInteger(ts)) echo.ts = ts; // Поля ответа важнее эха: device из тела register не перетрёт лишний заголовок. res.status(status).json(signResponse({ ...echo, ...payload }, signKey as crypto.KeyObject)); } // Проверка подписи запроса + защита от повторов. auth_pub — 32 байта. // null — всё верно, иначе причина отказа (для лога). function signFailure(req: Request, authPub: Buffer): string | null { const ts = Number(req.headers["x-sen-ts"]); const sig = String(req.headers["x-sen-sig"] ?? ""); if (!Number.isInteger(ts) || !sig) return "no_signature"; if (Math.abs(ts - now()) > TS_WINDOW) return `ts_window (часы клиента расходятся на ${ts - now()} с)`; const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0); if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return "bad_signature"; const t = Date.now(); for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k); if (seenSigs.has(sig)) return "replay"; seenSigs.set(sig, t + 2 * TS_WINDOW * 1000); return null; } interface DevReq extends Request { device?: DeviceRow } function deviceAuth(req: Request, res: Response, next: NextFunction) { const id = Number(req.headers["x-sen-device"]); const dev = Number.isSafeInteger(id) && id > 0 ? q.deviceById.get(id) as DeviceRow | undefined : undefined; // Устройства нет — его удалили (id не переиспользуются: AUTOINCREMENT). Отвечаем 410, // чтобы клиент снял конфиг, и не считаем в лимит неудач: такие клиенты опрашивают // по таймеру и иначе выбивали бы лимит соседям за тем же NAT. Существующему // устройству 410 не выдать, поэтому подписанный ответ тут можно отдать без проверки. if (!dev && Number.isSafeInteger(id) && id > 0) { send(req, res, 410, { error: "revoked" }); return; } const why = dev ? signFailure(req, Buffer.from(dev.auth_pub, "base64url")) : "no_device"; if (!why) { (req as DevReq).device = dev; next(); return; } // Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает. const ip = ipOf(req); if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; } console.warn(`sub: 401 ${req.method} ${req.path} device=${req.headers["x-sen-device"] ?? "-"} ${why} ip=${ip}`); send(req, res, 401, { error: "unauthorized" }); } const wrap = (fn: (req: Request, res: Response) => Promise) => (req: Request, res: Response) => { fn(req, res).catch(() => { send(req, res, 502, { error: "unavailable" }); }); }; sub.post("/sub/v1/register", wrap(async (req, res) => { const ip = ipOf(req); if (!registerLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; } const b = (req.body ?? {}) as Record; const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null; const secretB = str(b.sub, /^[A-Za-z0-9_-]{22}$/); const deviceId = str(b.device_id, /^[\w.-]{1,64}$/); const pubKey = str(b.pub_key, B64_PUB); const authPubS = str(b.auth_pub, /^[A-Za-z0-9_-]{43}$/); const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : ""; const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : ""; const version = str(b.version, CLIENT_VERSION) ?? ""; if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; } // Регистрация подписана ключом auth_pub из самого тела — доказательство владения. const authPub = Buffer.from(authPubS, "base64url"); const why = authPub.length !== 32 ? "bad_auth_pub" : signFailure(req, authPub); if (why) { console.warn(`sub: 401 register ${why} ip=${ip}`); send(req, res, 401, { error: "unauthorized" }); return; } const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined; if (!master) { send(req, res, 404, { error: "not_found" }); return; } const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id)); if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; } let deviceRowId = 0; let touched: number[] = []; try { uidb.transaction(() => { const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined; if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit) throw Object.assign(new Error("limit"), { code: "limit" }); deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid); for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid); })(); } catch (e: any) { if (e?.code === "limit") { send(req, res, 403, { error: "device_limit" }); return; } send(req, res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты } await reconcileAll([...touched, ...serverIds]); const dev = q.deviceById.get(deviceRowId) as DeviceRow; const cfg = buildConfig(dev, req); if (cfg.servers.length === 0) { // Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже. await reconcileAll(forgetDevice(dev)); send(req, res, 502, { error: "unavailable" }); return; } // devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу, // без отдельного запроса списка. send(req, res, 201, { device: dev.id, config: cfg, key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit, }); })); // Сколько мест у ключа занято — клиент показывает это, пока ссылку только вставили и ещё ничего не // регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register, // а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать. sub.post("/sub/v1/peek", wrap(async (req, res) => { const ip = ipOf(req); const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null; const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined; if (!master) { if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; } send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" }); return; } send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit }); })); sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => { const dev = (req as DevReq).device as DeviceRow; q.deviceSeen.run(now(), dev.id); // Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели. const v = String(req.headers["x-sen-version"] ?? ""); if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id); send(req, res, 200, { config: await readyConfig(dev, req) }); })); // Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать // можно лишь себя (DELETE /sub/v1/device); чужие устройства убирает панель. sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => { const dev = (req as DevReq).device as DeviceRow; const master = q.masterById.get(dev.master_id) as MasterRow | undefined; if (!master) { send(req, res, 404, { error: "not_found" }); return; } send(req, res, 200, { name: master.label, key_uuid: master.uuid, device_limit: master.device_limit, devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({ id: d.id, name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at, last_seen: d.last_seen, current: d.id === dev.id, })), }); })); // Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же IP и PSK), // где нет — когда он появится. Если не принял ни один — ключ не меняем, клиент повторит. sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => { const dev = (req as DevReq).device as DeviceRow; const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key; if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(req, res, 400, { error: "bad_request" }); return; } if (pub === dev.pub_key) { send(req, res, 200, { config: await readyConfig(dev, req) }); return; } try { q.devicePub.run(pub, 0, dev.id); } catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id); await reconcileAll(ids); const cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req); if (cfg.servers.length === 0) { q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id); await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить send(req, res, 502, { error: "unavailable" }); return; } send(req, res, 200, { config: cfg }); })); sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => { const dev = (req as DevReq).device as DeviceRow; await reconcileAll(forgetDevice(dev)); send(req, res, 200, { ok: true }); })); sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); }); // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); }); // ── Мастер-ключи: общая логика панели и внешнего API ─────────────────── // Панель (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys, // ключ awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа: // иначе он мог бы удалить или перенастроить раздачу на сервере, которого не видит. // Scope: null — панель, иначе набор серверов API-ключа. type Scope = number[] | null; class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } const fail = (status: number, message: string): never => { throw new ApiError(status, message); }; // ApiError — её статус; остальное (awg-ctrl не ответил посреди действия) — 502. const aw = (fn: (req: Request, res: Response) => unknown) => (req: Request, res: Response) => { Promise.resolve().then(() => fn(req, res)).catch(e => { if (e instanceof ApiError) res.status(e.status).json({ error: e.message }); else res.status(502).json({ error: "awg-ctrl недоступен" }); }); }; const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся // Мастер-ключ в пути — числовой id или uuid. const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const masterIdOf = (v: string) => UUID_RE.test(v) ? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1 : idOf(v); const LABEL = /^[^\r\n<>]{1,40}$/; const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100; const visible = (masterId: number, scope: Scope) => scope === null || serverIdsOf(masterId).every(id => scope.includes(id)); // Живой (не удаляемый) ключ в пределах области; чужой — как несуществующий. function masterFor(id: number, scope: Scope): MasterRow { const m = q.masterById.get(id) as MasterRow | undefined; return m && visible(m.id, scope) ? m : fail(404, "Не найден"); } // Устройство — только вместе со своим мастер-ключом (у внешнего API пути вложенные). function deviceFor(id: number, master?: MasterRow): DeviceRow { const d = q.deviceById.get(id) as DeviceRow | undefined; return d && (!master || d.master_id === master.id) ? d : fail(404, "Не найдено"); } // Набор серверов из тела запроса: непустой, без повторов, только существующие и — для // внешнего API — только из области его ключа. function parseServers(v: unknown, scope: Scope): number[] { if (!Array.isArray(v) || v.length === 0) fail(400, "Нужен хотя бы один сервер"); const list = v as unknown[]; if (list.length > MAX_SERVERS) fail(400, `Не больше ${MAX_SERVERS} серверов`); const ids = [...new Set(list)]; if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) fail(400, "Такого сервера нет"); if (scope && !ids.every(id => scope.includes(id as number))) fail(403, "Сервер вне области API-ключа"); return (ids as number[]).sort((a, b) => a - b); } // deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров. function masterOut(m: MasterRow & { devices: number }) { return { id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at, ...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}), }; } const masterOutById = (id: number) => masterOut(q.masterRow.get(id) as MasterRow & { devices: number }); const listMasters = (scope: Scope) => (q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut); // Серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область. function createMaster(body: unknown, scope: Scope) { const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown }; if (typeof label !== "string" || !LABEL.test(label)) fail(400, "Метка: до 40 символов, без переводов строк и <>"); const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit; if (!limitOk(limit)) fail(400, "Лимит устройств: от 1 до 100"); let ids: number[]; try { ids = parseServers(wanted ?? scope ?? [servers.defaultId()], scope); } catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; } let id = 0; uidb.transaction(() => { id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid); for (const sid of ids) q.serverAdd.run(id, sid); })(); return masterOutById(id); } async function updateMaster(m: MasterRow, body: unknown, scope: Scope) { const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown }; const nl = label ?? m.label, nd = device_limit ?? m.device_limit; if (typeof nl !== "string" || !LABEL.test(nl)) fail(400, "Неверная метка"); if (!limitOk(nd)) fail(400, "Лимит устройств: от 1 до 100"); let ids = serverIdsOf(m.id); const affected: number[] = []; if (wanted !== undefined) { const next = parseServers(wanted, scope); const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s)); const devices = q.devicesOf.all(m.id) as DeviceRow[]; uidb.transaction(() => { for (const sid of removed) { q.serverDel.run(m.id, sid); for (const d of devices) { const l = (q.linksOf.all(d.id) as LinkRow[]).find(x => x.server_id === sid); if (l?.peer_pub) q.tombAdd.run(sid, l.peer_pub); q.linkDel.run(d.id, sid); } } for (const sid of added) { q.serverAdd.run(m.id, sid); for (const d of devices) q.linkAdd.run(d.id, sid); } })(); affected.push(...added, ...removed); ids = next; } q.masterUpdate.run(nl as string, nd as number, m.id); // Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними, — // первые, кто занял места, их сохраняют. Как отзыв в панели: пиры снимаются, устройство получит 410. const unbound: number[] = []; const devices = q.devicesOf.all(m.id) as DeviceRow[]; if (devices.length > (nd as number)) { const newest = [...devices].sort((a, b) => b.created_at - a.created_at || b.id - a.id); for (const d of newest.slice(0, devices.length - (nd as number))) { affected.push(...forgetDevice(d)); unbound.push(d.id); } } await reconcileAll(affected); return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound }; } // Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret. const rotateMaster = (m: MasterRow) => { q.masterSecret.run(crypto.randomBytes(16), m.id); }; const rekeyMaster = (m: MasterRow) => { q.deviceFlag.run(-1, m.id); }; // Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся // отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель // сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются. async function deleteMaster(m: MasterRow) { const affected = serverIdsOf(m.id); uidb.transaction(() => { for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d)); q.masterRevoke.run(now(), m.id); })(); await reconcileAll(affected); return { success: true, id: m.id, pending: serverIdsOf(m.id) }; } // Выдать ссылку можно, только если подписка настроена и известен адрес панели. function linkProblem(req: Request): string | null { if (!signKey || !signPub || !SUB_PORT) return "Подписка не настроена на сервере"; if (!subHost(req)) return "Не удалось определить адрес панели — задайте SUB_HOST"; return null; } function linkOf(m: MasterRow, req: Request) { const problem = linkProblem(req); if (problem) fail(503, problem); const link = encodeSenLink({ tls: tlsPin !== null, addrs: [{ host: subHost(req), port: SUB_PORT }], secret: m.secret, signPub: signPub as Buffer, tlsPin: tlsPin ?? undefined, name: m.label, }); return { link, tls: tlsPin !== null }; } // Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть // на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ. async function masterDevices(m: MasterRow) { const stats = new Map(); await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => { const r = await servers.ctrlFor(id)("GET", "/api/peers"); if (!hasCtrlOk(r.status)) return; 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, background: false }; stats.set(p.pub_key, { online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake), rx: s.rx + p.rx, tx: s.tx + p.tx, // Устройство стоит на каждом сервере со своим IP; фоновым его делает любой из них. background: s.background || p.priority === "background", }); } })); return (q.devicesOf.all(m.id) as DeviceRow[]).map(d => { const links = q.linksOf.all(d.id) as LinkRow[]; const s = stats.get(d.pub_key); return { 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, 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, }; }); } async function deleteDevice(d: DeviceRow) { await reconcileAll(forgetDevice(d)); return { success: true, id: d.id }; } const rekeyDevice = (d: DeviceRow) => { q.deviceFlag.run(d.id, -1); }; // Новый PSK на каждом сервере; недоступные получат его, когда появятся. async function rotatePsk(d: DeviceRow) { q.linkPskDue.run(d.id); await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id)); return { id: d.id, pending: (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length }; } // ── Роуты панели (монтируются в server.ts за JWT) ────────────────────── const masterKeys = Router(); masterKeys.get("/", (_req, res) => { 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; 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.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/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); })); masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); })); masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); })); masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(req.params.id), null)) }); })); const devices = Router(); devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); })); devices.post("/:id/rekey", aw((req, res) => { const d = deviceFor(idOf(req.params.id)); rekeyDevice(d); res.json({ id: d.id }); })); devices.post("/:id/psk", aw(async (req, res) => { res.json(await rotatePsk(deviceFor(idOf(req.params.id)))); })); // ── Внешний API: /api/v1/masterkeys (ключ awgk_, монтируется в server.ts) ─ // scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы // область проверялась через их мастер-ключ. function apiRouter(scopeOf: (req: Request) => number[]) { const r = Router(); const master = (req: Request) => masterFor(masterIdOf(req.params.id), scopeOf(req)); const device = (req: Request) => deviceFor(idOf(req.params.device), master(req)); r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); })); r.post("/", aw((req, res) => { const problem = linkProblem(req); // без ссылки ключ внешней программе бесполезен if (problem) fail(503, problem); const out = createMaster(req.body, scopeOf(req)); res.status(201).json({ ...out, ...linkOf(masterFor(out.id, null), req) }); })); r.get("/:id", aw(async (req, res) => { const m = master(req); // devices — счётчик, как в списке; сами устройства — в device_list. res.json({ ...masterOutById(m.id), ...(linkProblem(req) ? { link: null, tls: tlsPin !== null } : linkOf(m, req)), device_list: await masterDevices(m) }); })); r.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(master(req), req.body, scopeOf(req))); })); r.post("/:id/rotate", aw((req, res) => { const m = master(req); rotateMaster(m); res.json({ id: m.id, ...linkOf(masterFor(m.id, null), req) }); })); r.post("/:id/rekey", aw((req, res) => { const m = master(req); rekeyMaster(m); res.json({ id: m.id }); })); r.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(master(req))); })); r.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(master(req)) }); })); r.delete("/:id/devices/:device", aw(async (req, res) => { res.json(await deleteDevice(device(req))); })); r.post("/:id/devices/:device/rekey", aw((req, res) => { const d = device(req); rekeyDevice(d); res.json({ id: d.id }); })); r.post("/:id/devices/:device/psk", aw(async (req, res) => { res.json(await rotatePsk(device(req))); })); return r; } // ── Листенер и фоновое сведение ──────────────────────────────────────── servers.onOnline(id => { void reconcile(id); }); // Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва // сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после // удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force; // тогда отозванные ключи перестают его ждать уже после удаления (afterDelete). servers.beforeDelete(async (id, force) => { const live = (q.liveOnServer.all(id) as { label: string }[]).map(r => r.label); if (live.length) return { error: `Сервер в мастер-ключах: ${live.join(", ")} — сначала уберите его оттуда`, forcible: false }; if (servers.isOnline(id)) await reconcile(id); const waiting = (q.revokedOnServer.all(id) as { label: string }[]).map(r => r.label); const tombs = (q.tombCount.get(id) as { n: number }).n; if (!waiting.length && !tombs || force) return null; return { error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") + ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)", forcible: true, }; }); servers.afterDelete(id => { q.serverRelease.run(id); q.revokedGone.run(); }); function start() { // Сводим и без подписки: у ключей могут остаться хвосты на удаление. setInterval(() => { void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id)); }, 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 (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; } const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub); server.listen(SUB_PORT, () => { console.log(`sub: listening on :${SUB_PORT} (${tlsMaterial ? "https" : "http"})`); }); } return { masterKeys, devices, apiRouter, start }; }