Stop master-key peers from outliving their devices
A deleted master key could keep letting its devices in: ui.db learns what is on a server only from awg-ctrl's reply, and a node that executed a request whose 504/502 reply got lost, or a device deleted while a reconcile pass was in flight, left a peer nothing remembered to remove. - awg-ctrl: PUT /api/peers/sync takes a panel's full list of wanted keys and removes every other peer of that panel's area. Removal is driven only by such an explicit list, never by time or inactivity: a panel that is offline sends nothing, so nothing is deleted. Registered before PUT /api/peers/:pub, with its own 512 kB body limit. - Every panel has a stable id (ui.db, mirrored to awg-ui/panel.id) and adds peers as "<panel>/m<key>/d<device>", so two panels on one awg-ctrl never touch each other's peers. Legacy "m1/d2" peers are claimed only when their key is in the panel's list. - Each reconcile pass ends with the sync, built from ui.db at that moment. Its reply also self-heals: a device whose key is missing on a server (a rekey rolled back after a lost reply) is added again. - Deleting a master key stops it at once but keeps it listed as "Удаляется — ждёт: …" until every server confirms, instead of saying "deleted" while an offline node still lets the devices in. - Deleting a node reconciles it first; if it is offline or has not confirmed a deleted key, it takes ?force=1 and a warning. Tombstones are no longer dropped silently. - /sub/v1: an unknown device gets a signed 410 revoked that does not count towards the failure limit (dead clients behind one node NAT used to lock everyone out), every response echoes the request's ts and device, and 401 reasons and the first 429 per window are logged. config and rev are unchanged, byte-identical on one server. - CLI: `awg-ctrl peers [drop <area|legacy>]` lists and removes subscription peers by area; `join --no-panel` asks whether to keep or drop the panel's own peers (NODE_PEERS=keep|drop).
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
cli/cli.env
|
||||
awg-ui/panel.id
|
||||
logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
|
||||
+54
-1
@@ -305,6 +305,7 @@ const stmts = {
|
||||
peerDelete: db.prepare<[string]>("DELETE FROM peers WHERE pub_key = ?"),
|
||||
peerRekey: db.prepare<[string, string]>("UPDATE peers SET pub_key = ? WHERE pub_key = ?"),
|
||||
peerPsk: db.prepare<[string, string]>("UPDATE peers SET psk_key = ? WHERE pub_key = ?"),
|
||||
peerOwner: db.prepare<[string, string]>("UPDATE peers SET owner = ? WHERE pub_key = ?"),
|
||||
userPubTaken: db.prepare<[string], { n: number }>("SELECT COUNT(*) AS n FROM users WHERE pub_key = ?"),
|
||||
get: db.prepare<[string], UserRow>("SELECT * FROM users WHERE name = ?"),
|
||||
all: db.prepare<[], UserRow>("SELECT * FROM users"),
|
||||
@@ -946,8 +947,52 @@ function removePeer(pub: string) {
|
||||
logger.info("peer removed", { owner: peer.owner });
|
||||
}
|
||||
|
||||
// Сведение пиров одной панели: панель присылает полный список ключей, которые ей нужны
|
||||
// на этом сервере, — всё остальное из её области («<panel>/…») снимается. Удаляем только
|
||||
// по такому явному списку, никогда по времени или неактивности: панель не на связи —
|
||||
// ничего не происходит. Пиры без области (owner «m1/d2», выданы до областей) панель
|
||||
// забирает себе, только если их ключ есть в её списке; остальные не трогаем — чьи они,
|
||||
// уже не разобрать.
|
||||
const PANEL_ID = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
const SYNC_MAX = 10_000;
|
||||
|
||||
function syncOwnedPeers(panel: string, keep: string[]): {
|
||||
present: string[]; removed: number; legacy: { pub_key: string; owner: string; ip: string }[];
|
||||
} {
|
||||
const want = new Set(keep);
|
||||
const prefix = panel + "/";
|
||||
const removed: PeerRow[] = [];
|
||||
let claimed = 0;
|
||||
db.transaction(() => {
|
||||
for (const p of stmts.peersAll.all()) {
|
||||
if (p.owner.startsWith(prefix)) {
|
||||
if (!want.has(p.pub_key)) { stmts.peerDelete.run(p.pub_key); removed.push(p); }
|
||||
} else if (LEGACY_OWNER.test(p.owner) && want.has(p.pub_key)) {
|
||||
stmts.peerOwner.run(prefix + p.owner, p.pub_key);
|
||||
claimed++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
for (const p of removed) spawnSync("awg", ["set", CONFIG.interface, "peer", p.pub_key, "remove"]);
|
||||
if (removed.length || claimed) rebuildConf();
|
||||
if (removed.length) logger.info("peers synced out", { panel, removed: removed.map(p => p.owner) });
|
||||
|
||||
const all = stmts.peersAll.all();
|
||||
return {
|
||||
present: all.filter(p => p.owner.startsWith(prefix)).map(p => p.pub_key),
|
||||
removed: removed.length,
|
||||
legacy: all.filter(p => LEGACY_OWNER.test(p.owner)).map(p => ({ pub_key: p.pub_key, owner: p.owner, ip: p.ip })),
|
||||
};
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "1kb" }));
|
||||
// Сведение пиров несёт полный список ключей панели — ему лимит побольше, остальным хватает 1kb.
|
||||
const SYNC_PATH = "/api/peers/sync";
|
||||
const jsonSmall = express.json({ limit: "1kb" });
|
||||
const jsonSync = express.json({ limit: "512kb" });
|
||||
app.use((req, res, next) => (req.path === SYNC_PATH ? jsonSync : jsonSmall)(req, res, next));
|
||||
|
||||
function verifyInternalToken(token: string): boolean {
|
||||
try {
|
||||
@@ -1070,6 +1115,14 @@ app.get("/api/peers", auth, handler((_req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
// Должен стоять ДО PUT /api/peers/:pub — иначе тот примет «sync» за ключ.
|
||||
app.put(SYNC_PATH, auth, handler((req, res) => {
|
||||
const { owner, keep } = (req.body ?? {}) as { owner?: unknown; keep?: unknown };
|
||||
if (typeof owner !== "string" || !PANEL_ID.test(owner)) throw new HttpError(400, "Неверный owner");
|
||||
if (!Array.isArray(keep) || keep.length > SYNC_MAX) throw new HttpError(400, "Неверный список ключей");
|
||||
res.json(syncOwnedPeers(owner, keep.map(checkPubKey)));
|
||||
}));
|
||||
|
||||
app.put("/api/peers/:pub", auth, handler((req, res) => {
|
||||
const { pub_key } = (req.body ?? {}) as { pub_key?: string };
|
||||
res.json(replacePeer(pubKeyFromParam(req.params.pub), checkPubKey(pub_key)));
|
||||
|
||||
+19
-5
@@ -48,15 +48,15 @@ let apiId = 1;
|
||||
|
||||
// ── мастер-ключи sen:// ──
|
||||
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[] }
|
||||
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
|
||||
let mkId = 1, devId = 1;
|
||||
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
|
||||
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
|
||||
const signPub = crypto.randomBytes(32);
|
||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at });
|
||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id));
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||
|
||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
||||
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
|
||||
@@ -80,7 +80,16 @@ app.post("/ui/nodes", (q, r) => {
|
||||
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
|
||||
});
|
||||
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
|
||||
app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); });
|
||||
app.delete("/ui/nodes/:id", (q, r) => {
|
||||
const id = Number(q.params.id), i = mnodes.findIndex(x => x.id === id); if (i < 0) return r.status(404).json({ error: "Не найден" });
|
||||
if (masters.some(m => !m.deleting && m.servers.includes(id))) return r.status(409).json({ error: "Сервер в мастер-ключах — сначала уберите его оттуда", can_force: false });
|
||||
const waiting = masters.filter(m => m.deleting?.includes(id));
|
||||
if (waiting.length && q.query.force !== "1")
|
||||
return r.status(409).json({ error: `Сервер не подтвердил, что снял пиров удалённых мастер-ключей (${waiting.map(m => m.label).join(", ")})`, can_force: true });
|
||||
for (const m of waiting) m.deleting = m.deleting!.filter(x => x !== id);
|
||||
for (const m of waiting) if (!m.deleting!.length) masters.splice(masters.indexOf(m), 1);
|
||||
mnodes.splice(i, 1); r.json({ success: true });
|
||||
});
|
||||
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
||||
|
||||
@@ -116,7 +125,12 @@ app.patch("/ui/masterkeys/:id", (q, r) => {
|
||||
});
|
||||
app.post("/ui/masterkeys/:id/rotate", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.secret = crypto.randomBytes(16); r.json({ id: m.id }); });
|
||||
app.post("/ui/masterkeys/:id/rekey", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); m.devs.forEach(d => d.rekey_requested = true); r.json({ id: m.id }); });
|
||||
app.delete("/ui/masterkeys/:id", (q, r) => { const i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
|
||||
app.delete("/ui/masterkeys/:id", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
const pending = m.servers.filter(id => id !== 0 && !mnodes.find(n => n.id === id)?.online);
|
||||
if (pending.length) { m.deleting = pending; m.devs = []; } else masters.splice(masters.indexOf(m), 1);
|
||||
r.json({ success: true, id: m.id, pending });
|
||||
});
|
||||
app.get("/ui/masterkeys/:id/link", (q, r) => {
|
||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
|
||||
|
||||
+22
-8
@@ -148,6 +148,13 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
const onlineHandlers: ((id: number) => void)[] = [];
|
||||
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
|
||||
|
||||
// Подписчики на «сервер удаляют»: подписка сперва снимает с него своих пиров и может
|
||||
// запретить удаление. forcible — запрет снимается подтверждением оператора (force):
|
||||
// сервер просто не всё успел подтвердить, а не используется.
|
||||
type DeleteVeto = { error: string; forcible: boolean } | null;
|
||||
const deleteHandlers: ((id: number, force: boolean) => Promise<DeleteVeto>)[] = [];
|
||||
const beforeDelete = (cb: (id: number, force: boolean) => Promise<DeleteVeto>) => { deleteHandlers.push(cb); };
|
||||
|
||||
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
||||
function defaultId(): number {
|
||||
if (LOCAL) return 0;
|
||||
@@ -339,16 +346,23 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
res.json({ id, name });
|
||||
});
|
||||
|
||||
router.delete("/:id", (req: Request, res: Response) => {
|
||||
router.delete("/:id", async (req: Request, res: Response) => {
|
||||
const id = idOf(req);
|
||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
|
||||
const used = (["api_keys", "master_servers"] as const).some(t => {
|
||||
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
|
||||
catch { return false; }
|
||||
});
|
||||
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; }
|
||||
// Ключи API ссылаются на server_id — не оставляем их висеть в воздухе. Мастер-ключи
|
||||
// проверяет подписка в своём обработчике.
|
||||
let used = false;
|
||||
try { used = (uidb.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE server_id = ?").get(id) as { n: number }).n > 0; }
|
||||
catch { /* таблицы ещё нет */ }
|
||||
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API — сначала удалите их" }); return; }
|
||||
const force = req.query.force === "1";
|
||||
for (const cb of deleteHandlers) {
|
||||
let veto: DeleteVeto;
|
||||
try { veto = await cb(id, force); } catch { veto = { error: "Не удалось подготовить сервер к удалению", forcible: true }; }
|
||||
if (veto) { res.status(409).json({ error: veto.error, can_force: veto.forcible && !force }); return; }
|
||||
}
|
||||
if (!q.byId.get(id)) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const c = conns.get(id);
|
||||
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
|
||||
q.delete.run(id);
|
||||
@@ -359,5 +373,5 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
res.json({ success: true, id });
|
||||
});
|
||||
|
||||
return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start };
|
||||
return { router, ctrlFor, exists, isOnline, ids, onOnline, beforeDelete, defaultId, start };
|
||||
}
|
||||
|
||||
+9
-1
@@ -102,7 +102,15 @@ export default function App() {
|
||||
const deleteNode = useCallback(async (n: NodeInfo) => {
|
||||
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
try {
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
|
||||
} catch (e) {
|
||||
// Сервер не подтвердил, что снял пиров удалённых мастер-ключей (не в сети) —
|
||||
// панель объясняет, что останется, и удаляет только с согласия.
|
||||
const data = axios.isAxiosError(e) ? e.response?.data : undefined;
|
||||
if (!data?.can_force || !confirm(`${data.error}\n\nВсё равно удалить «${n.name}»?`)) throw e;
|
||||
await apiFetch('DELETE', `/ui/nodes/${n.id}?force=1`, token);
|
||||
}
|
||||
showMsg('Сервер удалён');
|
||||
await loadNodes(token);
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import './masterkeys.css';
|
||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number }
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||
@@ -47,7 +48,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
const list: MasterKey[] = d.keys ?? [];
|
||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||
const all = await Promise.all(list.map(k =>
|
||||
const all = await Promise.all(list.filter(k => !k.deleting).map(k =>
|
||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
||||
setDevs(Object.fromEntries(all));
|
||||
} catch { showMsg('Ошибка загрузки мастер-ключей'); }
|
||||
@@ -71,10 +72,18 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
}, 'Мастер-ключ создан');
|
||||
}, [label, limit, nodes, token, act]);
|
||||
|
||||
const serverNames = useCallback((ids: number[]) =>
|
||||
ids.map(id => nodes.find(n => n.id === id)?.name ?? `#${id}`).join(', '), [nodes]);
|
||||
|
||||
const remove = useCallback(async (k: MasterKey) => {
|
||||
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
||||
await act(() => apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token), 'Удалён: ' + k.label);
|
||||
}, [token, act]);
|
||||
try {
|
||||
const r = await apiFetch('DELETE', `/ui/masterkeys/${k.id}`, token);
|
||||
const pending: number[] = r.pending ?? [];
|
||||
showMsg(pending.length ? `«${k.label}» отключён, ждём подтверждения: ${serverNames(pending)}` : 'Удалён: ' + k.label);
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [token, load, showMsg, serverNames]);
|
||||
|
||||
const fetchLink = useCallback(async (k: MasterKey) => {
|
||||
const r = await apiFetch('GET', `/ui/masterkeys/${k.id}/link`, token);
|
||||
@@ -101,6 +110,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
|
||||
useEffect(() => { if (token) load(); }, [token, load]);
|
||||
|
||||
// Пока какой-то ключ ждёт серверы, время от времени проверяем, не подтвердили ли они.
|
||||
const waiting = keys.some(k => k.deleting);
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
const t = setInterval(load, 15_000);
|
||||
return () => clearInterval(t);
|
||||
}, [waiting, load]);
|
||||
|
||||
const deletingChip = (k: MasterKey) => (
|
||||
<span className="chip chip--offline" title="Ключ уже не работает; строка исчезнет, когда серверы подтвердят, что сняли его устройства">
|
||||
<span className="chip-dot" />Удаляется — ждёт: {serverNames(k.deleting ?? [])}
|
||||
</span>
|
||||
);
|
||||
|
||||
const actions = (k: MasterKey) => (
|
||||
<div className="actions">
|
||||
<button className="btn-icon" title="QR-код" aria-label="QR-код" onClick={() => showQR(k)}><IcoQR /></button>
|
||||
@@ -277,6 +300,12 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? (
|
||||
<tr><td colSpan={9} className="empty">Нет мастер-ключей</td></tr>
|
||||
) : keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<tr key={k.id} className="mk-tr-master mk-last">
|
||||
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
|
||||
<td colSpan={8}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const open = !folded.has(k.id);
|
||||
@@ -324,6 +353,15 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{keys.length === 0 ? <p className="mk-empty mk-tree">Нет мастер-ключей</p> : (
|
||||
<ul className="mk-tree">
|
||||
{keys.map(k => {
|
||||
if (k.deleting) return (
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
const free = Math.max(0, k.device_limit - list.length);
|
||||
const isFolded = folded.has(k.id);
|
||||
|
||||
+172
-49
@@ -16,6 +16,14 @@
|
||||
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
|
||||
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
|
||||
// (сразу при действии, при подключении ноды и раз в минуту).
|
||||
//
|
||||
// Каждый проход заканчивается сведением пиров панели (PUT /api/peers/sync): awg-ctrl
|
||||
// получает полный список ключей, которые этой панели нужны на сервере, и снимает из её
|
||||
// области всё остальное. Это страховка от пиров, чья судьба потерялась (ответ ноды не
|
||||
// дошёл, устройство удалили посреди прохода). Область — id панели в owner пира
|
||||
// («<panel>/m1/d2»): у ноды может быть своя панель на том же awg-ctrl, чужих не трогаем.
|
||||
// Удаляем только по такому списку, никогда по времени: панель не на связи — ничего не
|
||||
// происходит, неактивные устройства никто не снимает.
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import http from "http";
|
||||
@@ -38,6 +46,7 @@ export interface Servers {
|
||||
ids(): number[];
|
||||
defaultId(): number;
|
||||
onOnline(cb: (id: number) => void): void;
|
||||
beforeDelete(cb: (id: number, force: boolean) => Promise<{ error: string; forcible: boolean } | null>): void;
|
||||
}
|
||||
|
||||
interface MasterRow {
|
||||
@@ -69,13 +78,17 @@ const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения
|
||||
const pubParam = (pub: string) => Buffer.from(pub, "base64").toString("base64url");
|
||||
|
||||
// ── Простой лимитер по IP ──────────────────────────────────────────────────
|
||||
function makeLimiter(max: number, windowMs: number) {
|
||||
// onLimited зовётся один раз за окно — на первом отказе, чтобы лог не забивало.
|
||||
function makeLimiter(max: number, windowMs: number, onLimited?: (ip: string) => void) {
|
||||
const hits = new Map<string, { n: number; reset: number }>();
|
||||
return (ip: string): boolean => {
|
||||
const now = Date.now();
|
||||
const e = hits.get(ip);
|
||||
if (!e || e.reset < now) { hits.set(ip, { n: 1, reset: now + windowMs }); return true; }
|
||||
if (e.n >= max) return false;
|
||||
if (e.n >= max) {
|
||||
if (e.n === max) { e.n++; onLimited?.(ip); }
|
||||
return false;
|
||||
}
|
||||
e.n++;
|
||||
return true;
|
||||
};
|
||||
@@ -172,6 +185,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
profile TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sub_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
|
||||
// таблицы колонки может не быть.
|
||||
@@ -185,20 +202,37 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
uidb.exec(`
|
||||
INSERT OR IGNORE INTO master_servers (master_id, server_id)
|
||||
SELECT id, server_id FROM master_keys
|
||||
WHERE id NOT IN (SELECT master_id FROM master_servers);
|
||||
WHERE revoked_at IS NULL AND id NOT IN (SELECT master_id FROM master_servers);
|
||||
INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub)
|
||||
SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id
|
||||
WHERE d.id NOT IN (SELECT device_id FROM device_peers);
|
||||
`);
|
||||
|
||||
// Id панели — область её пиров на серверах. Живёт, пока живёт ui.db; копия в panel.id
|
||||
// нужна CLI (у него нет SQLite), чтобы отличать пиров этой панели на awg-ctrl.
|
||||
uidb.prepare("INSERT OR IGNORE INTO sub_meta (key, value) VALUES ('panel_id', ?)").run(crypto.randomBytes(6).toString("hex"));
|
||||
const panelId = (uidb.prepare("SELECT value FROM sub_meta WHERE key = 'panel_id'").get() as { value: string }).value;
|
||||
try { fs.writeFileSync(path.join(deps.baseDir, "panel.id"), panelId + "\n"); }
|
||||
catch (e) { console.warn(`sub: не удалось записать panel.id: ${(e as Error).message}`); }
|
||||
|
||||
const q = {
|
||||
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
|
||||
// И отозванные: удалённый ключ висит в списке, пока все его серверы не подтвердят удаление.
|
||||
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m ORDER BY id"),
|
||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
||||
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
|
||||
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
|
||||
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
|
||||
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"),
|
||||
masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
|
||||
// Сервер сведён и надгробий на нём нет — отозванным ключам он больше не нужен.
|
||||
revokedServerDone: uidb.prepare<[number]>(
|
||||
"DELETE FROM master_servers WHERE server_id = ? AND master_id IN (SELECT id FROM master_keys WHERE revoked_at IS NOT NULL)"),
|
||||
revokedGone: uidb.prepare("DELETE FROM master_keys WHERE revoked_at IS NOT NULL AND id NOT IN (SELECT master_id FROM master_servers)"),
|
||||
liveOnServer: uidb.prepare<[number]>(
|
||||
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NULL"),
|
||||
revokedOnServer: uidb.prepare<[number]>(
|
||||
"SELECT m.label FROM master_servers s JOIN master_keys m ON m.id = s.master_id WHERE s.server_id = ? AND m.revoked_at IS NOT NULL"),
|
||||
serverRelease: uidb.prepare<[number]>("DELETE FROM master_servers WHERE server_id = ?"),
|
||||
serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"),
|
||||
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"),
|
||||
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
|
||||
@@ -228,6 +262,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"),
|
||||
tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"),
|
||||
tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"),
|
||||
tombCount: uidb.prepare<[number]>("SELECT COUNT(*) AS n FROM peer_tombstones WHERE server_id = ?"),
|
||||
// Что панели нужно на сервере: текущий ключ каждого устройства и ключ, который там стоит
|
||||
// сейчас (расходится с текущим, пока смена ключа не догнала сервер).
|
||||
keepOf: uidb.prepare<[number, number]>(`
|
||||
SELECT d.pub_key AS pub FROM device_peers l JOIN devices d ON d.id = l.device_id WHERE l.server_id = ?
|
||||
UNION SELECT peer_pub FROM device_peers WHERE server_id = ? AND peer_pub IS NOT NULL`),
|
||||
// Связи, которые считаются сведёнными: на сервере должен стоять текущий ключ устройства.
|
||||
linksSettled: uidb.prepare<[number]>(`
|
||||
SELECT l.device_id, l.peer_pub FROM device_peers l JOIN devices d ON d.id = l.device_id
|
||||
WHERE l.server_id = ? AND l.peer_pub = d.pub_key`),
|
||||
profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"),
|
||||
profileSet: uidb.prepare<[number, string, number]>(
|
||||
"INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"),
|
||||
@@ -290,7 +334,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) {
|
||||
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
|
||||
const add = async (): Promise<Peer | null | "gone"> => {
|
||||
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `m${l.master_id}/d${l.device_id}` });
|
||||
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `${panelId}/m${l.master_id}/d${l.device_id}` });
|
||||
if (hasCtrlOk(r.status)) return r.data;
|
||||
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
|
||||
return gone(r.status) ? "gone" : null;
|
||||
@@ -321,6 +365,33 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
}
|
||||
q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id);
|
||||
}
|
||||
|
||||
await syncPeers(id, ctrl);
|
||||
}
|
||||
|
||||
// Сведение пиров панели на сервере — в конце прохода и под его блокировкой: список строится
|
||||
// из ui.db сейчас, поэтому устройство, удалённое посреди прохода, в него уже не попадёт, и
|
||||
// ключ, поставленный для него этим проходом, снимется здесь же.
|
||||
async function syncPeers(id: number, ctrl: Ctrl) {
|
||||
const keep = (q.keepOf.all(id, id) as { pub: string }[]).map(r => r.pub);
|
||||
const r = await ctrl("PUT", "/api/peers/sync", { owner: panelId, keep });
|
||||
if (hasCtrlOk(r.status)) {
|
||||
// Самолечение: ключ считается стоящим, а его нет (откат смены ключа после потерянного
|
||||
// ответа, пир сняли руками) — забываем и ставим заново следующим проходом.
|
||||
const present = new Set(r.data?.present as string[]);
|
||||
let lost = 0;
|
||||
for (const l of q.linksSettled.all(id) as { device_id: number; peer_pub: string }[]) {
|
||||
if (present.has(l.peer_pub)) continue;
|
||||
q.linkSet.run(null, null, null, 0, l.device_id, id);
|
||||
lost++;
|
||||
}
|
||||
if (lost) void reconcile(id);
|
||||
} else if (r.status !== 400 && r.status !== 413) {
|
||||
return; // 400/413 — awg-ctrl старше sync: чистим, как раньше, одними надгробиями
|
||||
}
|
||||
if ((q.tombCount.get(id) as { n: number }).n > 0) return;
|
||||
q.revokedServerDone.run(id);
|
||||
q.revokedGone.run();
|
||||
}
|
||||
|
||||
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
|
||||
@@ -387,49 +458,69 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
verify: (req, _res, buf) => { (req as any).rawBody = buf; },
|
||||
}));
|
||||
|
||||
const registerLimit = makeLimiter(30, 15 * 60 * 1000);
|
||||
const failLimit = makeLimiter(30, 15 * 60 * 1000);
|
||||
const limited = (what: string) => (ip: string) => console.warn(`sub: 429 ${what} ip=${ip} — лимит исчерпан на 15 минут`);
|
||||
const registerLimit = makeLimiter(30, 15 * 60 * 1000, limited("register"));
|
||||
const failLimit = makeLimiter(30, 15 * 60 * 1000, limited("неудачные запросы"));
|
||||
const seenSigs = new Map<string, number>(); // подпись → когда забыть
|
||||
const ipOf = (req: Request) => req.socket.remoteAddress ?? "unknown";
|
||||
|
||||
function send(res: Response, status: number, payload: unknown) {
|
||||
res.status(status).json(signResponse(payload, signKey as crypto.KeyObject));
|
||||
// Ответ подписан и несёт эхо запроса: device (X-Sen-Device) и ts (X-Sen-Ts). Клиент сверяет
|
||||
// их со своим запросом — иначе подсунуть ему можно было бы чужой подписанный ответ (чужой
|
||||
// revoked, старый config), подпись ведь на запрос не завязана.
|
||||
function send(req: Request, res: Response, status: number, payload: object) {
|
||||
const echo: { device?: number; ts?: number } = {};
|
||||
const device = Number(req.headers["x-sen-device"]), ts = Number(req.headers["x-sen-ts"]);
|
||||
if (req.headers["x-sen-device"] !== undefined && Number.isSafeInteger(device)) echo.device = device;
|
||||
if (req.headers["x-sen-ts"] !== undefined && Number.isSafeInteger(ts)) echo.ts = ts;
|
||||
// Поля ответа важнее эха: device из тела register не перетрёт лишний заголовок.
|
||||
res.status(status).json(signResponse({ ...echo, ...payload }, signKey as crypto.KeyObject));
|
||||
}
|
||||
|
||||
// Проверка подписи запроса + защита от повторов. auth_pub — 32 байта.
|
||||
function checkSigned(req: Request, authPub: Buffer): boolean {
|
||||
// null — всё верно, иначе причина отказа (для лога).
|
||||
function signFailure(req: Request, authPub: Buffer): string | null {
|
||||
const ts = Number(req.headers["x-sen-ts"]);
|
||||
const sig = String(req.headers["x-sen-sig"] ?? "");
|
||||
if (!Number.isInteger(ts) || Math.abs(ts - now()) > TS_WINDOW || !sig) return false;
|
||||
if (!Number.isInteger(ts) || !sig) return "no_signature";
|
||||
if (Math.abs(ts - now()) > TS_WINDOW) return `ts_window (часы клиента расходятся на ${ts - now()} с)`;
|
||||
const body = ((req as any).rawBody as Buffer | undefined) ?? Buffer.alloc(0);
|
||||
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return false;
|
||||
if (!verifyRequest(req.method, req.path, ts, body, sig, authPub)) return "bad_signature";
|
||||
const t = Date.now();
|
||||
for (const [k, exp] of seenSigs) if (exp < t) seenSigs.delete(k);
|
||||
if (seenSigs.has(sig)) return false;
|
||||
if (seenSigs.has(sig)) return "replay";
|
||||
seenSigs.set(sig, t + 2 * TS_WINDOW * 1000);
|
||||
return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DevReq extends Request { device?: DeviceRow }
|
||||
|
||||
function deviceAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const id = Number(req.headers["x-sen-device"]);
|
||||
const dev = Number.isInteger(id) ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
|
||||
if (dev && checkSigned(req, Buffer.from(dev.auth_pub, "base64url"))) {
|
||||
const dev = Number.isSafeInteger(id) && id > 0 ? q.deviceById.get(id) as DeviceRow | undefined : undefined;
|
||||
// Устройства нет — его удалили (id не переиспользуются: AUTOINCREMENT). Отвечаем 410,
|
||||
// чтобы клиент снял конфиг, и не считаем в лимит неудач: такие клиенты опрашивают
|
||||
// по таймеру и иначе выбивали бы лимит соседям за тем же NAT. Существующему
|
||||
// устройству 410 не выдать, поэтому подписанный ответ тут можно отдать без проверки.
|
||||
if (!dev && Number.isSafeInteger(id) && id > 0) { send(req, res, 410, { error: "revoked" }); return; }
|
||||
const why = dev ? signFailure(req, Buffer.from(dev.auth_pub, "base64url")) : "no_device";
|
||||
if (!why) {
|
||||
(req as DevReq).device = dev;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// Лимитируем только неудачи: живой клиент, опрашивающий config, под лимит не попадает.
|
||||
if (!failLimit(req.socket.remoteAddress ?? "unknown")) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
send(res, 401, { error: "unauthorized" });
|
||||
const ip = ipOf(req);
|
||||
if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
console.warn(`sub: 401 ${req.method} ${req.path} device=${req.headers["x-sen-device"] ?? "-"} ${why} ip=${ip}`);
|
||||
send(req, res, 401, { error: "unauthorized" });
|
||||
}
|
||||
|
||||
const wrap = (fn: (req: Request, res: Response) => Promise<void>) =>
|
||||
(req: Request, res: Response) => { fn(req, res).catch(() => { send(res, 502, { error: "unavailable" }); }); };
|
||||
(req: Request, res: Response) => { fn(req, res).catch(() => { send(req, res, 502, { error: "unavailable" }); }); };
|
||||
|
||||
sub.post("/sub/v1/register", wrap(async (req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
if (!registerLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
const ip = ipOf(req);
|
||||
if (!registerLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
|
||||
const b = (req.body ?? {}) as Record<string, unknown>;
|
||||
const str = (v: unknown, re: RegExp) => typeof v === "string" && re.test(v) ? v : null;
|
||||
@@ -440,16 +531,21 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
const name = typeof b.device_name === "string" ? b.device_name.slice(0, 64) : "";
|
||||
const platform = typeof b.platform === "string" ? b.platform.slice(0, 32) : "";
|
||||
const version = str(b.version, CLIENT_VERSION) ?? "";
|
||||
if (!secretB || !deviceId || !pubKey || !authPubS) { send(res, 400, { error: "bad_request" }); return; }
|
||||
if (!secretB || !deviceId || !pubKey || !authPubS) { send(req, res, 400, { error: "bad_request" }); return; }
|
||||
|
||||
// Регистрация подписана ключом auth_pub из самого тела — доказательство владения.
|
||||
const authPub = Buffer.from(authPubS, "base64url");
|
||||
if (authPub.length !== 32 || !checkSigned(req, authPub)) { send(res, 401, { error: "unauthorized" }); return; }
|
||||
const why = authPub.length !== 32 ? "bad_auth_pub" : signFailure(req, authPub);
|
||||
if (why) {
|
||||
console.warn(`sub: 401 register ${why} ip=${ip}`);
|
||||
send(req, res, 401, { error: "unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
|
||||
if (!master) { send(res, 404, { error: "not_found" }); return; }
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
|
||||
if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; }
|
||||
if (serverIds.length === 0) { send(req, res, 502, { error: "unavailable" }); return; }
|
||||
|
||||
let deviceRowId = 0;
|
||||
let touched: number[] = [];
|
||||
@@ -463,8 +559,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
|
||||
})();
|
||||
} catch (e: any) {
|
||||
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
|
||||
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
||||
if (e?.code === "limit") { send(req, res, 403, { error: "device_limit" }); return; }
|
||||
send(req, res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
||||
}
|
||||
|
||||
await reconcileAll([...touched, ...serverIds]);
|
||||
@@ -473,12 +569,12 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
if (cfg.servers.length === 0) {
|
||||
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
|
||||
await reconcileAll(forgetDevice(dev));
|
||||
send(res, 502, { error: "unavailable" });
|
||||
send(req, res, 502, { error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||
// без отдельного запроса списка.
|
||||
send(res, 201, {
|
||||
send(req, res, 201, {
|
||||
device: dev.id, config: cfg,
|
||||
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
|
||||
});
|
||||
@@ -488,15 +584,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// регистрировали. Запрос не подписан устройством (его ещё нет): доступ по секрету из ссылки, как у register,
|
||||
// а ответ подписан, как всегда. Неверный секрет считается в failLimit — секрет не перебрать.
|
||||
sub.post("/sub/v1/peek", wrap(async (req, res) => {
|
||||
const ip = req.socket.remoteAddress ?? "unknown";
|
||||
const ip = ipOf(req);
|
||||
const secretB = typeof (req.body as any)?.sub === "string" && /^[A-Za-z0-9_-]{22}$/.test((req.body as any).sub) ? (req.body as any).sub as string : null;
|
||||
const master = secretB ? q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined : undefined;
|
||||
if (!master) {
|
||||
if (!failLimit(ip)) { send(res, 429, { error: "rate_limited" }); return; }
|
||||
send(res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
if (!failLimit(ip)) { send(req, res, 429, { error: "rate_limited" }); return; }
|
||||
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
return;
|
||||
}
|
||||
send(res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
send(req, res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
}));
|
||||
|
||||
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
|
||||
@@ -505,7 +601,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
|
||||
const v = String(req.headers["x-sen-version"] ?? "");
|
||||
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
|
||||
send(res, 200, { config: await readyConfig(dev, req) });
|
||||
send(req, res, 200, { config: await readyConfig(dev, req) });
|
||||
}));
|
||||
|
||||
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
|
||||
@@ -513,8 +609,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
sub.get("/sub/v1/devices", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const master = q.masterById.get(dev.master_id) as MasterRow | undefined;
|
||||
if (!master) { send(res, 404, { error: "not_found" }); return; }
|
||||
send(res, 200, {
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
send(req, res, 200, {
|
||||
name: master.label,
|
||||
device_limit: master.device_limit,
|
||||
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
|
||||
@@ -529,10 +625,10 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
|
||||
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
|
||||
if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; }
|
||||
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(req, res, 400, { error: "bad_request" }); return; }
|
||||
if (pub === dev.pub_key) { send(req, res, 200, { config: await readyConfig(dev, req) }); return; }
|
||||
try { q.devicePub.run(pub, 0, dev.id); }
|
||||
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
|
||||
catch { send(req, res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
|
||||
|
||||
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
|
||||
await reconcileAll(ids);
|
||||
@@ -540,21 +636,21 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
if (cfg.servers.length === 0) {
|
||||
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
|
||||
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
|
||||
send(res, 502, { error: "unavailable" });
|
||||
send(req, res, 502, { error: "unavailable" });
|
||||
return;
|
||||
}
|
||||
send(res, 200, { config: cfg });
|
||||
send(req, res, 200, { config: cfg });
|
||||
}));
|
||||
|
||||
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
|
||||
const dev = (req as DevReq).device as DeviceRow;
|
||||
await reconcileAll(forgetDevice(dev));
|
||||
send(res, 200, { ok: true });
|
||||
send(req, res, 200, { ok: true });
|
||||
}));
|
||||
|
||||
sub.use((_req, res) => { send(res, 404, { error: "not_found" }); });
|
||||
sub.use((req, res) => { send(req, res, 404, { error: "not_found" }); });
|
||||
// Битый JSON и т. п.: ответ всё равно подписан, чтобы клиент мог ему доверять.
|
||||
sub.use((_err: unknown, _req: Request, res: Response, _next: NextFunction) => { send(res, 400, { error: "bad_request" }); });
|
||||
sub.use((_err: unknown, req: Request, res: Response, _next: NextFunction) => { send(req, res, 400, { error: "bad_request" }); });
|
||||
|
||||
// ── Админ-роуты панели (монтируются в server.ts за JWT) ─────────────────
|
||||
const aw = (fn: (req: Request, res: Response) => Promise<void>) =>
|
||||
@@ -578,9 +674,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
res.json({
|
||||
enabled: signKey !== null,
|
||||
tls: tlsPin !== null,
|
||||
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
|
||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
||||
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
|
||||
...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -651,14 +749,19 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
res.json({ id: m.id });
|
||||
});
|
||||
|
||||
// Ключ сразу перестаёт работать (ссылка, регистрация, устройства), но строка остаётся
|
||||
// отозванной, пока каждый его сервер не подтвердит, что снял пиров, — иначе панель
|
||||
// сказала бы «удалён», пока на офлайн-ноде устройства ещё подключаются.
|
||||
masterKeys.delete("/:id", aw(async (req, res) => {
|
||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||
const affected: number[] = [];
|
||||
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
|
||||
q.masterDelete.run(m.id); // master_servers уйдут каскадом
|
||||
const affected = serverIdsOf(m.id);
|
||||
uidb.transaction(() => {
|
||||
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
|
||||
q.masterRevoke.run(now(), m.id);
|
||||
})();
|
||||
await reconcileAll(affected);
|
||||
res.json({ success: true, id: m.id });
|
||||
res.json({ success: true, id: m.id, pending: serverIdsOf(m.id) });
|
||||
}));
|
||||
|
||||
masterKeys.get("/:id/link", (req, res) => {
|
||||
@@ -735,6 +838,26 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// ── Листенер и фоновое сведение ────────────────────────────────────────
|
||||
servers.onOnline(id => { void reconcile(id); });
|
||||
|
||||
// Сервер удаляют из реестра: пока им пользуется живой мастер-ключ — нельзя. Иначе сперва
|
||||
// сводим его (ключи его не используют, значит, список пуст и пиры панели снимутся) — после
|
||||
// удаления до него уже не дотянуться. Не вышло (нода не в сети) — удалять только с force.
|
||||
servers.beforeDelete(async (id, force) => {
|
||||
const live = (q.liveOnServer.all(id) as { label: string }[]).map(r => r.label);
|
||||
if (live.length) return { error: `Сервер в мастер-ключах: ${live.join(", ")} — сначала уберите его оттуда`, forcible: false };
|
||||
if (servers.isOnline(id)) await reconcile(id);
|
||||
const waiting = (q.revokedOnServer.all(id) as { label: string }[]).map(r => r.label);
|
||||
const tombs = (q.tombCount.get(id) as { n: number }).n;
|
||||
if (!waiting.length && !tombs) return null;
|
||||
if (!force) return {
|
||||
error: "Сервер не подтвердил, что снял пиров" + (waiting.length ? ` удалённых мастер-ключей (${waiting.join(", ")})` : "") +
|
||||
". Дождитесь, пока он выйдет на связь, или удалите принудительно — тогда пиры могут остаться на нём (снять: awg-ctrl peers drop)",
|
||||
forcible: true,
|
||||
};
|
||||
q.serverRelease.run(id);
|
||||
q.revokedGone.run();
|
||||
return null;
|
||||
});
|
||||
|
||||
function start() {
|
||||
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
|
||||
setInterval(() => {
|
||||
|
||||
+140
-1
@@ -383,12 +383,150 @@ async function joinCore(link?: string, flag?: string) {
|
||||
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
||||
writeFileSync(envFile, content, { mode: 0o600 });
|
||||
|
||||
// Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше
|
||||
// некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их).
|
||||
const dropPeers = !keepPanel && process.env.UI_PASS ? await askDropPanelPeers() : false;
|
||||
if (!keepPanel) await stop("ui");
|
||||
if (dropPeers) await dropArea(localPanelId()!);
|
||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
|
||||
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
|
||||
restartFresh();
|
||||
}
|
||||
|
||||
// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────────
|
||||
// Пира ставит панель, owner = «<id панели>/m<ключ>/d<устройство>»: id — область панели.
|
||||
// У ноды со своей панелью на одном awg-ctrl две области (своя и core). Пиры без области
|
||||
// («m1/d2») выданы до областей. Снимает их awg-ctrl по полному списку от панели
|
||||
// (PUT /api/peers/sync); пустой список = снять всю область. Здесь — ручной рычаг для
|
||||
// оператора: панель выключена или core пропал насовсем. По времени ничего не удаляется.
|
||||
const PANEL_ID_FILE = path.join(ROOT, "awg-ui", "panel.id");
|
||||
const AREA_RE = /^[a-z0-9]{8,16}$/;
|
||||
const LEGACY_OWNER = /^m\d+\/d\d+$/;
|
||||
|
||||
function localPanelId(): string | null {
|
||||
try { const id = readFileSync(PANEL_ID_FILE, "utf8").trim(); return AREA_RE.test(id) ? id : null; }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// Тот же короткоживущий токен, что awg-ui и awg-agent шлют awg-ctrl (Ed25519, 60 с).
|
||||
function mintInternalToken(): string {
|
||||
const key = crypto.createPrivateKey(readFileSync(SERVICES.ui.env.INTERNAL_AUTH_KEY_FILE));
|
||||
const t = Math.floor(Date.now() / 1000);
|
||||
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
|
||||
const p = Buffer.from(JSON.stringify({ iss: "awg-cli", iat: t, exp: t + 60 })).toString("base64url");
|
||||
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), key).toString("base64url")}`;
|
||||
}
|
||||
|
||||
function ctrlCall(method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request({
|
||||
host: "127.0.0.1", port: Number(SERVICES.awgctrl.env.PORT), method, path: urlPath, timeout: 30_000,
|
||||
headers: {
|
||||
Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json",
|
||||
...(payload ? { "Content-Length": Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
}, res => {
|
||||
let raw = "";
|
||||
res.on("data", c => { raw += c; });
|
||||
res.on("end", () => { try { resolve({ status: res.statusCode ?? 0, data: JSON.parse(raw) }); } catch { resolve({ status: res.statusCode ?? 0, data: null }); } });
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => req.destroy(new Error("timeout")));
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
|
||||
interface CtrlPeer { pub_key: string; ip: string; owner: string; online: boolean }
|
||||
|
||||
async function listPeers(): Promise<CtrlPeer[]> {
|
||||
const r = await ctrlCall("GET", "/api/peers");
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
return r.data.peers as CtrlPeer[];
|
||||
}
|
||||
|
||||
// Область пира: id панели, «legacy» (до областей) или null (чужой формат — не наш).
|
||||
function areaOf(owner: string): string | null {
|
||||
const head = owner.split("/")[0];
|
||||
if (owner.includes("/") && AREA_RE.test(head)) return head;
|
||||
return LEGACY_OWNER.test(owner) ? "legacy" : null;
|
||||
}
|
||||
|
||||
const peersWord = (n: number) =>
|
||||
n % 10 === 1 && n % 100 !== 11 ? "пир" : [2, 3, 4].includes(n % 10) && ![12, 13, 14].includes(n % 100) ? "пира" : "пиров";
|
||||
|
||||
async function askDropPanelPeers(): Promise<boolean> {
|
||||
const own = localPanelId();
|
||||
if (!own) return false;
|
||||
let n: number;
|
||||
try { n = (await listPeers()).filter(p => areaOf(p.owner) === own).length; }
|
||||
catch (e) { console.warn(` ${dim(`Не удалось посмотреть пиров подписки: ${(e as Error).message}`)}`); return false; }
|
||||
if (n === 0) return false;
|
||||
|
||||
const preset = (process.env.NODE_PEERS ?? "").toLowerCase();
|
||||
if (preset === "keep" || preset === "drop") return preset === "drop";
|
||||
console.log(`\n На этой панели ${n} ${peersWord(n)} подписки (устройства мастер-ключей). Без панели ими некому управлять:`);
|
||||
console.log(` 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin`);
|
||||
console.log(` 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)`);
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const a = await new Promise<string>(r => rl.question(" Выбор [1]: ", r));
|
||||
rl.close();
|
||||
return a.trim() === "2";
|
||||
}
|
||||
|
||||
// Снять всю область: для панели — пустым списком через sync, для legacy — по одному.
|
||||
async function dropArea(area: string): Promise<void> {
|
||||
try {
|
||||
if (area === "legacy") {
|
||||
const legacy = (await listPeers()).filter(p => areaOf(p.owner) === "legacy");
|
||||
for (const p of legacy) await ctrlCall("DELETE", `/api/peers/${Buffer.from(p.pub_key, "base64").toString("base64url")}`);
|
||||
console.log(` ${green("✓")} Снято пиров без области: ${legacy.length}`);
|
||||
return;
|
||||
}
|
||||
const r = await ctrlCall("PUT", "/api/peers/sync", { owner: area, keep: [] });
|
||||
if (r.status !== 200) throw new Error(r.data?.error ?? `awg-ctrl ответил ${r.status}`);
|
||||
console.log(` ${green("✓")} Снято пиров области ${area}: ${r.data.removed}`);
|
||||
} catch (e) {
|
||||
console.error(` Не удалось снять пиров: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function peersCmd(sub?: string, arg?: string) {
|
||||
const own = localPanelId();
|
||||
let peers: CtrlPeer[];
|
||||
try { peers = await listPeers(); }
|
||||
catch (e) { console.error(` awg-ctrl недоступен: ${(e as Error).message}`); process.exit(1); }
|
||||
const areas = new Map<string, CtrlPeer[]>();
|
||||
for (const p of peers) { const a = areaOf(p.owner); if (a) areas.set(a, [...(areas.get(a) ?? []), p]); }
|
||||
|
||||
if (sub === undefined) {
|
||||
if (!areas.size) { console.log(" Пиров подписки (sen://) на этом сервере нет"); return; }
|
||||
console.log("\n Пиры подписки (sen://) на этом сервере:\n");
|
||||
for (const [a, list] of areas) {
|
||||
const mark = a === own ? "эта панель" : a === "legacy" ? "без области (выданы до областей)" : "";
|
||||
console.log(` ${bold(a.padEnd(16))} ${String(list.length).padStart(4)} ${peersWord(list.length).padEnd(5)}` +
|
||||
` онлайн ${list.filter(p => p.online).length} ${dim(mark)}`);
|
||||
}
|
||||
console.log(`\n ${dim("Снять область целиком: awg-ctrl peers drop <область>")}`);
|
||||
return;
|
||||
}
|
||||
if (sub !== "drop" || !arg || !(arg === "legacy" || AREA_RE.test(arg))) {
|
||||
console.error(" Usage: awg-ctrl peers [drop <область|legacy>]");
|
||||
process.exit(1);
|
||||
}
|
||||
const n = areas.get(arg)?.length ?? 0;
|
||||
if (!n) { console.log(` В области ${arg} пиров нет`); return; }
|
||||
// Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно.
|
||||
if (arg === own && isRunning("ui")) {
|
||||
console.error(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` ${dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")}`);
|
||||
console.log(` ${dim("сперва удалите сервер в ней (или мастер-ключи).")}`);
|
||||
if (!await confirmYes(` Снять ${n} ${peersWord(n)} области ${arg}? Их устройства потеряют доступ к этому серверу.`)) return;
|
||||
await dropArea(arg);
|
||||
}
|
||||
|
||||
async function unjoinCore() {
|
||||
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
|
||||
await stop("agent");
|
||||
@@ -502,7 +640,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>";
|
||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]>";
|
||||
|
||||
const [,,, sub, arg] = process.argv;
|
||||
|
||||
@@ -518,6 +656,7 @@ async function main() {
|
||||
break;
|
||||
case "join": await joinCore(sub, arg); break;
|
||||
case "unjoin": await unjoinCore(); break;
|
||||
case "peers": await peersCmd(sub, arg); break;
|
||||
default: console.log(USAGE); process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user