From c97acc933f33900a2191464a4bfb999d45ee8a62 Mon Sep 17 00:00:00 2001 From: Ivan Vasilev Date: Sat, 26 Sep 2026 21:43:58 +0300 Subject: [PATCH] Serve a master key's devices from several servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A master key now has a set of servers (the panel's own and/or nodes). A device's single WireGuard key becomes a peer on every server of its key, each with its own IP and PSK, and config.servers[] lists them all. - ui.db: master_servers (the key's set, never empty), device_peers (which key is on which server, with the ip/psk that server issued), peer_tombstones (peers to delete) and server_profiles (last known profile of each server). - Nothing is pushed blindly: reconcile() brings one server to the desired state - deletes tombstones, adds peers, rekeys with PUT, fills ip/psk, rotates a pending PSK - one pass per server at a time. It runs right after every action, when a node connects and once a minute, so a node that was offline catches up on rekeys, PSK changes and removals. - config is built from ui.db, so an offline node stays in it and rev does not flap; a server is listed once its peer carries the device's current key. An empty config is never sent (502 instead); register and rekey roll back if no server accepted the key. - Existing keys and devices migrate on start. With one server the config and its rev are byte-identical to 0.3.1 (checked by running both versions on the same ui.db), so existing clients notice nothing. - Panel: server checkboxes in the key settings instead of the placeholder (the last one cannot be removed), "на N из M серверов" on devices that have not reached every server yet, device stats summed over servers. - SUB_HOST: the panel's public address for sen:// links and endpoints; install.sh writes it. Without it a panel with no VPN of its own used the address it was opened at. - A node referenced by a key's server set cannot be deleted. --- awg-ui/mock-server.ts | 20 +- awg-ui/nodes.ts | 24 +- awg-ui/server.ts | 4 +- awg-ui/src/tabs/masterkeys/index.tsx | 75 ++-- awg-ui/src/tabs/masterkeys/masterkeys.css | 12 +- awg-ui/sub.ts | 427 ++++++++++++++++++---- cli/src/index.ts | 3 + install.sh | 1 + 8 files changed, 451 insertions(+), 115 deletions(-) diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 9b700ae..58a8ef4 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -48,12 +48,14 @@ let apiId = 1; // ── мастер-ключи sen:// ── interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number } -interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] } +interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[] } let mkId = 1, devId = 1; const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) }); -const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")] }]; +const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }]; const signPub = crypto.randomBytes(32); -const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, created_at: m.created_at }); +const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at }); +// Мок: устройство «догнано» на всех серверах ключа, кроме 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 find = (id: string) => masters.find(m => m.id === Number(id)); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" })); @@ -104,10 +106,14 @@ app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: m app.post("/ui/masterkeys", (q, r) => { const limit = q.body.device_limit ?? 3; if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" }); - const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] }; + const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] }; masters.push(m); r.status(201).json(mkOut(m)); }); -app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); }); +app.patch("/ui/masterkeys/:id", (q, r) => { + const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); + if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); } + if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); +}); app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); }); app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); }); app.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); }); @@ -115,10 +121,10 @@ app.get("/ui/masterkeys/:id/link", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false }); }); -app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); }); +app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); }); app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); }); app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); }); -app.post("/ui/devices/:id/psk", (_q, r) => r.json({})); +app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 })); const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080; app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`)); diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index 15fd2a8..d3b29c0 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -138,6 +138,16 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined); + /** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */ + const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id)); + + /** Все серверы реестра: локальный (если есть) и ноды. */ + const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)]; + + // Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное. + const onlineHandlers: ((id: number) => void)[] = []; + const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); }; + /** Сервер по умолчанию: локальный, а без него — первая нода. */ function defaultId(): number { if (LOCAL) return 0; @@ -216,6 +226,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba ws.send(JSON.stringify({ t: "ready" })); console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`); void refreshHealth(row.id); + for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } } } ws.on("message", raw => { @@ -241,7 +252,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba } function start() { - if (LOCAL) void refreshHealth(0); + if (LOCAL) { + void refreshHealth(0); + for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } } + } if (!NODE_PORT || !tls) return; const server = https.createServer(tls); const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 }); @@ -330,7 +344,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined; if (!n) { res.status(404).json({ error: "Не найден" }); return; } // Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе. - const used = (["api_keys", "master_keys"] as const).some(t => { + const used = (["api_keys", "master_servers"] as const).some(t => { try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; } catch { return false; } }); @@ -338,8 +352,12 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const c = conns.get(id); if (c) { dropConn(id, c); c.ws.close(4004, "removed"); } q.delete.run(id); + // Хвосты подписки для этого сервера больше некому доделать. + for (const t of ["peer_tombstones", "server_profiles"]) { + try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ } + } res.json({ success: true, id }); }); - return { router, ctrlFor, exists, defaultId, start }; + return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start }; } diff --git a/awg-ui/server.ts b/awg-ui/server.ts index cb80414..81178e7 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -289,8 +289,8 @@ app.use("/api/v1", ext); const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname }); app.use("/ui/nodes", requireAuth, nodes.router); -// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг. -const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname }); +// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды). +const sub = createSub({ uidb, servers: nodes, baseDir: __dirname }); app.use("/ui/masterkeys", requireAuth, sub.masterKeys); app.use("/ui/devices", requireAuth, sub.devices); diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index 2f43c07..df81af1 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -3,19 +3,25 @@ // LICENSE file in the root directory of this source tree. import { Fragment, useState, useEffect, useCallback } from 'react'; import QRCode from 'qrcode'; -import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, type PageProps } from '../../lib/shared'; +import { apiFetch, copyText, bytes, timeAgo, genLabel, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; import './masterkeys.css'; // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся // на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md. -interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number } +interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number } interface Device { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number; + servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ } +// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) — +// панель догонит, когда нода появится. +const syncLabel = (d: Device): string => + d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : ''; + // Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа. const platformLabel = (d: Device): string => d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—'; @@ -26,7 +32,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const [keys, setKeys] = useState([]); const [enabled, setEnabled] = useState(true); const [tls, setTls] = useState(false); - const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null); + const [nodes, setNodes] = useState([]); // серверы, которые можно включить в ключ const [label, setLabel] = useState(''); const [limit, setLimit] = useState('3'); const [devs, setDevs] = useState>({}); @@ -40,8 +46,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const d = await apiFetch('GET', '/ui/masterkeys', token); const list: MasterKey[] = d.keys ?? []; setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); - // Сервер, который сейчас отдаётся по ключу (он пока единственный). - apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {}); + apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); const all = await Promise.all(list.map(k => apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const))); setDevs(Object.fromEntries(all)); @@ -57,10 +62,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const create = useCallback(async () => { if (!label.trim()) return; await act(async () => { - await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) }); + // Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа. + const ready = nodes.filter(n => !n.pending).map(n => n.id); + await apiFetch('POST', '/ui/masterkeys', token, { + label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}), + }); setLabel(''); }, 'Мастер-ключ создан'); - }, [label, limit, token, act]); + }, [label, limit, nodes, token, act]); const remove = useCallback(async (k: MasterKey) => { if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; @@ -146,20 +155,32 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { - {/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён. - TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[] - уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */} + {/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку — + пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
-

