diff --git a/README.md b/README.md index 26a1085..545ce16 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,39 @@ curl -s -X POST http://HOST:PORT/api/v1/users \ `"3.1"`). Операции только CRD: у пира нет изменяемых полей, «перевыпустить» ключ = удалить и создать заново. +У ключа — **набор серверов**, которые он видит (панель → «API-ключи» → шестерёнка; +`0` — сервер самой панели, остальные — подключённые ноды). Пользователи `vpn://` +заводятся на сервере ключа по умолчанию; другой сервер из набора выбирается +заголовком `X-Server-Id: `, сервер вне набора → `404`. + +#### Мастер-ключи `sen://` + +Мастер-ключ — ссылка `sen://…` для приложения SenAWG с лимитом устройств: каждое +устройство само регистрируется по ссылке и получает пира на всех серверах ключа, +его приватный ключ сервер не видит. API-ключ видит и меняет мастер-ключ, только +если **все** его серверы входят в набор API-ключа; остальные для него не +существуют (`404`). + +```bash +curl -s -X POST http://HOST:PORT/api/v1/masterkeys \ + -H "X-Api-Key: awgk_xxx" -H "Content-Type: application/json" \ + -d '{"label":"Семья","device_limit":3}' | jq -r .link +``` + +| Метод | Что делает | +|---|---| +| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) | +| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` | +| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) | +| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств | +| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше | +| `POST /api/v1/masterkeys/:id/rekey` | Все устройства сменят ключи WireGuard при следующем опросе (IP и PSK сохраняются) | +| `DELETE /api/v1/masterkeys/:id` | `{ success, id, pending }` — ключ и устройства отключаются сразу; `pending` — серверы (ноды не в сети), которые ещё не сняли пиров, до тех пор ключ в списке с `deleting` | +| `GET /api/v1/masterkeys/:id/devices` | `{ devices: [{ id, device_name, platform, version, online, lastHandshake, rx, tx, last_seen, servers_ok, servers_total, … }] }` — статистика по всем серверам ключа | +| `DELETE /api/v1/masterkeys/:id/devices/:device` | Отвязывает устройство, место освобождается | +| `POST /api/v1/masterkeys/:id/devices/:device/rekey` | Устройство сменит ключ при следующем опросе | +| `POST /api/v1/masterkeys/:id/devices/:device/psk` | Новый PSK: `{ id, pending }` — `pending` серверов ждут ноду | + | Код | Когда | |---|---| | `401 {"error":"API key required"}` | заголовка нет или он не начинается с `awgk_` | @@ -92,8 +125,9 @@ curl -s -X POST http://HOST:PORT/api/v1/users \ Ключи лежат в собственной БД панели (`/etc/amnezia/amneziawg/ui.db`) — вне каталога установки, поэтому переустановка их не трогает. Сама awg-ctrl про внешние ключи ничего не знает: awg-ui ходит к ней своей внутренней авторизацией. -Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` и -`DELETE /ui/apikeys/:id`. +Управлять ключами можно и без панели, по JWT: `GET/POST /ui/apikeys` (с полем +`servers`), `PATCH /ui/apikeys/:id` `{ "servers": [0, 2] }` — сменить набор, не +перевыпуская ключ, и `DELETE /ui/apikeys/:id`. Подробно — `awg-ui/API.md`. ### Авторизация панели @@ -156,6 +190,22 @@ base64url-декодировать, отбросить первые 4 байта 3.1-конфиг — в этом случае прежний конфиг возвращается из копии, а интерфейс поднимается обратно на 2.0. +### Мастер-ключи + +Те же операции, что в `/api/v1/masterkeys`, но без ограничения набором серверов. + +| Метод | Что делает | +|---|---| +| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` | +| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` | +| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` | +| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` | +| `GET /ui/masterkeys/:id/devices` | Устройства со статусом и трафиком | +| `POST /ui/masterkeys/:id/rotate`, `/rekey` | Новая ссылка / смена ключей у всех устройств | +| `DELETE /ui/masterkeys/:id` | `{ success, id, pending }` | +| `DELETE /ui/devices/:id` | Отвязать устройство | +| `POST /ui/devices/:id/rekey`, `/psk` | Смена ключа / PSK у устройства | + ## Управление ```bash diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 91a89a0..547cd66 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -43,7 +43,7 @@ let nodeId = 2; const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url"); // ── API-ключи ── -const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }]; +const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, servers: [0], created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }]; let apiId = 1; // ── мастер-ключи sen:// ── @@ -108,7 +108,13 @@ app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q. app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); }); app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys })); -app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); }); +app.post("/ui/apikeys", (q, r) => { const sid = Number(q.body.server_id ?? 0); const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: sid, servers: q.body.servers ?? [sid], created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); }); +app.patch("/ui/apikeys/:id", (q, r) => { + const k = apiKeys.find(x => x.id === Number(q.params.id)); if (!k) return r.status(404).json({ error: "Не найден" }); + if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); + k.servers = [...q.body.servers].sort(); if (!k.servers.includes(k.server_id)) k.server_id = k.servers[0]; + r.json({ id: k.id, server_id: k.server_id, servers: k.servers }); +}); app.delete("/ui/apikeys/:id", (q, r) => { const i = apiKeys.findIndex(k => k.id === Number(q.params.id)); if (i >= 0) apiKeys.splice(i, 1); r.json({ success: true }); }); app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: masters.map(mkOut) })); diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts index f6f65c2..0863523 100644 --- a/awg-ui/nodes.ts +++ b/awg-ui/nodes.ts @@ -148,12 +148,15 @@ 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): - // сервер просто не всё успел подтвердить, а не используется. + // Подписчики на «сервер удаляют». beforeDelete — проверка: может запретить удаление и не + // меняет ui.db (запретить может и следующий подписчик). forcible — запрет снимается + // подтверждением оператора (force): сервер не всё успел подтвердить, а не используется. + // afterDelete — уборка своих ссылок на сервер, когда он уже удалён. 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); }; + const afterHandlers: ((id: number) => void)[] = []; + const afterDelete = (cb: (id: number) => void) => { afterHandlers.push(cb); }; /** Сервер по умолчанию: локальный, а без него — первая нода. */ function defaultId(): number { @@ -350,12 +353,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba 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 — не оставляем их висеть в воздухе. Мастер-ключи - // проверяет подписка в своём обработчике. - 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; } + // Ключи API и мастер-ключи проверяют их владельцы в своих обработчиках (beforeDelete). const force = req.query.force === "1"; for (const cb of deleteHandlers) { let veto: DeleteVeto; @@ -370,8 +368,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba for (const t of ["peer_tombstones", "server_profiles"]) { try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ } } + for (const cb of afterHandlers) { try { cb(id); } catch (e) { console.warn(`nodes: уборка после удаления #${id}: ${(e as Error).message}`); } } res.json({ success: true, id }); }); - return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, defaultId, start }; + return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, afterDelete, defaultId, start }; } diff --git a/awg-ui/server.ts b/awg-ui/server.ts index 81178e7..5b183ad 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -73,17 +73,56 @@ uidb.exec(` server_id INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_used INTEGER - ) + ); + -- Какие серверы видит ключ: на них он заводит vpn://-пользователей и в их пределах + -- управляет мастер-ключами. api_keys.server_id — сервер по умолчанию для /api/v1/users, + -- всегда входит в набор. + CREATE TABLE IF NOT EXISTS api_key_servers ( + key_id INTEGER NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + server_id INTEGER NOT NULL, + PRIMARY KEY (key_id, server_id) + ); + -- Ключи со времён одного сервера: набор — их server_id. + INSERT OR IGNORE INTO api_key_servers (key_id, server_id) + SELECT id, server_id FROM api_keys WHERE id NOT IN (SELECT key_id FROM api_key_servers); `); const keyStmts = { list: uidb.prepare("SELECT id, label, prefix, server_id, created_at, last_used FROM api_keys ORDER BY id"), insert: uidb.prepare("INSERT INTO api_keys (label, key_hash, prefix, server_id, created_at) VALUES (?, ?, ?, ?, ?)"), delete: uidb.prepare<[number]>("DELETE FROM api_keys WHERE id = ?"), + byId: uidb.prepare<[number]>("SELECT * FROM api_keys WHERE id = ?"), + setDefault: uidb.prepare<[number, number]>("UPDATE api_keys SET server_id = ? WHERE id = ?"), + serversOf: uidb.prepare<[number]>("SELECT server_id FROM api_key_servers WHERE key_id = ? ORDER BY server_id"), + serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO api_key_servers (key_id, server_id) VALUES (?, ?)"), + serversClear: uidb.prepare<[number]>("DELETE FROM api_key_servers WHERE key_id = ?"), + keysOnServer: uidb.prepare<[number]>( + "SELECT k.id, k.label, k.server_id, (SELECT COUNT(*) FROM api_key_servers x WHERE x.key_id = k.id) AS n FROM api_keys k JOIN api_key_servers s ON s.key_id = k.id WHERE s.server_id = ?"), byHash: uidb.prepare<[string]>("SELECT * FROM api_keys WHERE key_hash = ?"), touch: uidb.prepare<[number, number]>("UPDATE api_keys SET last_used = ? WHERE id = ?"), }; +const keyServers = (id: number) => (keyStmts.serversOf.all(id) as { server_id: number }[]).map(r => r.server_id); + +// Набор серверов ключа целиком. Сервер по умолчанию остаётся, если он в наборе, иначе — +// наименьший из набора. +function setKeyServers(id: number, ids: number[]) { + const row = keyStmts.byId.get(id) as ApiKeyRow; + uidb.transaction(() => { + keyStmts.serversClear.run(id); + for (const sid of ids) keyStmts.serverAdd.run(id, sid); + if (!ids.includes(row.server_id)) keyStmts.setDefault.run(ids[0], id); + })(); +} + +// Набор серверов из тела запроса: непустой, без повторов, только существующие. +function parseKeyServers(v: unknown): number[] | string { + if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер"; + const ids = [...new Set(v)]; + if (!ids.every(id => Number.isInteger(id) && nodes.exists(id as number))) return "Такого сервера нет"; + return (ids as number[]).sort((a, b) => a - b); +} + function hashKey(key: string): string { return crypto.createHash("sha256").update(key).digest("hex"); } @@ -173,26 +212,43 @@ app.get("/ui/brand", (_req: Request, res: Response) => { // Управление ключами внешнего API — роуты самой awg-ui, в awg-ctrl не идут. app.get("/ui/apikeys", requireAuth, (_req: Request, res: Response) => { - res.json({ keys: keyStmts.list.all() }); + res.json({ keys: (keyStmts.list.all() as ApiKeyRow[]).map(k => ({ ...k, servers: keyServers(k.id) })) }); }); +// servers — набор серверов ключа; без него — server_id (по-старому) или сервер по умолчанию. app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => { - const { label, server_id } = req.body as { label?: string; server_id?: number }; + const { label, server_id, servers } = req.body as { label?: string; server_id?: number; servers?: unknown }; if (!label || !/^[\w \-]{1,40}$/.test(label)) { res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; } - const sid = server_id === undefined ? nodes.defaultId() : Number(server_id); - if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; } + const ids = parseKeyServers(servers ?? [server_id === undefined ? nodes.defaultId() : Number(server_id)]); + if (typeof ids === "string") { res.status(400).json({ error: ids }); return; } + const sid = server_id !== undefined && ids.includes(Number(server_id)) ? Number(server_id) : ids[0]; const { key, hash, prefix } = genApiKey(); - const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)); + let id = 0; + uidb.transaction(() => { + id = Number(keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)).lastInsertRowid); + for (const s of ids) keyStmts.serverAdd.run(id, s); + })(); // Открытое значение отдаётся один раз — дальше в БД только хэш. - res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); + res.status(201).json({ id, label, prefix, key, server_id: sid, servers: ids }); +}); + +// Сменить набор серверов, не перевыпуская ключ. +app.patch("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { + const id = Number(req.params.id); + if (!Number.isInteger(id) || !keyStmts.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; } + const ids = parseKeyServers((req.body as { servers?: unknown } | undefined)?.servers); + if (typeof ids === "string") { res.status(400).json({ error: ids }); return; } + setKeyServers(id, ids); + const row = keyStmts.byId.get(id) as ApiKeyRow; + res.json({ id, server_id: row.server_id, servers: ids }); }); app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { const id = Number(req.params.id); if (!Number.isInteger(id)) { res.status(400).json({ error: "Неверный id" }); return; } - keyStmts.delete.run(id); + uidb.transaction(() => { keyStmts.serversClear.run(id); keyStmts.delete.run(id); })(); res.json({ success: true, id }); }); @@ -214,7 +270,7 @@ async function proxy(req: Request, res: Response) { } } -interface ExtRequest extends Request { apiKey?: ApiKeyRow; } +interface ExtRequest extends Request { apiKey?: ApiKeyRow; servers?: number[]; serverId?: number; } function requireApiKey(req: Request, res: Response, next: NextFunction) { const key = (req.headers["x-api-key"] as string) ?? ""; @@ -223,6 +279,18 @@ function requireApiKey(req: Request, res: Response, next: NextFunction) { if (!row) { res.status(401).json({ error: "Invalid API key" }); return; } keyStmts.touch.run(Math.floor(Date.now() / 1000), row.id); (req as ExtRequest).apiKey = row; + (req as ExtRequest).servers = keyServers(row.id); + next(); +} + +// Сервер для /api/v1/users: X-Server-Id из набора ключа, без заголовка — сервер ключа по +// умолчанию. Сервер вне набора — как несуществующий. +function pickKeyServer(req: Request, res: Response, next: NextFunction) { + const r = req as ExtRequest; + const raw = req.headers["x-server-id"]; + const id = raw === undefined ? r.apiKey!.server_id : Number(raw); + if (!Number.isInteger(id) || !r.servers!.includes(id)) { res.status(404).json({ error: "Сервер не найден" }); return; } + r.serverId = id; next(); } @@ -243,8 +311,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string } const ext = express.Router(); ext.use(requireApiKey); -// Ключ привязан к server_id — все вызовы уходят на его сервер. -const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0); +// vpn://-пользователи живут на одном сервере — на выбранном pickKeyServer. +ext.use("/users", pickKeyServer); +const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).serverId ?? 0); // Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не // отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ @@ -293,6 +362,22 @@ app.use("/ui/nodes", requireAuth, nodes.router); const sub = createSub({ uidb, servers: nodes, baseDir: __dirname }); app.use("/ui/masterkeys", requireAuth, sub.masterKeys); app.use("/ui/devices", requireAuth, sub.devices); +// Мастер-ключи во внешнем API — в пределах набора серверов API-ключа. ext уже смонтирован +// на /api/v1 до JWT-прокси, роуты в нём можно добавлять и позже. +ext.use("/masterkeys", sub.apiRouter(req => (req as ExtRequest).servers ?? [])); + +// Сервер удаляют из реестра: ключ API, для которого он единственный, — повод отказать +// (ключ остался бы без серверов); у остальных он убирается из набора, когда сервер удалён. +type KeyOnServer = { id: number; label: string; server_id: number; n: number }; +nodes.beforeDelete(async id => { + const only = (keyStmts.keysOnServer.all(id) as KeyOnServer[]).filter(k => k.n === 1).map(k => k.label); + return only.length + ? { error: `Это единственный сервер ключей API: ${only.join(", ")} — сначала удалите их или добавьте им другой сервер`, forcible: false } + : null; +}); +nodes.afterDelete(id => { + for (const k of keyStmts.keysOnServer.all(id) as KeyOnServer[]) setKeyServers(k.id, keyServers(k.id).filter(s => s !== id)); +}); app.use(["/api", "/health", "/awg"], requireAuth, proxy); diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index 09115f0..bc7221f 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -732,3 +732,23 @@ tbody td:last-child { padding-right: 20px; } } .qr-img { width: 100%; height: auto; aspect-ratio: 1; } } + +/* Выбор набора серверов галочками (components/ServerPicker): серверы мастер-ключа, + видимость API-ключа */ +.server-pick { + display: flex; align-items: center; gap: 10px; + background: var(--surface-container); + border-radius: 12px; + padding: 10px 14px; + font-size: 13px; + color: var(--on-surface); + cursor: pointer; +} +.server-pick input { accent-color: var(--primary); margin: 0; } +.server-pick:hover { background: var(--state-primary); } +.server-pick--locked { cursor: default; } +.server-pick--locked:hover { background: var(--surface-container); } +.server-pick-dot { padding: 0; background: none; } +.server-pick-name { font-weight: 500; } +.server-pick-ip, .server-pick-gen { font-size: 12px; color: var(--neutral-text); } +.server-pick-ip { font-family: var(--mono); margin-left: auto; } diff --git a/awg-ui/src/components/ServerPicker.tsx b/awg-ui/src/components/ServerPicker.tsx new file mode 100644 index 0000000..eb75a5c --- /dev/null +++ b/awg-ui/src/components/ServerPicker.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +import { type NodeInfo, genLabel } from '../lib/shared'; + +// Выбор набора серверов галочками: серверы мастер-ключа, видимость API-ключа. Набор не +// бывает пустым — последний отмеченный сервер снять нельзя. onChange получает новый набор +// и сервер, который переключили. +export default function ServerPicker({ nodes, selected, onChange }: { + nodes: NodeInfo[]; + selected: number[]; + onChange: (next: number[], node: NodeInfo, added: boolean) => void; +}) { + return ( + <> + {nodes.map(n => { + const on = selected.includes(n.id); + const last = on && selected.length === 1; + const h = n.health; + return ( + + ); + })} + + ); +} + +// Имя сервера для подписей: как в сервер-баре, иначе «#id». +export const serverName = (nodes: NodeInfo[], id: number): string => { + const n = nodes.find(x => x.id === id); + return n ? n.health?.server || n.name : `#${id}`; +}; diff --git a/awg-ui/src/lib/shared.ts b/awg-ui/src/lib/shared.ts index 97cdfc2..dee5f33 100644 --- a/awg-ui/src/lib/shared.ts +++ b/awg-ui/src/lib/shared.ts @@ -32,7 +32,8 @@ export interface ApiKey { id: number; label: string; prefix: string; - server_id: number; + server_id: number; // сервер по умолчанию для /api/v1/users + servers: number[]; // какие серверы видит ключ created_at: number; last_used: number | null; } diff --git a/awg-ui/src/tabs/apikeys/apikeys.css b/awg-ui/src/tabs/apikeys/apikeys.css index af9403a..e9468ce 100644 --- a/awg-ui/src/tabs/apikeys/apikeys.css +++ b/awg-ui/src/tabs/apikeys/apikeys.css @@ -75,3 +75,18 @@ LICENSE file in the root directory of this source tree. @media (max-width: 640px) { .apikey-cards-list { display: flex; } } + +/* Настройки ключа: какие серверы он видит */ +.apikey-tr-tools td { padding-top: 0; } +.apikey-tools { + display: flex; flex-direction: column; gap: 10px; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: 16px; + padding: 12px 16px 14px; + margin: 6px 0 8px; +} +.apikey-tools-title { margin: 0; font-size: 13px; font-weight: 500; color: var(--label); } +.apikey-tools-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } +.apikey-card-btns { display: flex; gap: 8px; flex-shrink: 0; } +.apikey-card-servers { font-size: 12px; color: var(--neutral-text); } diff --git a/awg-ui/src/tabs/apikeys/index.tsx b/awg-ui/src/tabs/apikeys/index.tsx index f569afb..c304f35 100644 --- a/awg-ui/src/tabs/apikeys/index.tsx +++ b/awg-ui/src/tabs/apikeys/index.tsx @@ -1,29 +1,57 @@ // Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. -import { useState, useEffect, useCallback } from 'react'; -import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared'; -import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons'; +import { Fragment, useState, useEffect, useCallback } from 'react'; +import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type NodeInfo, type PageProps } from '../../lib/shared'; +import { IcoPlus, IcoTrash, IcoCopy, IcoSettings } from '../../components/icons'; +import ServerPicker, { serverName } from '../../components/ServerPicker'; import './apikeys.css'; // Вкладка «API-ключи»: ключи внешнего API (/api/v1). Открытый ключ показывается // один раз при создании (в БД хранится только хэш). Сервер-бар сверху рендерит App. -// TODO multi-server: сейчас сервер один (server_id = 0). Когда появится список -// серверов — ключ будет привязываться к выбранному в сервер-баре. +// Ключ видит набор серверов: на них он заводит vpn://-пользователей и в их пределах +// управляет мастер-ключами. Создаётся на выбранном в сервер-баре сервере, набор +// правится в настройках ключа. export default function ApiKeysPage({ token, showMsg }: PageProps) { const [keys, setKeys] = useState([]); + const [nodes, setNodes] = useState([]); const [label, setLabel] = useState(''); const [created, setCreated] = useState<{ label: string; key: string } | null>(null); + const [tools, setTools] = useState(null); // ключ с открытыми настройками const load = useCallback(async () => { try { const data = await apiFetch('GET', '/ui/apikeys', token); setKeys(data.keys ?? []); + apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {}); } catch { showMsg('Ошибка загрузки ключей'); } }, [token, showMsg]); + const setServers = useCallback(async (k: ApiKey, next: number[], n: NodeInfo, added: boolean) => { + try { + await apiFetch('PATCH', `/ui/apikeys/${k.id}`, token, { servers: next }); + showMsg(added ? `Ключ видит «${serverName(nodes, n.id)}»` : `«${serverName(nodes, n.id)}» убран из ключа`); + await load(); + } catch (e: any) { + showMsg(e?.response?.data?.error ?? 'Ошибка запроса'); + } + }, [token, nodes, load, showMsg]); + + const serversText = (k: ApiKey) => (k.servers ?? [k.server_id]).map(id => serverName(nodes, id)).join(', '); + + const toolbox = (k: ApiKey) => ( +
+

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

