diff --git a/.gitignore b/.gitignore index cc537f4..48af737 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ **/node_modules/ **/dist/ cli/cli.env +awg-ui/panel.id logs/ npm-debug.log* yarn-debug.log* diff --git a/awg-ctrl/index.ts b/awg-ctrl/index.ts index 2dfbc81..71b0f8d 100644 --- a/awg-ctrl/index.ts +++ b/awg-ctrl/index.ts @@ -305,6 +305,7 @@ const stmts = { peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"), peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"), peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"), + peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? WHERE pub_key = ?"), userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"), get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"), all: db.prepare<[], UserRow>("SELECT * FROM users"), @@ -946,8 +947,52 @@ function removePeer(pub: string) { logger.info("peer removed", { owner: peer.owner }); } +// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны +// на этом сервере, — всё остальное из её области («/…») снимается. Удаляем только +// по такому явному списку, никогда по времени или неактивности: панель не на связи — +// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель +// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они, +// уже не разобрать. +const PANEL_ID = /^[a-z0-9]{8,16}$/; +const LEGACY_OWNER = /^m\d+\/d\d+$/; +const SYNC_MAX = 10_000; + +function syncOwnedPeers(panel: string, keep: string[]): { + present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[]; +} { + const want = new Set(keep); + const prefix = panel + "/"; + const removed: PeerRow[] = []; + let claimed = 0; + db.transaction(() => { + for (const p of stmts.peersAll.all()) { + if (p.owner.startsWith(prefix)) { + if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); } + } else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) { + stmts.peerOwner.run(prefix + p.owner, p.pub_key); + claimed++; + } + } + })(); + + for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]); + if (removed.length || claimed) rebuildConf(); + if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) }); + + const all = stmts.peersAll.all(); + return { + present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key), + removed: removed.length, + legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })), + }; +} + const app = express(); -app.use(express.json({ limit: "1kb" })); +// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb. +const SYNC_PATH = "/api/peers/sync"; +const jsonSmall = express.json({ limit: "1kb" }); +const jsonSync = express.json({ limit: "512kb" }); +app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next)); function verifyInternalToken(token: string): boolean { try { @@ -1070,6 +1115,14 @@ app.get("/api/peers", auth, handler((_req, res) => { }); })); +// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ. +app.put(SYNC_PATH, auth, handler((req, res) => { + const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown }; + if (typeof owner !== "string" || !PANEL_ID.test(owner)) throw new HttpError(400, "Неверный owner"); + if (!Array.isArray(keep) || keep.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей"); + res.json(syncOwnedPeers(owner, keep.map(checkPubKey))); +})); + app.put("/api/peers/:pub", auth, handler((req, res) => { const { pub_key } = (req.body ?? {}) as { pub_key?: string }; res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key))); diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 58a8ef4..91a89a0 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -48,15 +48,15 @@ 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[]; servers: number[] } +interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: 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")], 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, servers: m.servers, 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, ...(m.deleting ? { deleting: m.deleting } : {}) }); // Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась). const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length }); -const find = (id: string) => masters.find(m => m.id === Number(id)); +const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" })); app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" })); @@ -80,7 +80,16 @@ app.post("/ui/nodes", (q, r) => { r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 }); }); app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); }); -app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); }); +app.delete("/ui/nodes/:id", (q, r) => { + const id = Number(q.params.id), i = mnodes.findIndex(x => x.id === id); if (i < 0) return r.status(404).json({ error: "Не найден" }); + if (masters.some(m => !m.deleting && m.servers.includes(id))) return r.status(409).json({ error: "Сервер в мастер-ключах — сначала уберите его оттуда", can_force: false }); + const waiting = masters.filter(m => m.deleting?.includes(id)); + if (waiting.length && q.query.force !== "1") + return r.status(409).json({ error: `Сервер не подтвердил, что снял пиров удалённых мастер-ключей (${waiting.map(m => m.label).join(", ")})`, can_force: true }); + for (const m of waiting) m.deleting = m.deleting!.filter(x => x !== id); + for (const m of waiting) if (!m.deleting!.length) masters.splice(masters.indexOf(m), 1); + mnodes.splice(i, 1); r.json({ success: true }); +}); app.post("/awg/restart", (_q, r) => r.json({ up: true })); app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length })); @@ -116,7 +125,12 @@ app.patch("/ui/masterkeys/:id", (q, r) => { }); 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 }); }); +app.delete("/ui/masterkeys/:id", (q, r) => { + const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); + const pending = m.servers.filter(id => id !== 0 && !mnodes.find(n => n.id === id)?.online); + if (pending.length) { m.deleting = pending; m.devs = []; } else masters.splice(masters.indexOf(m), 1); + r.json({ success: true, id: m.id, pending }); +}); 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 }); diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index d3b29c0..f6f65c2 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -148,6 +148,13 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba const onlineHandlers: ((id: number) => void)[] = []; const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); }; + // Подписчики на «сервер удаляют»: подписка сперва снимает с него своих пиров и может + // запретить удаление. forcible — запрет снимается подтверждением оператора (force): + // сервер просто не всё успел подтвердить, а не используется. + type DeleteVeto = { error: string; forcible: boolean } | null; + const deleteHandlers: ((id: number, force: boolean) => Promise)[] = []; + const beforeDelete = (cb: (id: number, force: boolean) => Promise) => { deleteHandlers.push(cb); }; + /** Сервер по умолчанию: локальный, а без него — первая нода. */ function defaultId(): number { if (LOCAL) return 0; @@ -339,16 +346,23 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba res.json({ id, name }); }); - router.delete("/:id", (req: Request, res: Response) => { + router.delete("/:id", async (req: Request, res: Response) => { const id = idOf(req); 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_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; } - }); - if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; } + // Ключи API ссылаются на server_id — не оставляем их висеть в воздухе. Мастер-ключи + // проверяет подписка в своём обработчике. + let used = false; + try { used = (uidb.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE server_id = ?").get(id) as { n: number }).n > 0; } + catch { /* таблицы ещё нет */ } + if (used) { res.status(409).json({ error: "К серверу привязаны ключи API — сначала удалите их" }); return; } + const force = req.query.force === "1"; + for (const cb of deleteHandlers) { + let veto: DeleteVeto; + try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; } + if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; } + } + if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; } const c = conns.get(id); if (c) { dropConn(id, c); c.ws.close(4004, "removed"); } q.delete.run(id); @@ -359,5 +373,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba res.json({ success: true, id }); }); - return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start }; + return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, defaultId, start }; } diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index c751dbb..6d813e5 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -102,7 +102,15 @@ export default function App() { const deleteNode = useCallback(async (n: NodeInfo) => { if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return; try { - await apiFetch('DELETE', `/ui/nodes/${n.id}`, token); + try { + await apiFetch('DELETE', `/ui/nodes/${n.id}`, token); + } catch (e) { + // Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) — + // панель объясняет, что останется, и удаляет только с согласия. + const data = axios.isAxiosError(e) ? e.response?.data : undefined; + if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e; + await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token); + } showMsg('Сервер удалён'); await loadNodes(token); } catch (e) { diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index df81af1..6c0b530 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -10,7 +10,8 @@ import './masterkeys.css'; // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка // плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся // на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md. -interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number } +// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров. +interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: 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; @@ -47,7 +48,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { const list: MasterKey[] = d.keys ?? []; setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls); apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); - const all = await Promise.all(list.map(k => + const all = await Promise.all(list.filter(k => !k.deleting).map(k => apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const))); setDevs(Object.fromEntries(all)); } catch { showMsg('Ошибка загрузки мастер-ключей'); } @@ -71,10 +72,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { }, 'Мастер-ключ создан'); }, [label, limit, nodes, token, act]); + const serverNames = useCallback((ids: number[]) => + ids.map(id => nodes.find(n => n.id === id)?.name ?? `#${id}`).join(', '), [nodes]); + const remove = useCallback(async (k: MasterKey) => { if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; - await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label); - }, [token, act]); + try { + const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token); + const pending: number[] = r.pending ?? []; + showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label); + await load(); + } catch (e) { showMsg(errText(e)); } + }, [token, load, showMsg, serverNames]); const fetchLink = useCallback(async (k: MasterKey) => { const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token); @@ -101,6 +110,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { useEffect(() => { if (token) load(); }, [token, load]); + // Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они. + const waiting = keys.some(k => k.deleting); + useEffect(() => { + if (!waiting) return; + const t = setInterval(load, 15_000); + return () => clearInterval(t); + }, [waiting, load]); + + const deletingChip = (k: MasterKey) => ( + + Удаляется — ждёт: {serverNames(k.deleting ?? [])} + + ); + const actions = (k: MasterKey) => (
@@ -277,6 +300,12 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { {keys.length === 0 ? ( Нет мастер-ключей ) : keys.map(k => { + if (k.deleting) return ( + + {k.label} + {deletingChip(k)} + + ); const list = devs[k.id] ?? []; const free = Math.max(0, k.device_limit - list.length); const open = !folded.has(k.id); @@ -324,6 +353,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { {keys.length === 0 ?

Нет мастер-ключей

: (
    {keys.map(k => { + if (k.deleting) return ( +
  • +
    + + {k.label} + {deletingChip(k)} +
    +
  • + ); const list = devs[k.id] ?? []; const free = Math.max(0, k.device_limit - list.length); const isFolded = folded.has(k.id); diff --git a/awg-ui/sub.ts b/awg-ui/sub.ts index 68c1040..b8ab4be 100644 --- a/awg-ui/sub.ts +++ b/awg-ui/sub.ts @@ -16,6 +16,14 @@ // хранит, какой ключ сейчас стоит на сервере, 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"; @@ -38,6 +46,7 @@ export interface Servers { ids(): number[]; defaultId(): number; onOnline(cb: (id: number) => void): void; + beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void; } interface MasterRow { @@ -69,13 +78,17 @@ const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url"); // ── Простой лимитер по IP ────────────────────────────────────────────────── -function makeLimiter(max: number, windowMs: number) { +// 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) return false; + if (e.n >= max) { + if (e.n === max) { e.n++; onLimited?.(ip); } + return false; + } e.n++; return true; }; @@ -172,6 +185,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas profile TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS sub_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); `); // Версия клиента — необязательное поле, добавленное позже: у уже созданной // таблицы колонки может не быть. @@ -185,20 +202,37 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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); + 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 WHERE revoked_at IS NULL ORDER BY id"), + // И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление. + 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"), masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"), 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 = ?"), + 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 = ?"), @@ -228,6 +262,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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"), @@ -290,7 +334,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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}` }); + 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; @@ -321,6 +365,33 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas } 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(); } /** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */ @@ -387,49 +458,69 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas verify: (req, _res, buf) => { (req as any).rawBody = buf; }, })); - const registerLimit = makeLimiter(30, 15 * 60 * 1000); - const failLimit = makeLimiter(30, 15 * 60 * 1000); + 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"; - function send(res: Response, status: number, payload: unknown) { - res.status(status).json(signResponse(payload, signKey as crypto.KeyObject)); + // Ответ подписан и несёт эхо запроса: 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 байта. - function checkSigned(req: Request, authPub: Buffer): boolean { + // 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) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false; + 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 false; + 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 false; + if (seenSigs.has(sig)) return "replay"; seenSigs.set(sig, t + 2 * TS_WINDOW * 1000); - return true; + 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.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined; - if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) { + 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, под лимит не попадает. - if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; } - send(res, 401, { error: "unauthorized" }); + 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(res, 502, { error: "unavailable" }); }); }; + (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 = req.socket.remoteAddress ?? "unknown"; - if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; } + 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; @@ -440,16 +531,21 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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(res, 400, { error: "bad_request" }); return; } + if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; } // Регистрация подписана ключом auth_pub из самого тела — доказательство владения. const authPub = Buffer.from(authPubS, "base64url"); - if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; } + 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(res, 404, { error: "not_found" }); return; } + 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(res, 502, { error: "unavailable" }); return; } + if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; } let deviceRowId = 0; let touched: number[] = []; @@ -463,8 +559,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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 (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]); @@ -473,12 +569,12 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas if (cfg.servers.length === 0) { // Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже. await reconcileAll(forgetDevice(dev)); - send(res, 502, { error: "unavailable" }); + send(req, res, 502, { error: "unavailable" }); return; } // devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу, // без отдельного запроса списка. - send(res, 201, { + send(req, res, 201, { device: dev.id, config: cfg, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit, }); @@ -488,15 +584,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas // регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register, // а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать. sub.post("/sub/v1/peek", wrap(async (req, res) => { - const ip = req.socket.remoteAddress ?? "unknown"; + 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(res, 429, { error: "rate_limited" }); return; } - send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" }); + 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(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit }); + send(req, res, 200, { 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) => { @@ -505,7 +601,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas // Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели. 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 readyConfig(dev, req) }); + send(req, res, 200, { config: await readyConfig(dev, req) }); })); // Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать @@ -513,8 +609,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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(res, 404, { error: "not_found" }); return; } - send(res, 200, { + if (!master) { send(req, res, 404, { error: "not_found" }); return; } + send(req, res, 200, { name: master.label, device_limit: master.device_limit, devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({ @@ -529,10 +625,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas 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; } - if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; } + 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(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства + 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); @@ -540,21 +636,21 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas if (cfg.servers.length === 0) { q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id); await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить - send(res, 502, { error: "unavailable" }); + send(req, res, 502, { error: "unavailable" }); return; } - send(res, 200, { config: cfg }); + 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(res, 200, { ok: true }); + send(req, res, 200, { ok: true }); })); - sub.use((_req, res) => { send(res, 404, { error: "not_found" }); }); + sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); }); // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. - sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); }); + sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); }); // ── Админ-роуты панели (монтируются в server.ts за JWT) ───────────────── const aw = (fn: (req: Request, res: Response) => Promise) => @@ -578,9 +674,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas res.json({ enabled: signKey !== null, tls: tlsPin !== null, + // deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров. keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({ id: m.id, 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) } : {}), })), }); }); @@ -651,14 +749,19 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas res.json({ id: m.id }); }); + // Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся + // отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель + // сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются. 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; } - 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 уйдут каскадом + 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); - res.json({ success: true, id: m.id }); + res.json({ success: true, id: m.id, pending: serverIdsOf(m.id) }); })); masterKeys.get("/:id/link", (req, res) => { @@ -735,6 +838,26 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas // ── Листенер и фоновое сведение ──────────────────────────────────────── servers.onOnline(id => { void reconcile(id); }); + // Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва + // сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после + // удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force. + 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) return null; + if (!force) return { + error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") + + ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)", + forcible: true, + }; + q.serverRelease.run(id); + q.revokedGone.run(); + return null; + }); + function start() { // Сводим и без подписки: у ключей могут остаться хвосты на удаление. setInterval(() => { diff --git a/cli/src/index.ts b/cli/src/index.ts index 717d97b..70695a1 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -383,12 +383,150 @@ async function joinCore(link?: string, flag?: string) { for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v); writeFileSync(envFile, content, { mode: 0o600 }); + // Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше + // некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их). + const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false; if (!keepPanel) await stop("ui"); + if (dropPeers) await dropArea(localPanelId()!); console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` + (keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : "")); restartFresh(); } +// ── Пиры подписки (sen://) на локальном awg-ctrl ────────────────────────────── +// Пира ставит панель, owner = «/m<ключ>/d<устройство>»: id — область панели. +// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области +// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели +// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для +// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется. +const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id"); +const AREA_RE = /^[a-z0-9]{8,16}$/; +const LEGACY_OWNER = /^m\d+\/d\d+$/; + +function localPanelId(): string | null { + try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; } + catch { return null; } +} + +// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с). +function mintInternalToken(): string { + const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE)); + const t = Math.floor(Date.now() / 1000); + const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url"); + const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url"); + return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`; +} + +function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request({ + host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000, + headers: { + Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json", + ...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}), + }, + }, res => { + let raw = ""; + res.on("data", c => { raw += c; }); + res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } }); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("timeout"))); + req.end(payload); + }); +} + +interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean } + +async function listPeers(): Promise { + const r = await ctrlCall("GET", "/api/peers"); + if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`); + return r.data.peers as CtrlPeer[]; +} + +// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш). +function areaOf(owner: string): string | null { + const head = owner.split("/")[0]; + if (owner.includes("/") && AREA_RE.test(head)) return head; + return LEGACY_OWNER.test(owner) ? "legacy" : null; +} + +const peersWord = (n: number) => + n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров"; + +async function askDropPanelPeers(): Promise { + const own = localPanelId(); + if (!own) return false; + let n: number; + try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; } + catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; } + if (n === 0) return false; + + const preset = (process.env.NODE_PEERS ?? "").toLowerCase(); + if (preset === "keep" || preset === "drop") return preset === "drop"; + console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`); + console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`); + console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const a = await new Promise(r => rl.question(" Выбор [1]: ", r)); + rl.close(); + return a.trim() === "2"; +} + +// Снять всю область: для панели — пустым списком через sync, для legacy — по одному. +async function dropArea(area: string): Promise { + try { + if (area === "legacy") { + const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy"); + for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`); + console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`); + return; + } + const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] }); + if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`); + console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`); + } catch (e) { + console.error(` Не удалось снять пиров: ${(e as Error).message}`); + } +} + +async function peersCmd(sub?: string, arg?: string) { + const own = localPanelId(); + let peers: CtrlPeer[]; + try { peers = await listPeers(); } + catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); } + const areas = new Map(); + for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); } + + if (sub === undefined) { + if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; } + console.log("\n Пиры подписки (sen://) на этом сервере:\n"); + for (const [a, list] of areas) { + const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : ""; + console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` + + ` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`); + } + console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`); + return; + } + if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) { + console.error(" Usage: awg-ctrl peers [drop <область|legacy>]"); + process.exit(1); + } + const n = areas.get(arg)?.length ?? 0; + if (!n) { console.log(` В области ${arg} пиров нет`); return; } + // Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно. + if (arg === own && isRunning("ui")) { + console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней"); + process.exit(1); + } + console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`); + console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`); + if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return; + await dropArea(arg); +} + async function unjoinCore() { if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; } await stop("agent"); @@ -502,7 +640,7 @@ async function main() { return; } - const USAGE = "Usage: cli [--no-panel]|unjoin>"; + const USAGE = "Usage: cli [--no-panel]|unjoin|peers [drop <область>]>"; const [,,, sub, arg] = process.argv; @@ -518,6 +656,7 @@ async function main() { break; case "join": await joinCore(sub, arg); break; case "unjoin": await unjoinCore(); break; + case "peers": await peersCmd(sub, arg); break; default: console.log(USAGE); process.exit(1); } }