Включённые серверы

- - - - +

Серверы ключа

+ {nodes.map(n => { + const on = k.servers.includes(n.id); + const last = on && k.servers.length === 1; + const h = n.health; + return ( + + ); + })} +

Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.

); @@ -181,8 +202,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const deviceActions = (d: Device) => (
- +
{d.online ? 'Онлайн' : 'Офлайн'} @@ -276,7 +302,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { {bytes(d.rx)} {bytes(d.tx)} {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'} - {d.rekey_requested && · ждёт смены ключа} + {d.rekey_requested && · ждёт смены ключа} + {syncLabel(d) && {syncLabel(d)}} {deviceActions(d)} diff --git a/awg-ui/src/tabs/masterkeys/masterkeys.css b/awg-ui/src/tabs/masterkeys/masterkeys.css index 6d78c10..a8b1a60 100644 --- a/awg-ui/src/tabs/masterkeys/masterkeys.css +++ b/awg-ui/src/tabs/masterkeys/masterkeys.css @@ -162,13 +162,11 @@ LICENSE file in the root directory of this source tree. .mk-server-name { font-weight: 500; } .mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); } .mk-server-ip { font-family: var(--mono); margin-left: auto; } -.mk-server-add { - width: 100%; justify-content: center; - background: transparent; - border: 1px dashed var(--outline); - color: var(--neutral-dot); - cursor: not-allowed; -} +.mk-server { cursor: pointer; } +.mk-server:hover { background: var(--state-primary); } +.mk-server--locked { cursor: default; } +.mk-server--locked:hover { background: var(--surface-container); } +.mk-server-dot { padding: 0; background: none; } .mk-box .tip-wrap { width: 100%; } /* дети: вертикальная линия слева и «ножка» к каждому узлу */ diff --git a/awg-ui/sub.ts b/awg-ui/sub.ts index f5f69b2..68c1040 100644 --- a/awg-ui/sub.ts +++ b/awg-ui/sub.ts @@ -8,6 +8,14 @@ // // awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по // публичному ключу (/api/peers*) и общий профиль сервера (/api/profile). +// +// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный +// awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он +// становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers). +// Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers +// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить, +// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться +// (сразу при действии, при подключении ноды и раз в минуту). import crypto from "crypto"; import fs from "fs"; import http from "http"; @@ -22,6 +30,16 @@ import { 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; +} + interface MasterRow { id: number; label: string; secret: Buffer; device_limit: number; server_id: number; created_at: number; revoked_at: number | null; @@ -31,9 +49,19 @@ interface DeviceRow { 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 B64_PUB = /^[A-Za-z0-9+/]{43}=$/; const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная @@ -53,12 +81,15 @@ function makeLimiter(max: number, windowMs: number) { }; } -export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) { - const { uidb, ctrl } = deps; +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; @@ -109,6 +140,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: 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 + ); `); // Версия клиента — необязательное поле, добавленное позже: у уже созданной // таблицы колонки может не быть. @@ -116,14 +179,30 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''"); 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 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); + `); + const q = { masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"), masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"), masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"), - masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"), + masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), 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 = ?"), masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE 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 = ?"), @@ -133,47 +212,171 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: 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 = ?"), - deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 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 = ?"), + 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); - // ── Общие куски ──────────────────────────────────────────────────────── - async function profile() { - const r = await ctrl("GET", "/api/profile"); - if (!hasCtrlOk(r.status)) throw new Error("profile"); - return r.data as { - name: string; endpoint: string; server_pub: string; gen: string; - dns: string[]; keepalive: string; mtu: number; awg: Record; - }; + // ── Сведение серверов ────────────────────────────────────────────────── + 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: `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); + } + } + + /** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */ + 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(":")); - async function buildConfig(dev: DeviceRow) { - const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]); - if (!hasCtrlOk(peers.status)) throw new Error("peers"); - const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[]) - .find(p => p.pub_key === dev.pub_key); - if (!peer) throw new Error("peer missing"); + 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: [`${hostOf(prof.endpoint)}:${SUB_PORT}`], - servers: [{ - id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub, - psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns, - keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg, - }], + 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 dropPeer(pub: string) { - await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно + // Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель + // из-за временной недоступности нод — пусть лучше живёт со старым конфигом. + 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 ─────────────────────────────────────────── @@ -245,34 +448,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined; if (!master) { send(res, 404, { error: "not_found" }); return; } + const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id)); + if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; } let deviceRowId = 0; - let oldPub: string | null = null; + let touched: number[] = []; try { uidb.transaction(() => { const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined; - if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); } + 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(res, 403, { error: "device_limit" }); return; } send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты } - if (oldPub) await dropPeer(oldPub); - const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` }); - if (!hasCtrlOk(r.status)) { - q.deviceDelete.run(deviceRowId); - send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); + 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(res, 502, { error: "unavailable" }); return; } - const dev = q.deviceById.get(deviceRowId) as DeviceRow; // devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу, // без отдельного запроса списка. send(res, 201, { - device: dev.id, config: await buildConfig(dev), + device: dev.id, config: cfg, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit, }); })); @@ -298,7 +505,7 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: // Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели. const v = String(req.headers["x-sen-version"] ?? ""); if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id); - send(res, 200, { config: await buildConfig(dev) }); + send(res, 200, { config: await readyConfig(dev, req) }); })); // Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать @@ -317,20 +524,31 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: }); })); + // Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же 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(res, 400, { error: "bad_request" }); return; } - const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub }); - if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; } - q.deviceRekeyed.run(pub, dev.id); - send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) }); + if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; } + try { q.devicePub.run(pub, 0, dev.id); } + catch { send(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(res, 502, { error: "unavailable" }); + return; + } + send(res, 200, { config: cfg }); })); sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => { const dev = (req as DevReq).device as DeviceRow; - await dropPeer(dev.pub_key); - q.deviceDelete.run(dev.id); + await reconcileAll(forgetDevice(dev)); send(res, 200, { ok: true }); })); @@ -345,6 +563,15 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: const LABEL = /^[^\r\n<>]{1,40}$/; const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100; + // Набор серверов из тела запроса: непустой, без повторов, только существующие. + function parseServers(v: unknown): number[] | string { + if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер"; + if (v.length > MAX_SERVERS) return `Не больше ${MAX_SERVERS} серверов`; + const ids = [...new Set(v)]; + if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) return "Такого сервера нет"; + return (ids as number[]).sort((a, b) => a - b); + } + const masterKeys = Router(); masterKeys.get("/", (_req, res) => { @@ -353,30 +580,62 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: tls: tlsPin !== null, keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({ id: m.id, label: m.label, device_limit: m.device_limit, - devices: m.devices, server_id: m.server_id, created_at: m.created_at, + devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at, })), }); }); masterKeys.post("/", (req, res) => { - const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number }; + const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown }; if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; } const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit; if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } - const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now()); - res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 }); + const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted); + if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; } + let id = 0; + uidb.transaction(() => { + id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid); + for (const sid of ids) q.serverAdd.run(id, sid); + })(); + res.status(201).json({ id, label, device_limit: limit, devices: 0, servers: ids }); }); - masterKeys.patch("/:id", (req, res) => { + masterKeys.patch("/:id", aw(async (req, res) => { const m = q.masterById.get(idOf(req)) as MasterRow | undefined; if (!m) { res.status(404).json({ error: "Не найден" }); return; } - const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number }; + const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown }; const nl = label ?? m.label, nd = device_limit ?? m.device_limit; if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; } if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } + + let ids = serverIdsOf(m.id); + const affected: number[] = []; + if (wanted !== undefined) { + const next = parseServers(wanted); + if (typeof next === "string") { res.status(400).json({ error: next }); return; } + 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, nd, m.id); - res.json({ id: m.id, label: nl, device_limit: nd }); - }); + await reconcileAll(affected); + res.json({ id: m.id, label: nl, device_limit: nd, servers: ids }); + })); masterKeys.post("/:id/rotate", (req, res) => { const m = q.masterById.get(idOf(req)) as MasterRow | undefined; @@ -395,50 +654,64 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: masterKeys.delete("/:id", aw(async (req, res) => { const m = q.masterById.get(idOf(req)) as MasterRow | undefined; if (!m) { res.status(404).json({ error: "Не найден" }); return; } - for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key); - q.masterDelete.run(m.id); // devices уйдут каскадом + const affected: number[] = []; + for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d)); + q.masterDelete.run(m.id); // master_servers уйдут каскадом + await reconcileAll(affected); res.json({ success: true, id: m.id }); })); - masterKeys.get("/:id/link", aw(async (req, res) => { + masterKeys.get("/:id/link", (req, res) => { const m = q.masterById.get(idOf(req)) as MasterRow | undefined; if (!m) { res.status(404).json({ error: "Не найден" }); return; } if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; } - const prof = await profile(); + const host = subHost(req); + if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; } const link = encodeSenLink({ tls: tlsPin !== null, - addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }], + addrs: [{ host, port: SUB_PORT }], secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label, }); res.json({ link, tls: tlsPin !== null }); - })); + }); + // Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть + // на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ. masterKeys.get("/:id/devices", aw(async (req, res) => { const m = q.masterById.get(idOf(req)) as MasterRow | undefined; if (!m) { res.status(404).json({ error: "Не найден" }); return; } const stats = new Map(); - const r = await ctrl("GET", "/api/peers"); - if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p); + 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 }[]) { + const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 }; + 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, + }); + } + })); res.json({ - devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({ - 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, - ...pick(stats.get(d.pub_key)), - })), + devices: (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, + servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length, + }; + }), }); })); - function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) { - return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 }; - } - const devices = Router(); devices.delete("/:id", aw(async (req, res) => { const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; if (!d) { res.status(404).json({ error: "Не найдено" }); return; } - await dropPeer(d.pub_key); - q.deviceDelete.run(d.id); + await reconcileAll(forgetDevice(d)); res.json({ success: true, id: d.id }); })); @@ -449,15 +722,25 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: res.json({ id: d.id }); }); + // Новый PSK на каждом сервере; недоступные получат его, когда появятся. devices.post("/:id/psk", aw(async (req, res) => { const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; if (!d) { res.status(404).json({ error: "Не найдено" }); return; } - const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`); - res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" }); + q.linkPskDue.run(d.id); + await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id)); + const pending = (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length; + res.json({ id: d.id, pending }); })); - // ── Листенер ─────────────────────────────────────────────────────────── + // ── Листенер и фоновое сведение ──────────────────────────────────────── + servers.onOnline(id => { void reconcile(id); }); + function start() { + // Сводим и без подписки: у ключей могут остаться хвосты на удаление. + setInterval(() => { + void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id)); + }, SYNC_EVERY).unref(); + if (!signKey) return; if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; } const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub); diff --git a/cli/src/index.ts b/cli/src/index.ts index ab741c3..717d97b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -100,6 +100,9 @@ const SERVICES = { SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key", SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key", SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt", + // Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального + // сервера (у панели без своего VPN — тот, по которому к ней зашли). + SUB_HOST: process.env.SUB_HOST ?? "", // Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в // join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off — // core без собственного VPN: awg-ctrl рядом не нужен. diff --git a/install.sh b/install.sh index ffc7d1e..dbd1d14 100644 --- a/install.sh +++ b/install.sh @@ -1363,6 +1363,7 @@ SUB_TLS=${SUB_TLS} SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV} SUB_TLS_KEY_FILE=${SUB_TLS_KEY} SUB_TLS_CERT_FILE=${SUB_TLS_CRT} +SUB_HOST=${SERVER_IP} # ── приём нод (несколько серверов) ──────────────────────────────────────── NODE_PORT=${NODE_PORT}