Manage master keys through the external API
API keys now see a set of servers instead of one, and /api/v1 gains the master-key operations the panel already had. - api_key_servers holds each key's servers (migrated from server_id, which stays the default server for /api/v1/users). /users follows X-Server-Id within the set; a server outside it is 404. PATCH /ui/apikeys/:id changes the set without reissuing the key. - /api/v1/masterkeys: create (returns the sen:// link), list, get with link and devices, patch label/limit/servers, rotate, rekey, delete, and nested device routes (unlink, rekey, PSK). A key sees a master key only if all of its servers are in the key's set, so it cannot delete or re-point service on a server it cannot see; anything else is 404, servers outside the set are 403. Creating refuses with 503 before writing anything if the subscription cannot issue links. - sub.ts: the panel routes and the API share one service layer, the panel's behaviour is unchanged. - Node deletion: owners check in beforeDelete (no writes, a later hook may still veto) and clean up in afterDelete. Previously a forced delete could release a waiting master key and then be vetoed. A node that is the only server of an API key is refused; other keys just lose it. - Panel: server checkboxes are a shared ServerPicker, used by the master key settings and the new API key settings row; the API keys table shows each key's servers. - README: the API section covers server sets and master keys.
This commit is contained in:
@@ -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) }));
|
||||
|
||||
+9
-10
@@ -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<DeleteVeto>)[] = [];
|
||||
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { 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 };
|
||||
}
|
||||
|
||||
+96
-11
@@ -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);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 (
|
||||
<label className={`server-pick${last ? ' server-pick--locked' : ''}`} key={n.id}>
|
||||
<input type="checkbox" checked={on} disabled={last}
|
||||
onChange={() => onChange(on ? selected.filter(id => id !== n.id) : [...selected, n.id], n, !on)} />
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-pick-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="server-pick-name">{h?.server || n.name}</span>
|
||||
{h?.ip && <code className="server-pick-ip">{h.ip}</code>}
|
||||
{h?.gen && <span className="server-pick-gen">{genLabel(h.gen)}</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Имя сервера для подписей: как в сервер-баре, иначе «#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}`;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<ApiKey[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [label, setLabel] = useState('');
|
||||
const [created, setCreated] = useState<{ label: string; key: string } | null>(null);
|
||||
const [tools, setTools] = useState<number | null>(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) => (
|
||||
<section className="apikey-tools">
|
||||
<h4 className="apikey-tools-title">Серверы ключа</h4>
|
||||
<p className="apikey-tools-hint">
|
||||
На них ключ заводит vpn://-пользователей (по умолчанию — на «{serverName(nodes, k.server_id)}», другой
|
||||
выбирается заголовком X-Server-Id) и управляет мастер-ключами, чьи серверы все отмечены здесь.
|
||||
</p>
|
||||
<ServerPicker nodes={nodes} selected={k.servers ?? [k.server_id]} onChange={(next, n, added) => setServers(k, next, n, added)} />
|
||||
</section>
|
||||
);
|
||||
|
||||
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; новый ключ получает выбранный в нём сервер. */}
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="field"
|
||||
@@ -68,6 +95,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<th>#</th>
|
||||
<th>Метка</th>
|
||||
<th>Ключ</th>
|
||||
<th>Серверы</th>
|
||||
<th>Создан</th>
|
||||
<th>Последний раз</th>
|
||||
<th></th>
|
||||
@@ -75,24 +103,36 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={6} className="empty">Нет ключей</td></tr>
|
||||
<tr><td colSpan={7} className="empty">Нет ключей</td></tr>
|
||||
) : keys.map((k, i) => (
|
||||
<tr key={k.id}>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<Fragment key={k.id}>
|
||||
<tr>
|
||||
<td className="td-num">{i + 1}</td>
|
||||
<td>{k.label}</td>
|
||||
<td className="td-mono">{k.prefix}</td>
|
||||
<td>{serversText(k)}</td>
|
||||
<td>{new Date(k.created_at * 1000).toLocaleDateString()}</td>
|
||||
<td>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'никогда'}</td>
|
||||
<td className="td-actions">
|
||||
<div className="actions">
|
||||
<button
|
||||
className="btn-icon"
|
||||
title="Серверы ключа"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn-icon btn-icon--danger"
|
||||
title="Удалить ключ"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{tools === k.id && <tr className="apikey-tr-tools"><td colSpan={7}>{toolbox(k)}</td></tr>}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -110,13 +150,23 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
||||
<span className="apikey-card-num">#{i + 1}</span>
|
||||
<span className="apikey-card-label">{k.label}</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
<div className="apikey-card-btns">
|
||||
<button
|
||||
className="btn btn--tonal btn--sq"
|
||||
aria-label="Серверы ключа"
|
||||
aria-expanded={tools === k.id}
|
||||
onClick={() => setTools(tools === k.id ? null : k.id)}
|
||||
><IcoSettings /></button>
|
||||
<button
|
||||
className="btn btn--danger btn--sq"
|
||||
aria-label="Удалить ключ"
|
||||
onClick={() => deleteKey(k.id, k.label)}
|
||||
><IcoTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="apikey-card-prefix">{k.prefix}</code>
|
||||
<span className="apikey-card-servers">Серверы: {serversText(k)}</span>
|
||||
{tools === k.id && toolbox(k)}
|
||||
<div className="apikey-card-meta">
|
||||
<span>Создан {new Date(k.created_at * 1000).toLocaleDateString()}</span>
|
||||
<span>{k.last_used ? timeAgo(k.last_used) + ' назад' : 'не использован'}</span>
|
||||
|
||||
@@ -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) {
|
||||
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Серверы ключа</h4>
|
||||
{nodes.map(n => {
|
||||
const on = k.servers.includes(n.id);
|
||||
const last = on && k.servers.length === 1;
|
||||
const h = n.health;
|
||||
return (
|
||||
<label className={`mk-server${last ? ' mk-server--locked' : ''}`} key={n.id}>
|
||||
<input type="checkbox" checked={on} disabled={last}
|
||||
onChange={() => {
|
||||
const next = on ? k.servers.filter(id => id !== n.id) : [...k.servers, n.id];
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
on ? `«${h?.server || n.name}» убран из ключа` : `«${h?.server || n.name}» добавлен в ключ`);
|
||||
}} />
|
||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} mk-server-dot`} title={n.online ? 'В сети' : 'Нет связи'}>
|
||||
<span className="chip-dot" />
|
||||
</span>
|
||||
<span className="mk-server-name">{h?.server || n.name}</span>
|
||||
{h?.ip && <code className="mk-server-ip">{h.ip}</code>}
|
||||
{h?.gen && <span className="mk-server-gen">{genLabel(h.gen)}</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<ServerPicker nodes={nodes} selected={k.servers} onChange={(next, n, added) =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { servers: next }),
|
||||
added ? `«${serverName(nodes, n.id)}» добавлен в ключ` : `«${serverName(nodes, n.id)}» убран из ключа`)} />
|
||||
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||
|
||||
+174
-111
@@ -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<void>) =>
|
||||
(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<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user