+

+ На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой + выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь. +

+ setServers(k, next, n, added)} /> +
+ ); + const createKey = useCallback(async () => { if (!label.trim()) return; const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 }); @@ -44,8 +72,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) { return ( <> - {/* Сервер-бар сверху рендерит App; к выбранному в нём серверу и привязывается ключ. - TODO multi-server: server_id берётся из выбранной карточки (пока 0). */} + {/* Сервер-бар сверху рендерит App; новый ключ получает выбранный в нём сервер. */}
# Метка Ключ + Серверы Создан Последний раз @@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) { {keys.length === 0 ? ( - Нет ключей + Нет ключей ) : keys.map((k, i) => ( - - {i + 1} - {k.label} - {k.prefix} - {new Date(k.created_at * 1000).toLocaleDateString()} - {k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'} - -
- -
- - + + + {i + 1} + {k.label} + {k.prefix} + {serversText(k)} + {new Date(k.created_at * 1000).toLocaleDateString()} + {k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'} + +
+ + +
+ + + {tools === k.id && {toolbox(k)}} +
))} @@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) { #{i + 1} {k.label}
- +
+ + +
{k.prefix} + Серверы: {serversText(k)} + {tools === k.id && toolbox(k)}
Создан {new Date(k.created_at * 1000).toLocaleDateString()} {k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'} diff --git a/awg-ui/src/tabs/masterkeys/index.tsx b/awg-ui/src/tabs/masterkeys/index.tsx index 6c0b530..e81ba85 100644 --- a/awg-ui/src/tabs/masterkeys/index.tsx +++ b/awg-ui/src/tabs/masterkeys/index.tsx @@ -3,8 +3,9 @@ // 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 NodeInfo, type PageProps } from '../../lib/shared'; +import { apiFetch, copyText, bytes, timeAgo, type NodeInfo, type PageProps } from '../../lib/shared'; import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons'; +import ServerPicker, { serverName } from '../../components/ServerPicker'; import './masterkeys.css'; // Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка @@ -72,8 +73,7 @@ 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 serverNames = useCallback((ids: number[]) => ids.map(id => serverName(nodes, id)).join(', '), [nodes]); const remove = useCallback(async (k: MasterKey) => { if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return; @@ -182,27 +182,9 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) { пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}

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

- {nodes.map(n => { - const on = k.servers.includes(n.id); - const last = on && k.servers.length === 1; - const h = n.health; - return ( - - ); - })} + + act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }), + added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />

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

