Compare commits
12
Commits
1e73aaae6a
..
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eede2f015 | ||
|
|
2054122110 | ||
|
|
129f29d6be | ||
|
|
2f2f0e268a | ||
|
|
2e773deba2 | ||
|
|
aa31b7b8a2 | ||
|
|
789538756a | ||
|
|
1278ad527d | ||
|
|
6e010a0141 | ||
|
|
923bd87fe9 | ||
|
|
d5e00ddaf8 | ||
|
|
2b46d04af9 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.3.1
|
VERSION=0.3.2
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
|||||||
|
|
||||||
| Метод | Ответ |
|
| Метод | Ответ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-agent",
|
"name": "awg-agent",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+13
-7
@@ -48,12 +48,14 @@ let apiId = 1;
|
|||||||
|
|
||||||
// ── мастер-ключи sen:// ──
|
// ── мастер-ключи 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 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[] }
|
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[] }
|
||||||
let mkId = 1, devId = 1;
|
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 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")] }];
|
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 signPub = crypto.randomBytes(32);
|
||||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, server_id: 0, 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 });
|
||||||
|
// Мок: устройство «догнано» на всех серверах ключа, кроме 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));
|
||||||
|
|
||||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
||||||
@@ -104,10 +106,14 @@ app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: m
|
|||||||
app.post("/ui/masterkeys", (q, r) => {
|
app.post("/ui/masterkeys", (q, r) => {
|
||||||
const limit = q.body.device_limit ?? 3;
|
const limit = q.body.device_limit ?? 3;
|
||||||
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
||||||
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] };
|
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
||||||
masters.push(m); r.status(201).json(mkOut(m));
|
masters.push(m); r.status(201).json(mkOut(m));
|
||||||
});
|
});
|
||||||
app.patch("/ui/masterkeys/:id", (q, r) => { const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" }); if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m)); });
|
app.patch("/ui/masterkeys/:id", (q, r) => {
|
||||||
|
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
||||||
|
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
|
||||||
|
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m));
|
||||||
|
});
|
||||||
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/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.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 i = masters.findIndex(m => m.id === Number(q.params.id)); if (i >= 0) masters.splice(i, 1); r.json({ success: true }); });
|
||||||
@@ -115,10 +121,10 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
|
|||||||
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
|
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 });
|
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
|
||||||
});
|
});
|
||||||
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs }) : r.status(404).json({ error: "Не найден" }); });
|
app.get("/ui/masterkeys/:id/devices", (q, r) => { const m = find(q.params.id); m ? r.json({ devices: m.devs.map(d => devOut(m, d)) }) : r.status(404).json({ error: "Не найден" }); });
|
||||||
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
|
app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.devs.filter(d => d.id !== Number(q.params.id)); r.json({ success: true }); });
|
||||||
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
|
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
|
||||||
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
|
app.post("/ui/devices/:id/psk", (_q, r) => r.json({ pending: 0 }));
|
||||||
|
|
||||||
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
|
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
|
||||||
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
|
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
|
||||||
|
|||||||
+21
-3
@@ -138,6 +138,16 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
|
|
||||||
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
|
const exists = (id: number) => (id === 0 ? LOCAL : q.byId.get(id) !== undefined);
|
||||||
|
|
||||||
|
/** Есть ли сейчас связь с сервером (локальный считаем доступным всегда — до первого отказа awg-ctrl). */
|
||||||
|
const isOnline = (id: number) => (id === 0 ? LOCAL : conns.has(id));
|
||||||
|
|
||||||
|
/** Все серверы реестра: локальный (если есть) и ноды. */
|
||||||
|
const ids = (): number[] => [...(LOCAL ? [0] : []), ...(q.all.all() as NodeRow[]).map(n => n.id)];
|
||||||
|
|
||||||
|
// Подписчики на «сервер снова на связи» — подписка по этому сигналу догоняет пропущенное.
|
||||||
|
const onlineHandlers: ((id: number) => void)[] = [];
|
||||||
|
const onOnline = (cb: (id: number) => void) => { onlineHandlers.push(cb); };
|
||||||
|
|
||||||
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
||||||
function defaultId(): number {
|
function defaultId(): number {
|
||||||
if (LOCAL) return 0;
|
if (LOCAL) return 0;
|
||||||
@@ -216,6 +226,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
ws.send(JSON.stringify({ t: "ready" }));
|
ws.send(JSON.stringify({ t: "ready" }));
|
||||||
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
|
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
|
||||||
void refreshHealth(row.id);
|
void refreshHealth(row.id);
|
||||||
|
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.on("message", raw => {
|
ws.on("message", raw => {
|
||||||
@@ -241,7 +252,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
}
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (LOCAL) void refreshHealth(0);
|
if (LOCAL) {
|
||||||
|
void refreshHealth(0);
|
||||||
|
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
|
||||||
|
}
|
||||||
if (!NODE_PORT || !tls) return;
|
if (!NODE_PORT || !tls) return;
|
||||||
const server = https.createServer(tls);
|
const server = https.createServer(tls);
|
||||||
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
|
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
|
||||||
@@ -330,7 +344,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
|
||||||
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
|
||||||
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
|
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
|
||||||
const used = (["api_keys", "master_keys"] as const).some(t => {
|
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; }
|
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
|
||||||
catch { return false; }
|
catch { return false; }
|
||||||
});
|
});
|
||||||
@@ -338,8 +352,12 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
|||||||
const c = conns.get(id);
|
const c = conns.get(id);
|
||||||
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
|
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
|
||||||
q.delete.run(id);
|
q.delete.run(id);
|
||||||
|
// Хвосты подписки для этого сервера больше некому доделать.
|
||||||
|
for (const t of ["peer_tombstones", "server_profiles"]) {
|
||||||
|
try { uidb.prepare(`DELETE FROM ${t} WHERE server_id = ?`).run(id); } catch { /* таблицы ещё нет */ }
|
||||||
|
}
|
||||||
res.json({ success: true, id });
|
res.json({ success: true, id });
|
||||||
});
|
});
|
||||||
|
|
||||||
return { router, ctrlFor, exists, defaultId, start };
|
return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+2
-2
@@ -289,8 +289,8 @@ app.use("/api/v1", ext);
|
|||||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
|
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
|
||||||
app.use("/ui/nodes", requireAuth, nodes.router);
|
app.use("/ui/nodes", requireAuth, nodes.router);
|
||||||
|
|
||||||
// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг.
|
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||||
const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname });
|
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
|
||||||
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
|
||||||
app.use("/ui/devices", requireAuth, sub.devices);
|
app.use("/ui/devices", requireAuth, sub.devices);
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,25 @@
|
|||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { Fragment, useState, useEffect, useCallback } from 'react';
|
import { Fragment, useState, useEffect, useCallback } from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, type PageProps } from '../../lib/shared';
|
import { apiFetch, copyText, bytes, timeAgo, genLabel, type NodeInfo, type PageProps } from '../../lib/shared';
|
||||||
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
import { IcoPlus, IcoTrash, IcoCopy, IcoQR, IcoKey, IcoRefresh, IcoSettings } from '../../components/icons';
|
||||||
import './masterkeys.css';
|
import './masterkeys.css';
|
||||||
|
|
||||||
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
|
||||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; created_at: number }
|
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number }
|
||||||
interface Device {
|
interface Device {
|
||||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
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;
|
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||||
|
servers_total: number; servers_ok: number; // на скольких серверах ключа уже стоит его текущий ключ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Устройство ещё не на всех серверах ключа (нода не в сети при регистрации или смене ключа) —
|
||||||
|
// панель догонит, когда нода появится.
|
||||||
|
const syncLabel = (d: Device): string =>
|
||||||
|
d.servers_ok < d.servers_total ? ` · на ${d.servers_ok} из ${d.servers_total} серверов` : '';
|
||||||
|
|
||||||
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
|
// Платформа и версия клиента: «macOS-0.6.5»; без версии — просто платформа.
|
||||||
const platformLabel = (d: Device): string =>
|
const platformLabel = (d: Device): string =>
|
||||||
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
|
d.platform ? (d.version ? `${d.platform}-${d.version}` : d.platform) : '—';
|
||||||
@@ -26,7 +32,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const [keys, setKeys] = useState<MasterKey[]>([]);
|
const [keys, setKeys] = useState<MasterKey[]>([]);
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
const [tls, setTls] = useState(false);
|
const [tls, setTls] = useState(false);
|
||||||
const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null);
|
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
const [limit, setLimit] = useState('3');
|
const [limit, setLimit] = useState('3');
|
||||||
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
const [devs, setDevs] = useState<Record<number, Device[]>>({});
|
||||||
@@ -40,8 +46,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
const d = await apiFetch('GET', '/ui/masterkeys', token);
|
||||||
const list: MasterKey[] = d.keys ?? [];
|
const list: MasterKey[] = d.keys ?? [];
|
||||||
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
|
||||||
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
|
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
|
||||||
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
|
|
||||||
const all = await Promise.all(list.map(k =>
|
const all = await Promise.all(list.map(k =>
|
||||||
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
|
||||||
setDevs(Object.fromEntries(all));
|
setDevs(Object.fromEntries(all));
|
||||||
@@ -57,10 +62,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
const create = useCallback(async () => {
|
const create = useCallback(async () => {
|
||||||
if (!label.trim()) return;
|
if (!label.trim()) return;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
|
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
|
||||||
|
const ready = nodes.filter(n => !n.pending).map(n => n.id);
|
||||||
|
await apiFetch('POST', '/ui/masterkeys', token, {
|
||||||
|
label: label.trim(), device_limit: Number(limit), ...(ready.length ? { servers: ready } : {}),
|
||||||
|
});
|
||||||
setLabel('');
|
setLabel('');
|
||||||
}, 'Мастер-ключ создан');
|
}, 'Мастер-ключ создан');
|
||||||
}, [label, limit, token, act]);
|
}, [label, limit, nodes, token, act]);
|
||||||
|
|
||||||
const remove = useCallback(async (k: MasterKey) => {
|
const remove = useCallback(async (k: MasterKey) => {
|
||||||
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
|
||||||
@@ -146,20 +155,32 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
|
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
|
||||||
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
|
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
|
||||||
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
|
|
||||||
<section className="mk-box">
|
<section className="mk-box">
|
||||||
<h4 className="mk-box-title">Включённые серверы</h4>
|
<h4 className="mk-box-title">Серверы ключа</h4>
|
||||||
<label className="mk-server">
|
{nodes.map(n => {
|
||||||
<input type="checkbox" checked disabled />
|
const on = k.servers.includes(n.id);
|
||||||
<span className="mk-server-name">{server?.name ?? 'Текущий сервер'}</span>
|
const last = on && k.servers.length === 1;
|
||||||
{server?.ip && <code className="mk-server-ip">{server.ip}</code>}
|
const h = n.health;
|
||||||
{server?.gen && <span className="mk-server-gen">{genLabel(server.gen as AwgGen)}</span>}
|
return (
|
||||||
</label>
|
<label className={`mk-server${last ? ' mk-server--locked' : ''}`} key={n.id}>
|
||||||
<span className="tip-wrap" data-tip="Появится вместе с поддержкой нескольких серверов">
|
<input type="checkbox" checked={on} disabled={last}
|
||||||
<button className="btn mk-server-add" disabled><IcoPlus /> Добавить сервер</button>
|
onChange={() => {
|
||||||
</span>
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="mk-setting-hint">Новые серверы добавляются кнопкой «Добавить сервер» над вкладкой.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -181,8 +202,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
const deviceActions = (d: Device) => (
|
const deviceActions = (d: Device) => (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
|
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
|
||||||
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><IcoRefresh /></button>
|
try {
|
||||||
|
const r = await apiFetch('POST', `/ui/devices/${d.id}/psk`, token);
|
||||||
|
showMsg(r.pending ? `PSK обновлён; ещё на ${r.pending} — когда сервер будет на связи` : 'PSK обновлён');
|
||||||
|
await load();
|
||||||
|
} catch (e) { showMsg(errText(e)); }
|
||||||
|
}}><IcoRefresh /></button>
|
||||||
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
|
<button className="btn-icon btn-icon--danger" title="Отозвать устройство" aria-label="Отозвать устройство" onClick={() => {
|
||||||
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
|
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
|
||||||
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
|
act(() => apiFetch('DELETE', `/ui/devices/${d.id}`, token), 'Устройство отозвано');
|
||||||
@@ -205,7 +231,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
<code className="mk-device-id" title={`ID устройства: ${d.device_id}`}>{d.device_id}</code>
|
||||||
</span>
|
</span>
|
||||||
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
<span className="mk-device-sub">{platformLabel(d)} · был {d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||||
{d.rekey_requested ? ' · ждёт смены ключа' : ''}</span>
|
{d.rekey_requested ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
|
||||||
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
|
||||||
@@ -276,7 +302,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
|||||||
<td className="td-mono">{bytes(d.rx)}</td>
|
<td className="td-mono">{bytes(d.rx)}</td>
|
||||||
<td className="td-mono">{bytes(d.tx)}</td>
|
<td className="td-mono">{bytes(d.tx)}</td>
|
||||||
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
<td>{d.last_seen ? timeAgo(d.last_seen) + ' назад' : 'никогда'}
|
||||||
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}</td>
|
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}
|
||||||
|
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -162,13 +162,11 @@ LICENSE file in the root directory of this source tree.
|
|||||||
.mk-server-name { font-weight: 500; }
|
.mk-server-name { font-weight: 500; }
|
||||||
.mk-server-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
|
.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-ip { font-family: var(--mono); margin-left: auto; }
|
||||||
.mk-server-add {
|
.mk-server { cursor: pointer; }
|
||||||
width: 100%; justify-content: center;
|
.mk-server:hover { background: var(--state-primary); }
|
||||||
background: transparent;
|
.mk-server--locked { cursor: default; }
|
||||||
border: 1px dashed var(--outline);
|
.mk-server--locked:hover { background: var(--surface-container); }
|
||||||
color: var(--neutral-dot);
|
.mk-server-dot { padding: 0; background: none; }
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.mk-box .tip-wrap { width: 100%; }
|
.mk-box .tip-wrap { width: 100%; }
|
||||||
|
|
||||||
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
|
||||||
|
|||||||
+355
-72
@@ -8,6 +8,14 @@
|
|||||||
//
|
//
|
||||||
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
|
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
|
||||||
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
|
// публичному ключу (/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 crypto from "crypto";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
@@ -22,6 +30,16 @@ import {
|
|||||||
|
|
||||||
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
|
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 {
|
interface MasterRow {
|
||||||
id: number; label: string; secret: Buffer; device_limit: number;
|
id: number; label: string; secret: Buffer; device_limit: number;
|
||||||
server_id: number; created_at: number; revoked_at: number | null;
|
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;
|
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
|
||||||
rekey_requested: number; version: string;
|
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 TS_WINDOW = 300; // секунд, ±
|
||||||
const DEFAULT_LIMIT = 3;
|
const DEFAULT_LIMIT = 3;
|
||||||
|
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
|
||||||
|
const SYNC_EVERY = 60_000; // фоновое сведение серверов
|
||||||
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
|
||||||
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
|
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 }) {
|
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
|
||||||
const { uidb, ctrl } = deps;
|
const { uidb, servers } = deps;
|
||||||
|
|
||||||
// ── Настройка из окружения ─────────────────────────────────────────────
|
// ── Настройка из окружения ─────────────────────────────────────────────
|
||||||
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
|
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
|
||||||
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
|
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);
|
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
|
||||||
|
|
||||||
let signKey: crypto.KeyObject | null = null;
|
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,
|
rekey_requested INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE (master_id, device_id)
|
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.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||||
uidb.pragma("foreign_keys = ON");
|
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 = {
|
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 WHERE revoked_at IS NULL ORDER BY id"),
|
||||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
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"),
|
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 = ?"),
|
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 = ?"),
|
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
|
||||||
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys 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"),
|
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
|
||||||
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
|
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
|
||||||
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_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 = ?"),
|
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
|
||||||
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
|
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
|
||||||
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? 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 = ?"),
|
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 now = () => Math.floor(Date.now() / 1000);
|
||||||
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
|
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() {
|
function storedProfile(id: number): Profile | null {
|
||||||
const r = await ctrl("GET", "/api/profile");
|
const row = q.profileGet.get(id) as { profile: string } | undefined;
|
||||||
if (!hasCtrlOk(r.status)) throw new Error("profile");
|
try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; }
|
||||||
return r.data as {
|
|
||||||
name: string; endpoint: string; server_pub: string; gen: string;
|
|
||||||
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(":"));
|
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
|
||||||
|
|
||||||
async function buildConfig(dev: DeviceRow) {
|
function subHost(req?: Request): string {
|
||||||
const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]);
|
if (SUB_HOST) return SUB_HOST;
|
||||||
if (!hasCtrlOk(peers.status)) throw new Error("peers");
|
const local = servers.exists(0) ? storedProfile(0) : null;
|
||||||
const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[])
|
if (local) return hostOf(local.endpoint);
|
||||||
.find(p => p.pub_key === dev.pub_key);
|
return (req?.hostname ?? "").replace(/^\[|\]$/g, "");
|
||||||
if (!peer) throw new Error("peer missing");
|
}
|
||||||
|
|
||||||
|
// В 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 = {
|
const cfg = {
|
||||||
rekey: dev.rekey_requested === 1,
|
rekey: dev.rekey_requested === 1,
|
||||||
endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`],
|
endpoints: [`${subHost(req)}:${SUB_PORT}`],
|
||||||
servers: [{
|
servers: list,
|
||||||
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,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
|
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
|
||||||
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
|
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
|
||||||
return { rev, ...cfg };
|
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 ───────────────────────────────────────────
|
// ── Публичная часть: /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;
|
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
|
||||||
if (!master) { send(res, 404, { error: "not_found" }); return; }
|
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 deviceRowId = 0;
|
||||||
let oldPub: string | null = null;
|
let touched: number[] = [];
|
||||||
try {
|
try {
|
||||||
uidb.transaction(() => {
|
uidb.transaction(() => {
|
||||||
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
|
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)
|
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
|
||||||
throw Object.assign(new Error("limit"), { code: "limit" });
|
throw Object.assign(new Error("limit"), { code: "limit" });
|
||||||
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
|
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) {
|
} catch (e: any) {
|
||||||
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
|
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
|
||||||
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldPub) await dropPeer(oldPub);
|
await reconcileAll([...touched, ...serverIds]);
|
||||||
const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` });
|
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
|
||||||
if (!hasCtrlOk(r.status)) {
|
const cfg = buildConfig(dev, req);
|
||||||
q.deviceDelete.run(deviceRowId);
|
if (cfg.servers.length === 0) {
|
||||||
send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" });
|
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
|
||||||
|
await reconcileAll(forgetDevice(dev));
|
||||||
|
send(res, 502, { error: "unavailable" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
|
|
||||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||||
// без отдельного запроса списка.
|
// без отдельного запроса списка.
|
||||||
send(res, 201, {
|
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,
|
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"] ?? "");
|
const v = String(req.headers["x-sen-version"] ?? "");
|
||||||
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
|
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) => {
|
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
|
||||||
const dev = (req as DevReq).device as DeviceRow;
|
const dev = (req as DevReq).device as DeviceRow;
|
||||||
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
|
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 (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 (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; }
|
||||||
if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; }
|
try { q.devicePub.run(pub, 0, dev.id); }
|
||||||
q.deviceRekeyed.run(pub, dev.id);
|
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
|
||||||
send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) });
|
|
||||||
|
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) => {
|
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
|
||||||
const dev = (req as DevReq).device as DeviceRow;
|
const dev = (req as DevReq).device as DeviceRow;
|
||||||
await dropPeer(dev.pub_key);
|
await reconcileAll(forgetDevice(dev));
|
||||||
q.deviceDelete.run(dev.id);
|
|
||||||
send(res, 200, { ok: true });
|
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 LABEL = /^[^\r\n<>]{1,40}$/;
|
||||||
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
|
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();
|
const masterKeys = Router();
|
||||||
|
|
||||||
masterKeys.get("/", (_req, res) => {
|
masterKeys.get("/", (_req, res) => {
|
||||||
@@ -353,30 +580,62 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
|
|||||||
tls: tlsPin !== null,
|
tls: tlsPin !== null,
|
||||||
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
|
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
|
||||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
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) => {
|
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; }
|
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
|
||||||
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
|
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
|
||||||
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
|
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
|
||||||
const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now());
|
const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted);
|
||||||
res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 });
|
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;
|
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
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;
|
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 (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
|
||||||
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); 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);
|
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) => {
|
masterKeys.post("/:id/rotate", (req, res) => {
|
||||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
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) => {
|
masterKeys.delete("/:id", aw(async (req, res) => {
|
||||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||||
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
|
const affected: number[] = [];
|
||||||
q.masterDelete.run(m.id); // devices уйдут каскадом
|
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 });
|
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;
|
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||||
if (!signKey || !signPub || !SUB_PORT) { res.status(503).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({
|
const link = encodeSenLink({
|
||||||
tls: tlsPin !== null,
|
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,
|
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
|
||||||
});
|
});
|
||||||
res.json({ link, tls: tlsPin !== null });
|
res.json({ link, tls: tlsPin !== null });
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
|
||||||
|
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
|
||||||
masterKeys.get("/:id/devices", aw(async (req, res) => {
|
masterKeys.get("/:id/devices", aw(async (req, res) => {
|
||||||
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
|
||||||
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
|
||||||
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
|
||||||
const r = await ctrl("GET", "/api/peers");
|
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
|
||||||
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
|
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({
|
res.json({
|
||||||
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({
|
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,
|
const links = q.linksOf.all(d.id) as LinkRow[];
|
||||||
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
|
const s = stats.get(d.pub_key);
|
||||||
...pick(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();
|
const devices = Router();
|
||||||
|
|
||||||
devices.delete("/:id", aw(async (req, res) => {
|
devices.delete("/:id", aw(async (req, res) => {
|
||||||
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
||||||
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
||||||
await dropPeer(d.pub_key);
|
await reconcileAll(forgetDevice(d));
|
||||||
q.deviceDelete.run(d.id);
|
|
||||||
res.json({ success: true, id: d.id });
|
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 });
|
res.json({ id: d.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Новый PSK на каждом сервере; недоступные получат его, когда появятся.
|
||||||
devices.post("/:id/psk", aw(async (req, res) => {
|
devices.post("/:id/psk", aw(async (req, res) => {
|
||||||
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
|
||||||
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
|
||||||
const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`);
|
q.linkPskDue.run(d.id);
|
||||||
res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" });
|
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() {
|
function start() {
|
||||||
|
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
|
||||||
|
setInterval(() => {
|
||||||
|
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
|
||||||
|
}, SYNC_EVERY).unref();
|
||||||
|
|
||||||
if (!signKey) return;
|
if (!signKey) return;
|
||||||
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
|
||||||
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.3.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+28
-15
@@ -100,6 +100,9 @@ const SERVICES = {
|
|||||||
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
||||||
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
||||||
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
||||||
|
// Публичный адрес панели в sen://-ссылках и endpoints. Пусто — адрес локального
|
||||||
|
// сервера (у панели без своего VPN — тот, по которому к ней зашли).
|
||||||
|
SUB_HOST: process.env.SUB_HOST ?? "",
|
||||||
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
|
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
|
||||||
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
|
||||||
// core без собственного VPN: awg-ctrl рядом не нужен.
|
// core без собственного VPN: awg-ctrl рядом не нужен.
|
||||||
@@ -141,7 +144,13 @@ type SvcName = keyof typeof SERVICES;
|
|||||||
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
|
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
|
||||||
const IS_NODE = !!process.env.CORE_HOST;
|
const IS_NODE = !!process.env.CORE_HOST;
|
||||||
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
|
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
|
||||||
const ALL: SvcName[] = IS_NODE ? ["awgctrl", "agent"] : NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
// Нода со своей панелью: работающий сервер «панель + VPN» подключили к главной панели, не
|
||||||
|
// выключая его собственную (join без --no-panel). Обе панели ходят в один awg-ctrl — он и
|
||||||
|
// есть источник правды, поэтому друг другу они не мешают. У ноды, поставленной install.sh,
|
||||||
|
// панели нет (UI_PASS не задан).
|
||||||
|
const NODE_PANEL = IS_NODE && !!process.env.UI_PASS && (process.env.NODE_PANEL ?? "on").toLowerCase() !== "off";
|
||||||
|
const ALL: SvcName[] = IS_NODE ? (NODE_PANEL ? ["awgctrl", "ui", "agent"] : ["awgctrl", "agent"])
|
||||||
|
: NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
||||||
|
|
||||||
function readPid(name: SvcName): number | null {
|
function readPid(name: SvcName): number | null {
|
||||||
const { pidFile } = SERVICES[name];
|
const { pidFile } = SERVICES[name];
|
||||||
@@ -319,12 +328,12 @@ async function startAll() {
|
|||||||
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
|
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
|
||||||
|
|
||||||
await start("awgctrl");
|
await start("awgctrl");
|
||||||
const next: SvcName = IS_NODE ? "agent" : "ui";
|
const rest = ALL.filter(n => n !== "awgctrl");
|
||||||
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
||||||
console.error(` ${grey("■")} awg-ctrl не отвечает — ${SERVICES[next].label} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await start(next);
|
for (const n of rest) await start(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopAll() { for (const n of ALL) await stop(n); }
|
async function stopAll() { for (const n of ALL) await stop(n); }
|
||||||
@@ -347,11 +356,12 @@ function restartFresh(): void {
|
|||||||
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
|
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// `join <awgjoin://…>`: превратить эту установку в ноду. Панель (awg-ui) на этой машине
|
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
|
||||||
// останавливается, вместо неё запускается агент; awg-ctrl и пользователи остаются как были.
|
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
|
||||||
async function joinCore(link?: string) {
|
// работать, если она здесь есть, — с --no-panel она останавливается.
|
||||||
if (!link?.startsWith("awgjoin://")) {
|
async function joinCore(link?: string, flag?: string) {
|
||||||
console.error(" Usage: awg-ctrl join <awgjoin://…> (ссылку даёт панель: «Добавить сервер»)");
|
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
|
||||||
|
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
|
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
|
||||||
@@ -364,15 +374,18 @@ async function joinCore(link?: string) {
|
|||||||
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
|
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
|
||||||
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
|
if (IS_NODE && !await confirmYes(` Эта нода уже подключена к ${process.env.CORE_HOST}. Перепривязать?`)) return;
|
||||||
|
|
||||||
|
const keepPanel = flag !== "--no-panel" && !!process.env.UI_PASS;
|
||||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||||
const set: Record<string, string> = {
|
const set: Record<string, string> = {
|
||||||
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
|
CORE_HOST: j.h!, CORE_PORT: String(j.p), CORE_PIN: j.pin!, NODE_ID: String(j.n), JOIN_SECRET: j.s!,
|
||||||
|
NODE_PANEL: keepPanel ? "on" : "off",
|
||||||
};
|
};
|
||||||
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
||||||
writeFileSync(envFile, content, { mode: 0o600 });
|
writeFileSync(envFile, content, { mode: 0o600 });
|
||||||
|
|
||||||
await stop("ui"); // панель на ноде не нужна
|
if (!keepPanel) await stop("ui");
|
||||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}`);
|
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
|
||||||
|
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
|
||||||
restartFresh();
|
restartFresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +393,7 @@ async function unjoinCore() {
|
|||||||
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
|
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
|
||||||
await stop("agent");
|
await stop("agent");
|
||||||
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||||
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET"]) content = removeEnvVar(content, k);
|
for (const k of ["CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"]) content = removeEnvVar(content, k);
|
||||||
writeFileSync(envFile, content, { mode: 0o600 });
|
writeFileSync(envFile, content, { mode: 0o600 });
|
||||||
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
|
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
|
||||||
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
|
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
|
||||||
@@ -489,9 +502,9 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…>|unjoin>";
|
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>";
|
||||||
|
|
||||||
const [,,, sub] = process.argv;
|
const [,,, sub, arg] = process.argv;
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "start": await startAll(); break;
|
case "start": await startAll(); break;
|
||||||
@@ -503,7 +516,7 @@ async function main() {
|
|||||||
else if (sub === "pass") await setCredentials("pass");
|
else if (sub === "pass") await setCredentials("pass");
|
||||||
else await setCredentials();
|
else await setCredentials();
|
||||||
break;
|
break;
|
||||||
case "join": await joinCore(sub); break;
|
case "join": await joinCore(sub, arg); break;
|
||||||
case "unjoin": await unjoinCore(); break;
|
case "unjoin": await unjoinCore(); break;
|
||||||
default: console.log(USAGE); process.exit(1);
|
default: console.log(USAGE); process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
|||||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||||
# При бампе версии правь оба места.
|
# При бампе версии правь оба места.
|
||||||
VERSION="0.3.1"
|
VERSION="0.3.2"
|
||||||
CHANNEL="${CHANNEL:-Beta}"
|
CHANNEL="${CHANNEL:-Beta}"
|
||||||
|
|
||||||
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||||
@@ -1363,6 +1363,7 @@ SUB_TLS=${SUB_TLS}
|
|||||||
SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV}
|
SUB_SIGN_KEY_FILE=${SUB_SIGN_PRIV}
|
||||||
SUB_TLS_KEY_FILE=${SUB_TLS_KEY}
|
SUB_TLS_KEY_FILE=${SUB_TLS_KEY}
|
||||||
SUB_TLS_CERT_FILE=${SUB_TLS_CRT}
|
SUB_TLS_CERT_FILE=${SUB_TLS_CRT}
|
||||||
|
SUB_HOST=${SERVER_IP}
|
||||||
|
|
||||||
# ── приём нод (несколько серверов) ────────────────────────────────────────
|
# ── приём нод (несколько серверов) ────────────────────────────────────────
|
||||||
NODE_PORT=${NODE_PORT}
|
NODE_PORT=${NODE_PORT}
|
||||||
|
|||||||
Reference in New Issue
Block a user