Serve a master key's devices from several servers

A master key now has a set of servers (the panel's own and/or nodes). A
device's single WireGuard key becomes a peer on every server of its key,
each with its own IP and PSK, and config.servers[] lists them all.

- ui.db: master_servers (the key's set, never empty), device_peers (which
  key is on which server, with the ip/psk that server issued),
  peer_tombstones (peers to delete) and server_profiles (last known
  profile of each server).
- Nothing is pushed blindly: reconcile() brings one server to the desired
  state - deletes tombstones, adds peers, rekeys with PUT, fills ip/psk,
  rotates a pending PSK - one pass per server at a time. It runs right
  after every action, when a node connects and once a minute, so a node
  that was offline catches up on rekeys, PSK changes and removals.
- config is built from ui.db, so an offline node stays in it and rev does
  not flap; a server is listed once its peer carries the device's current
  key. An empty config is never sent (502 instead); register and rekey
  roll back if no server accepted the key.
- Existing keys and devices migrate on start. With one server the config
  and its rev are byte-identical to 0.3.1 (checked by running both
  versions on the same ui.db), so existing clients notice nothing.
- Panel: server checkboxes in the key settings instead of the placeholder
  (the last one cannot be removed), "на N из M серверов" on devices that
  have not reached every server yet, device stats summed over servers.
- SUB_HOST: the panel's public address for sen:// links and endpoints;
  install.sh writes it. Without it a panel with no VPN of its own used
  the address it was opened at.
- A node referenced by a key's server set cannot be deleted.
This commit is contained in:
2026-09-26 21:43:58 +03:00
parent a65dfeeae9
commit c97acc933f
8 changed files with 451 additions and 115 deletions
+355 -72
View File
@@ -8,6 +8,14 @@
//
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
//
// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный
// awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он
// становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers).
// Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
// (сразу при действии, при подключении ноды и раз в минуту).
import crypto from "crypto";
import fs from "fs";
import http from "http";
@@ -22,6 +30,16 @@ import {
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
/** Что подписке нужно от реестра серверов (awg-ui/nodes.ts). */
export interface Servers {
ctrlFor(id: number): Ctrl;
exists(id: number): boolean;
isOnline(id: number): boolean;
ids(): number[];
defaultId(): number;
onOnline(cb: (id: number) => void): void;
}
interface MasterRow {
id: number; label: string; secret: Buffer; device_limit: number;
server_id: number; created_at: number; revoked_at: number | null;
@@ -31,9 +49,19 @@ interface DeviceRow {
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
rekey_requested: number; version: string;
}
interface LinkRow {
device_id: number; server_id: number; peer_pub: string | null;
ip: string | null; psk: string | null; psk_pending: number;
}
interface Profile {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
}
const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3;
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
const SYNC_EVERY = 60_000; // фоновое сведение серверов
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
@@ -53,12 +81,15 @@ function makeLimiter(max: number, windowMs: number) {
};
}
export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) {
const { uidb, ctrl } = deps;
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
const { uidb, servers } = deps;
// ── Настройка из окружения ─────────────────────────────────────────────
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
// Публичный адрес панели для ссылок и endpoints. Без него — адрес локального сервера,
// а у панели без своего VPN — тот, по которому к ней пришли.
const SUB_HOST = (process.env.SUB_HOST ?? "").trim();
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
let signKey: crypto.KeyObject | null = null;
@@ -109,6 +140,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
-- дальше не читается (кроме переноса ниже).
CREATE TABLE IF NOT EXISTS master_servers (
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (master_id, server_id)
);
-- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере
-- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока
-- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера.
CREATE TABLE IF NOT EXISTS device_peers (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
peer_pub TEXT,
ip TEXT,
psk TEXT,
psk_pending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (device_id, server_id)
);
-- Пиры, которые надо удалить с сервера, когда до него дотянемся.
CREATE TABLE IF NOT EXISTS peer_tombstones (
server_id INTEGER NOT NULL,
pub_key TEXT NOT NULL,
PRIMARY KEY (server_id, pub_key)
);
-- Последний известный профиль сервера: config собирается из него, даже когда
-- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва.
CREATE TABLE IF NOT EXISTS server_profiles (
server_id INTEGER PRIMARY KEY,
profile TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
// таблицы колонки может не быть.
@@ -116,14 +179,30 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
uidb.pragma("foreign_keys = ON");
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
// уже стоит там под devices.pub_key (ip/psk подтянет reconcile). У ключа всегда есть хотя
// бы один сервер, поэтому «ни одной строки» бывает только у ключей из старой схемы.
uidb.exec(`
INSERT OR IGNORE INTO master_servers (master_id, server_id)
SELECT id, server_id FROM master_keys
WHERE id NOT IN (SELECT master_id FROM master_servers);
INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub)
SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id
WHERE d.id NOT IN (SELECT device_id FROM device_peers);
`);
const q = {
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"),
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"),
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
usedServers: uidb.prepare("SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones"),
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
@@ -133,47 +212,171 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 WHERE id = ?"),
devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"),
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
linksOf: uidb.prepare<[number]>("SELECT * FROM device_peers WHERE device_id = ? ORDER BY server_id"),
linkAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)"),
linkDel: uidb.prepare<[number, number]>("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?"),
linkSet: uidb.prepare<[string | null, string | null, string | null, number, number, number]>(
"UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?"),
linkPskDue: uidb.prepare<[number]>("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL"),
// Что на сервере расходится с нужным: пира нет, стоит старый ключ, не знаем ip/psk, ждёт новый PSK.
linksToFix: uidb.prepare<[number]>(`
SELECT l.*, d.pub_key AS dev_pub, d.master_id FROM device_peers l JOIN devices d ON d.id = l.device_id
WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key
OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`),
tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"),
tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"),
tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"),
profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"),
profileSet: uidb.prepare<[number, string, number]>(
"INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"),
};
const now = () => Math.floor(Date.now() / 1000);
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
const serverIdsOf = (masterId: number) => (q.serversOf.all(masterId) as { server_id: number }[]).map(r => r.server_id);
// ── Общие куски ────────────────────────────────────────────────────────
async function profile() {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status)) throw new Error("profile");
return r.data as {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
};
// ── Сведение серверов ──────────────────────────────────────────────────
function storedProfile(id: number): Profile | null {
const row = q.profileGet.get(id) as { profile: string } | undefined;
try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; }
}
async function refreshProfile(id: number, ctrl: Ctrl): Promise<boolean> {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status) || typeof r.data?.endpoint !== "string") return false;
const json = JSON.stringify(r.data);
const row = q.profileGet.get(id) as { profile: string } | undefined;
if (row?.profile !== json) q.profileSet.run(id, json, now());
return true;
}
// Один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды.
const locks = new Map<number, Promise<void>>();
function reconcile(id: number): Promise<void> {
const next = (locks.get(id) ?? Promise.resolve()).then(() => reconcileNow(id)).catch(e => {
console.warn(`sub: сервер #${id} не сведён: ${(e as Error).message}`);
});
locks.set(id, next);
return next;
}
const reconcileAll = (ids: Iterable<number>) => Promise.all([...new Set(ids)].map(reconcile));
async function reconcileNow(id: number) {
if (!servers.exists(id) || !servers.isOnline(id)) return;
const ctrl = servers.ctrlFor(id);
if (!await refreshProfile(id, ctrl)) return; // сервер не отвечает — попробуем позже
// Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk).
let listed: Map<string, { ip: string; psk_key: string }> | null = null;
const onServer = async (pub: string) => {
if (!listed) {
const r = await ctrl("GET", "/api/peers");
if (!hasCtrlOk(r.status)) throw new Error(`peers ${r.status}`);
listed = new Map((r.data.peers as { pub_key: string; ip: string; psk_key: string }[]).map(p => [p.pub_key, p]));
}
return listed.get(pub) ?? null;
};
const gone = (s: number) => s === 502 || s === 504; // связь пропала посреди прохода
for (const { pub_key } of q.tombsOf.all(id) as { pub_key: string }[]) {
const r = await ctrl("DELETE", `/api/peers/${pubParam(pub_key)}`);
if (hasCtrlOk(r.status) || r.status === 404) q.tombDel.run(id, pub_key);
else if (gone(r.status)) return;
}
type Peer = { ip: string; psk_key: string };
for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) {
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
const add = async (): Promise<Peer | null | "gone"> => {
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `m${l.master_id}/d${l.device_id}` });
if (hasCtrlOk(r.status)) return r.data;
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
return gone(r.status) ? "gone" : null;
};
let peer: Peer | null | "gone";
if (l.peer_pub === null) {
peer = await add();
} else if (l.peer_pub !== l.dev_pub) {
// Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ.
const r = await ctrl("PUT", `/api/peers/${pubParam(l.peer_pub)}`, { pub_key: l.dev_pub });
peer = hasCtrlOk(r.status) ? r.data
: r.status === 404 ? await add() // старого пира на сервере уже нет
: r.status === 409 ? await onServer(l.dev_pub)
: gone(r.status) ? "gone" : null;
} else {
peer = await onServer(l.dev_pub) ?? await add(); // ip/psk не знаем или пир пропал с сервера
}
if (peer === "gone") return;
if (!peer) continue;
let pending = 0;
if (l.psk_pending === 1) {
const r = await ctrl("POST", `/api/peers/${pubParam(l.dev_pub)}/psk`);
if (hasCtrlOk(r.status)) peer = r.data as Peer;
else if (gone(r.status)) return;
else pending = 1; // пир на месте, PSK сменим позже
}
q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id);
}
}
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
function forgetDevice(dev: DeviceRow): number[] {
const links = q.linksOf.all(dev.id) as LinkRow[];
uidb.transaction(() => {
for (const l of links) if (l.peer_pub) q.tombAdd.run(l.server_id, l.peer_pub);
q.deviceDelete.run(dev.id); // device_peers уйдут каскадом
})();
return links.map(l => l.server_id);
}
// ── config ─────────────────────────────────────────────────────────────
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
async function buildConfig(dev: DeviceRow) {
const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]);
if (!hasCtrlOk(peers.status)) throw new Error("peers");
const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[])
.find(p => p.pub_key === dev.pub_key);
if (!peer) throw new Error("peer missing");
function subHost(req?: Request): string {
if (SUB_HOST) return SUB_HOST;
const local = servers.exists(0) ? storedProfile(0) : null;
if (local) return hostOf(local.endpoint);
return (req?.hostname ?? "").replace(/^\[|\]$/g, "");
}
// В servers[] — только серверы, где уже стоит текущий ключ устройства и известны ip/psk.
// Сервер, который ещё не догнали (нода не в сети при регистрации или смене ключа), появится,
// когда его сведут, — rev при этом сменится, и клиент подтянет.
function buildConfig(dev: DeviceRow, req?: Request) {
const list = [];
for (const l of q.linksOf.all(dev.id) as LinkRow[]) {
const prof = storedProfile(l.server_id);
if (!prof || l.peer_pub !== dev.pub_key || !l.ip || !l.psk) continue;
list.push({
id: l.server_id, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: l.psk, address: `${l.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
});
}
const cfg = {
rekey: dev.rekey_requested === 1,
endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`],
servers: [{
id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
}],
endpoints: [`${subHost(req)}:${SUB_PORT}`],
servers: list,
};
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
return { rev, ...cfg };
}
async function dropPeer(pub: string) {
await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно
// Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель
// из-за временной недоступности нод — пусть лучше живёт со старым конфигом.
async function readyConfig(dev: DeviceRow, req: Request) {
let cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
await reconcileAll((q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id));
cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
}
if (cfg.servers.length === 0) throw new Error("no servers");
return cfg;
}
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
@@ -245,34 +448,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; }
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; }
let deviceRowId = 0;
let oldPub: string | null = null;
let touched: number[] = [];
try {
uidb.transaction(() => {
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); }
if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
throw Object.assign(new Error("limit"), { code: "limit" });
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
})();
} catch (e: any) {
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
}
if (oldPub) await dropPeer(oldPub);
const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` });
if (!hasCtrlOk(r.status)) {
q.deviceDelete.run(deviceRowId);
send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" });
await reconcileAll([...touched, ...serverIds]);
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
const cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
await reconcileAll(forgetDevice(dev));
send(res, 502, { error: "unavailable" });
return;
}
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(res, 201, {
device: dev.id, config: await buildConfig(dev),
device: dev.id, config: cfg,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
@@ -298,7 +505,7 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(res, 200, { config: await buildConfig(dev) });
send(res, 200, { config: await readyConfig(dev, req) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
@@ -317,20 +524,31 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
});
}));
// Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же IP и PSK),
// где нет — когда он появится. Если не принял ни один — ключ не меняем, клиент повторит.
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub });
if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; }
q.deviceRekeyed.run(pub, dev.id);
send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) });
if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; }
try { q.devicePub.run(pub, 0, dev.id); }
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
await reconcileAll(ids);
const cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
if (cfg.servers.length === 0) {
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
send(res, 502, { error: "unavailable" });
return;
}
send(res, 200, { config: cfg });
}));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
await dropPeer(dev.pub_key);
q.deviceDelete.run(dev.id);
await reconcileAll(forgetDevice(dev));
send(res, 200, { ok: true });
}));
@@ -345,6 +563,15 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
const LABEL = /^[^\r\n<>]{1,40}$/;
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
if (v.length > MAX_SERVERS) return `Не больше ${MAX_SERVERS} серверов`;
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
const masterKeys = Router();
masterKeys.get("/", (_req, res) => {
@@ -353,30 +580,62 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
tls: tlsPin !== null,
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
id: m.id, label: m.label, device_limit: m.device_limit,
devices: m.devices, server_id: m.server_id, created_at: m.created_at,
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
})),
});
});
masterKeys.post("/", (req, res) => {
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now());
res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 });
const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted);
if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; }
let id = 0;
uidb.transaction(() => {
id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid);
for (const sid of ids) q.serverAdd.run(id, sid);
})();
res.status(201).json({ id, label, device_limit: limit, devices: 0, servers: ids });
});
masterKeys.patch("/:id", (req, res) => {
masterKeys.patch("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
const nl = label ?? m.label, nd = device_limit ?? m.device_limit;
if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
let ids = serverIdsOf(m.id);
const affected: number[] = [];
if (wanted !== undefined) {
const next = parseServers(wanted);
if (typeof next === "string") { res.status(400).json({ error: next }); return; }
const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s));
const devices = q.devicesOf.all(m.id) as DeviceRow[];
uidb.transaction(() => {
for (const sid of removed) {
q.serverDel.run(m.id, sid);
for (const d of devices) {
const l = (q.linksOf.all(d.id) as LinkRow[]).find(x => x.server_id === sid);
if (l?.peer_pub) q.tombAdd.run(sid, l.peer_pub);
q.linkDel.run(d.id, sid);
}
}
for (const sid of added) {
q.serverAdd.run(m.id, sid);
for (const d of devices) q.linkAdd.run(d.id, sid);
}
})();
affected.push(...added, ...removed);
ids = next;
}
q.masterUpdate.run(nl, nd, m.id);
res.json({ id: m.id, label: nl, device_limit: nd });
});
await reconcileAll(affected);
res.json({ id: m.id, label: nl, device_limit: nd, servers: ids });
}));
masterKeys.post("/:id/rotate", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
@@ -395,50 +654,64 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
masterKeys.delete("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
q.masterDelete.run(m.id); // devices уйдут каскадом
const affected: number[] = [];
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterDelete.run(m.id); // master_servers уйдут каскадом
await reconcileAll(affected);
res.json({ success: true, id: m.id });
}));
masterKeys.get("/:id/link", aw(async (req, res) => {
masterKeys.get("/:id/link", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; }
const prof = await profile();
const host = subHost(req);
if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; }
const link = encodeSenLink({
tls: tlsPin !== null,
addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }],
addrs: [{ host, port: SUB_PORT }],
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
});
res.json({ link, tls: tlsPin !== null });
}));
});
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
masterKeys.get("/:id/devices", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
const r = await ctrl("GET", "/api/peers");
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers");
if (!hasCtrlOk(r.status)) return;
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
stats.set(p.pub_key, {
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
rx: s.rx + p.rx, tx: s.tx + p.tx,
});
}
}));
res.json({
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
...pick(stats.get(d.pub_key)),
})),
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
const links = q.linksOf.all(d.id) as LinkRow[];
const s = stats.get(d.pub_key);
return {
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
};
}),
});
}));
function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) {
return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 };
}
const devices = Router();
devices.delete("/:id", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
await dropPeer(d.pub_key);
q.deviceDelete.run(d.id);
await reconcileAll(forgetDevice(d));
res.json({ success: true, id: d.id });
}));
@@ -449,15 +722,25 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
res.json({ id: d.id });
});
// Новый PSK на каждом сервере; недоступные получат его, когда появятся.
devices.post("/:id/psk", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`);
res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" });
q.linkPskDue.run(d.id);
await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id));
const pending = (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length;
res.json({ id: d.id, pending });
}));
// ── Листенер ───────────────────────────────────────────────────────────
// ── Листенер и фоновое сведение ────────────────────────────────────────
servers.onOnline(id => { void reconcile(id); });
function start() {
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
setInterval(() => {
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
}, SYNC_EVERY).unref();
if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);