diff --git a/awg-ui/src/tabs/masterkeys/masterkeys.css b/awg-ui/src/tabs/masterkeys/masterkeys.css index a8b1a60..31c58a7 100644 --- a/awg-ui/src/tabs/masterkeys/masterkeys.css +++ b/awg-ui/src/tabs/masterkeys/masterkeys.css @@ -149,24 +149,6 @@ LICENSE file in the root directory of this source tree. .mk-meter--full { --meter-fill: var(--error-dot); --meter-on: #fff; } /* лимит — красный */ :root[data-theme="dark"] .mk-meter--full { --meter-on: #2a2118; } -/* серверы, отдаваемые по ключу (заглушка) */ -.mk-server { - display: flex; align-items: center; gap: 10px; - background: var(--surface-container); - border-radius: 12px; - padding: 10px 14px; - font-size: 13px; - color: var(--on-surface); -} -.mk-server input { accent-color: var(--primary); margin: 0; } -.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 { 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 b8ab4be..de25779 100644 --- a/awg-ui/sub.ts +++ b/awg-ui/sub.ts @@ -47,6 +47,7 @@ export interface Servers { defaultId(): number; onOnline(cb: (id: number) => void): void; beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void; + afterDelete(cb: (id: number) => void): void; } interface MasterRow { @@ -219,6 +220,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas // И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление. masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id"), masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"), + masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"), masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"), masterInsert: uidb.prepare<[string, 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 = ?"), @@ -652,65 +654,96 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas // Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять. 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) => - (req: Request, res: Response) => { fn(req, res).catch(() => { res.status(502).json({ error: "awg-ctrl недоступен" }); }); }; - const idOf = (req: Request) => Number(req.params.id); + // ── Мастер-ключи: общая логика панели и внешнего API ─────────────────── + // Панель (/ui/masterkeys, /ui/devices, за JWT) видит всё. Внешний API (/api/v1/masterkeys, + // ключ awgk_) — только ключи, чьи серверы целиком входят в набор серверов API-ключа: + // иначе он мог бы удалить или перенастроить раздачу на сервере, которого не видит. + // Scope: null — панель, иначе набор серверов API-ключа. + type Scope = number[] | null; + + class ApiError extends Error { + constructor(public status: number, message: string) { super(message); } + } + const fail = (status: number, message: string): never => { throw new ApiError(status, message); }; + + // ApiError — её статус; остальное (awg-ctrl не ответил посреди действия) — 502. + const aw = (fn: (req: Request, res: Response) => unknown) => (req: Request, res: Response) => { + Promise.resolve().then(() => fn(req, res)).catch(e => { + if (e instanceof ApiError) res.status(e.status).json({ error: e.message }); + else res.status(502).json({ error: "awg-ctrl недоступен" }); + }); + }; + const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся 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 "Такого сервера нет"; + const visible = (masterId: number, scope: Scope) => + scope === null || serverIdsOf(masterId).every(id => scope.includes(id)); + + // Живой (не удаляемый) ключ в пределах области; чужой — как несуществующий. + function masterFor(id: number, scope: Scope): MasterRow { + const m = q.masterById.get(id) as MasterRow | undefined; + return m && visible(m.id, scope) ? m : fail(404, "Не найден"); + } + + // Устройство — только вместе со своим мастер-ключом (у внешнего API пути вложенные). + function deviceFor(id: number, master?: MasterRow): DeviceRow { + const d = q.deviceById.get(id) as DeviceRow | undefined; + return d && (!master || d.master_id === master.id) ? d : fail(404, "Не найдено"); + } + + // Набор серверов из тела запроса: непустой, без повторов, только существующие и — для + // внешнего API — только из области его ключа. + function parseServers(v: unknown, scope: Scope): number[] { + if (!Array.isArray(v) || v.length === 0) fail(400, "Нужен хотя бы один сервер"); + const list = v as unknown[]; + if (list.length > MAX_SERVERS) fail(400, `Не больше ${MAX_SERVERS} серверов`); + const ids = [...new Set(list)]; + if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) fail(400, "Такого сервера нет"); + if (scope && !ids.every(id => scope.includes(id as number))) fail(403, "Сервер вне области API-ключа"); return (ids as number[]).sort((a, b) => a - b); } - const masterKeys = Router(); + // deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров. + function masterOut(m: MasterRow & { devices: number }) { + return { + 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) } : {}), + }; + } + const masterOutById = (id: number) => masterOut(q.masterRow.get(id) as MasterRow & { devices: number }); - masterKeys.get("/", (_req, res) => { - 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) } : {}), - })), - }); - }); + const listMasters = (scope: Scope) => + (q.masters.all() as (MasterRow & { devices: number })[]).filter(m => visible(m.id, scope)).map(masterOut); - masterKeys.post("/", (req, res) => { - 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; } + // Серверы по умолчанию: у панели — сервер по умолчанию, у API-ключа — вся его область. + function createMaster(body: unknown, scope: Scope) { + const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown }; + if (typeof label !== "string" || !LABEL.test(label)) fail(400, "Метка: до 40 символов, без переводов строк и <>"); const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit; - if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } - const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted); - if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; } + if (!limitOk(limit)) fail(400, "Лимит устройств: от 1 до 100"); + let ids: number[]; + try { ids = parseServers(wanted ?? scope ?? [servers.defaultId()], scope); } + catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; } let id = 0; uidb.transaction(() => { - id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid); + id = Number(q.masterInsert.run(label as string, crypto.randomBytes(16), limit as number, 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 }); - }); + return masterOutById(id); + } - 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, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown }; + async function updateMaster(m: MasterRow, body: unknown, scope: Scope) { + const { label, device_limit, servers: wanted } = (body ?? {}) as { label?: unknown; device_limit?: unknown; servers?: unknown }; const nl = label ?? m.label, nd = device_limit ?? m.device_limit; - if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; } - if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; } + if (typeof nl !== "string" || !LABEL.test(nl)) fail(400, "Неверная метка"); + if (!limitOk(nd)) fail(400, "Лимит устройств: от 1 до 100"); let ids = serverIdsOf(m.id); const affected: number[] = []; if (wanted !== undefined) { - const next = parseServers(wanted); - if (typeof next === "string") { res.status(400).json({ error: next }); return; } + const next = parseServers(wanted, scope); const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s)); const devices = q.devicesOf.all(m.id) as DeviceRow[]; uidb.transaction(() => { @@ -730,59 +763,49 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas affected.push(...added, ...removed); ids = next; } - q.masterUpdate.run(nl, nd, m.id); + q.masterUpdate.run(nl as string, nd as number, m.id); await reconcileAll(affected); - res.json({ id: m.id, label: nl, device_limit: nd, servers: ids }); - })); + return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids }; + } - masterKeys.post("/:id/rotate", (req, res) => { - const m = q.masterById.get(idOf(req)) as MasterRow | undefined; - if (!m) { res.status(404).json({ error: "Не найден" }); return; } - q.masterSecret.run(crypto.randomBytes(16), m.id); // устройства продолжают работать: у них auth_pub - res.json({ id: m.id }); - }); - - masterKeys.post("/:id/rekey", (req, res) => { - const m = q.masterById.get(idOf(req)) as MasterRow | undefined; - if (!m) { res.status(404).json({ error: "Не найден" }); return; } - q.deviceFlag.run(-1, m.id); - res.json({ id: m.id }); - }); + // Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret. + const rotateMaster = (m: MasterRow) => { q.masterSecret.run(crypto.randomBytes(16), m.id); }; + const rekeyMaster = (m: MasterRow) => { q.deviceFlag.run(-1, m.id); }; // Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся // отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель // сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются. - 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; } + async function deleteMaster(m: MasterRow) { const affected = serverIdsOf(m.id); uidb.transaction(() => { for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d)); q.masterRevoke.run(now(), m.id); })(); await reconcileAll(affected); - res.json({ success: true, id: m.id, pending: serverIdsOf(m.id) }); - })); + return { success: true, id: m.id, pending: serverIdsOf(m.id) }; + } - 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 host = subHost(req); - if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; } + // Выдать ссылку можно, только если подписка настроена и известен адрес панели. + function linkProblem(req: Request): string | null { + if (!signKey || !signPub || !SUB_PORT) return "Подписка не настроена на сервере"; + if (!subHost(req)) return "Не удалось определить адрес панели — задайте SUB_HOST"; + return null; + } + + function linkOf(m: MasterRow, req: Request) { + const problem = linkProblem(req); + if (problem) fail(503, problem); const link = encodeSenLink({ tls: tlsPin !== null, - addrs: [{ host, port: SUB_PORT }], - secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label, + addrs: [{ host: subHost(req), port: SUB_PORT }], + secret: m.secret, signPub: signPub as Buffer, tlsPin: tlsPin ?? undefined, name: m.label, }); - res.json({ link, tls: tlsPin !== null }); - }); + return { 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; } + async function masterDevices(m: MasterRow) { const stats = new Map(); await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => { const r = await servers.ctrlFor(id)("GET", "/api/peers"); @@ -795,67 +818,107 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas }); } })); - res.json({ - 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, - }; - }), + return (q.devicesOf.all(m.id) as DeviceRow[]).map(d => { + const links = q.linksOf.all(d.id) as LinkRow[]; + const s = stats.get(d.pub_key); + return { + id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at, + last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1, + online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0, + servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length, + }; }); - })); + } - 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; } + async function deleteDevice(d: DeviceRow) { await reconcileAll(forgetDevice(d)); - res.json({ success: true, id: d.id }); - })); - - devices.post("/:id/rekey", (req, res) => { - const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined; - if (!d) { res.status(404).json({ error: "Не найдено" }); return; } - q.deviceFlag.run(d.id, -1); - res.json({ id: d.id }); - }); + return { success: true, id: d.id }; + } + const rekeyDevice = (d: DeviceRow) => { q.deviceFlag.run(d.id, -1); }; // Новый 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; } + async function rotatePsk(d: DeviceRow) { 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 }); - })); + return { id: d.id, pending: (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length }; + } + + // ── Роуты панели (монтируются в server.ts за JWT) ────────────────────── + const masterKeys = Router(); + masterKeys.get("/", (_req, res) => { + res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) }); + }); + masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); })); + masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(idOf(req.params.id), null), req.body, null)); })); + masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); })); + masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); })); + masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(idOf(req.params.id), null))); })); + masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(idOf(req.params.id), null), req)); })); + masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(idOf(req.params.id), null)) }); })); + + const devices = Router(); + devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); })); + devices.post("/:id/rekey", aw((req, res) => { const d = deviceFor(idOf(req.params.id)); rekeyDevice(d); res.json({ id: d.id }); })); + devices.post("/:id/psk", aw(async (req, res) => { res.json(await rotatePsk(deviceFor(idOf(req.params.id)))); })); + + // ── Внешний API: /api/v1/masterkeys (ключ awgk_, монтируется в server.ts) ─ + // scopeOf — набор серверов API-ключа запроса. Устройства — вложенными путями, чтобы + // область проверялась через их мастер-ключ. + function apiRouter(scopeOf: (req: Request) => number[]) { + const r = Router(); + const master = (req: Request) => masterFor(idOf(req.params.id), scopeOf(req)); + const device = (req: Request) => deviceFor(idOf(req.params.device), master(req)); + + r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); })); + r.post("/", aw((req, res) => { + const problem = linkProblem(req); // без ссылки ключ внешней программе бесполезен + if (problem) fail(503, problem); + const out = createMaster(req.body, scopeOf(req)); + res.status(201).json({ ...out, ...linkOf(masterFor(out.id, null), req) }); + })); + r.get("/:id", aw(async (req, res) => { + const m = master(req); + // devices — счётчик, как в списке; сами устройства — в device_list. + res.json({ ...masterOutById(m.id), ...(linkProblem(req) ? { link: null, tls: tlsPin !== null } : linkOf(m, req)), device_list: await masterDevices(m) }); + })); + r.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(master(req), req.body, scopeOf(req))); })); + r.post("/:id/rotate", aw((req, res) => { + const m = master(req); + rotateMaster(m); + res.json({ id: m.id, ...linkOf(masterFor(m.id, null), req) }); + })); + r.post("/:id/rekey", aw((req, res) => { const m = master(req); rekeyMaster(m); res.json({ id: m.id }); })); + r.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(master(req))); })); + r.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(master(req)) }); })); + r.delete("/:id/devices/:device", aw(async (req, res) => { res.json(await deleteDevice(device(req))); })); + r.post("/:id/devices/:device/rekey", aw((req, res) => { const d = device(req); rekeyDevice(d); res.json({ id: d.id }); })); + r.post("/:id/devices/:device/psk", aw(async (req, res) => { res.json(await rotatePsk(device(req))); })); + return r; + } // ── Листенер и фоновое сведение ──────────────────────────────────────── servers.onOnline(id => { void reconcile(id); }); // Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва // сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после - // удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force. + // удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force; + // тогда отозванные ключи перестают его ждать уже после удаления (afterDelete). servers.beforeDelete(async (id, force) => { const live = (q.liveOnServer.all(id) as { label: string }[]).map(r => r.label); if (live.length) return { error: `Сервер в мастер-ключах: ${live.join(", ")} — сначала уберите его оттуда`, forcible: false }; if (servers.isOnline(id)) await reconcile(id); const waiting = (q.revokedOnServer.all(id) as { label: string }[]).map(r => r.label); const tombs = (q.tombCount.get(id) as { n: number }).n; - if (!waiting.length && !tombs) return null; - if (!force) return { + if (!waiting.length && !tombs || force) return null; + return { error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") + ". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)", forcible: true, }; + }); + servers.afterDelete(id => { q.serverRelease.run(id); q.revokedGone.run(); - return null; }); function start() { @@ -872,5 +935,5 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas }); } - return { masterKeys, devices, start }; + return { masterKeys, devices, apiRouter, start }; }