Compare commits
4
Commits
eb0ec2878c
...
v0.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fbff82e76 | ||
|
|
565b2759ed | ||
|
|
f0f2f9848a | ||
|
|
5c3bf21918 |
@@ -104,8 +104,8 @@ curl -s -X POST http://HOST:PORT/api/v1/masterkeys \
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
|
||||
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
|
||||
| `POST /api/v1/masterkeys` | Создаёт ключ. Тело `{ label, device_limit?, servers? }` (лимит 1–100, по умолчанию 3; без `servers` — весь набор API-ключа). `201` с `{ id, uuid, label, device_limit, devices, servers, created_at, link, tls }`; `403` — сервер вне набора, `503` — подписка не настроена (ключ не создаётся) |
|
||||
| `GET /api/v1/masterkeys` | Список: `{ keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`. `uuid` — постоянный идентификатор ключа: не меняется ни при перевыпуске ссылки, ни при правке метки. Везде, где в пути `:id`, можно передать и `uuid` |
|
||||
| `GET /api/v1/masterkeys/:id` | Ключ, ссылка и устройства одним запросом: те же поля + `link`, `tls`, `device_list` (`devices` — счётчик) |
|
||||
| `PATCH /api/v1/masterkeys/:id` | `{ label?, device_limit?, servers? }` — смена серверов сразу добавляет/снимает пиров устройств |
|
||||
| `POST /api/v1/masterkeys/:id/rotate` | Новая ссылка `{ id, link, tls }`; старая не принимает новые устройства, подключённые работают дальше |
|
||||
@@ -149,7 +149,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
||||
|
||||
| Метод | Ответ |
|
||||
|---|---|
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.3" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.4" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||
|
||||
@@ -196,7 +196,7 @@ base64url-декодировать, отбросить первые 4 байта
|
||||
|
||||
| Метод | Что делает |
|
||||
|---|---|
|
||||
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, label, device_limit, devices, servers, created_at, deleting? }] }` |
|
||||
| `GET /ui/masterkeys` | `{ enabled, tls, keys: [{ id, uuid, label, device_limit, devices, servers, created_at, deleting? }] }`; `:id` в путях — id или `uuid` |
|
||||
| `POST /ui/masterkeys` | `{ label, device_limit?, servers? }` → `201` |
|
||||
| `PATCH /ui/masterkeys/:id` | `{ label?, device_limit?, servers? }` |
|
||||
| `GET /ui/masterkeys/:id/link` | `{ link, tls }` — ссылка `sen://…` |
|
||||
|
||||
@@ -23,6 +23,16 @@ const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnez
|
||||
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
|
||||
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
|
||||
|
||||
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
|
||||
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
|
||||
function envFromFile(file: string, key: string): string {
|
||||
try {
|
||||
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
|
||||
return m ? m[1].trim() : "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
|
||||
|
||||
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
|
||||
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
|
||||
process.exit(1);
|
||||
@@ -138,6 +148,7 @@ function connectOnce(): Promise<{ authFailed: boolean }> {
|
||||
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
|
||||
};
|
||||
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
|
||||
if (VERSION) auth.version = VERSION;
|
||||
ws.send(JSON.stringify(auth));
|
||||
} else if (m.t === "ready") {
|
||||
ready = true; backoff = 1000; armDead();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-agent",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-control",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
+20
-14
@@ -15,6 +15,7 @@ const now = () => Math.floor(Date.now() / 1000);
|
||||
const rnd = (n: number) => Math.floor(Math.random() * n);
|
||||
const b64 = (n: number) => crypto.randomBytes(n).toString("base64");
|
||||
const GEN = "3.1";
|
||||
const MOCK_VERSION = "0.3.3";
|
||||
|
||||
// ── пользователи (vpn://) ──
|
||||
interface U { name: string; ip: string; pub_key: string; vpn_key: string; key_gen: string; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
@@ -34,12 +35,13 @@ const usersOf: Record<number, U[]> = {
|
||||
};
|
||||
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
|
||||
const U_ = (q: express.Request) => usersOf[srv(q)];
|
||||
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
|
||||
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean; version: string | null }
|
||||
const mnodes: MNode[] = [
|
||||
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false },
|
||||
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
|
||||
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false, version: "0.3.2" }, // отстаёт от панели
|
||||
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true, version: null },
|
||||
{ id: 3, name: "Amsterdam", ip: "192.0.2.10", gen: "3.1", online: true, pending: false, version: null }, // старый агент: версию не шлёт
|
||||
];
|
||||
let nodeId = 2;
|
||||
let nodeId = 3;
|
||||
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
|
||||
|
||||
// ── API-ключи ──
|
||||
@@ -48,17 +50,17 @@ 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[]; deleting?: number[] }
|
||||
interface MK { id: number; uuid: string; 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 masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), 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, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, 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) && !m.deleting);
|
||||
|
||||
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: MOCK_VERSION }));
|
||||
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: "Неверный логин или пароль" }));
|
||||
app.post("/logout", (_q, r) => r.json({ ok: true }));
|
||||
app.get("/health", (q, r) => {
|
||||
@@ -68,15 +70,15 @@ app.get("/health", (q, r) => {
|
||||
|
||||
// ── серверы ──
|
||||
const nodeView = () => [
|
||||
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
|
||||
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null,
|
||||
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, version: MOCK_VERSION, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
|
||||
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, version: n.version,
|
||||
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
|
||||
];
|
||||
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
|
||||
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, version: MOCK_VERSION, nodes: nodeView() }));
|
||||
app.post("/ui/nodes", (q, r) => {
|
||||
const name = String(q.body.name ?? "");
|
||||
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
|
||||
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true });
|
||||
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true, version: null });
|
||||
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: "Не найден" }); });
|
||||
@@ -121,13 +123,17 @@ app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: m
|
||||
app.post("/ui/masterkeys", (q, r) => {
|
||||
const limit = q.body.device_limit ?? 3;
|
||||
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: [], servers: q.body.servers ?? [0] };
|
||||
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), 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));
|
||||
});
|
||||
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));
|
||||
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit;
|
||||
// Как sub.ts: лишние устройства сверх лимита — отвязать, начиная с подключившихся последними.
|
||||
const unbound = [...m.devs].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, Math.max(0, m.devs.length - m.device_limit)).map(d => d.id);
|
||||
m.devs = m.devs.filter(d => !unbound.includes(d.id));
|
||||
r.json({ ...mkOut(m), unbound });
|
||||
});
|
||||
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 }); });
|
||||
|
||||
+14
-7
@@ -25,7 +25,7 @@ export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<
|
||||
interface NodeRow {
|
||||
id: number; name: string; pub: string | null;
|
||||
join_hash: Buffer | null; join_expires: number | null;
|
||||
created_at: number; last_seen: number | null;
|
||||
created_at: number; last_seen: number | null; version: string | null;
|
||||
}
|
||||
|
||||
interface Health {
|
||||
@@ -46,11 +46,12 @@ const RPC_TIMEOUT = 15_000;
|
||||
const RPC_SLOW = 120_000; // /awg/*: рестарт и upgrade идут долго
|
||||
const NAME_RE = /^[^\r\n<>]{1,40}$/;
|
||||
const HOST_RE = /^[A-Za-z0-9.:_-]{1,253}$/;
|
||||
const VERSION_RE = /^[\w.+-]{1,32}$/;
|
||||
|
||||
// Сообщение, которое нода подписывает своим ключом. Дублируется в awg-agent.
|
||||
const authMsg = (nonce: string, nodeId: number) => Buffer.from(`awg-node-v1\n${nonce}\n${nodeId}`);
|
||||
|
||||
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string }) {
|
||||
export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; baseDir: string; version: string }) {
|
||||
const { uidb, ctrlLocal } = deps;
|
||||
|
||||
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
|
||||
@@ -94,6 +95,10 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
last_seen INTEGER
|
||||
);
|
||||
`);
|
||||
// Версия Forgetting на ноде — из кадра auth (агенты до 0.3.4 её не присылают). Колонка
|
||||
// добавлена позже, у существующей таблицы её может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(nodes)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE nodes ADD COLUMN version TEXT");
|
||||
const q = {
|
||||
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
|
||||
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
|
||||
@@ -101,7 +106,7 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
rename: uidb.prepare<[string, number]>("UPDATE nodes SET name = ? WHERE id = ?"),
|
||||
rejoin: uidb.prepare<[Buffer, number, number]>("UPDATE nodes SET join_hash = ?, join_expires = ? WHERE id = ? AND pub IS NULL"),
|
||||
bind: uidb.prepare<[string, number]>("UPDATE nodes SET pub = ?, join_hash = NULL, join_expires = NULL WHERE id = ?"),
|
||||
seen: uidb.prepare<[number, number]>("UPDATE nodes SET last_seen = ? WHERE id = ?"),
|
||||
seen: uidb.prepare<[number, string | null, number]>("UPDATE nodes SET last_seen = ?, version = ? WHERE id = ?"),
|
||||
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
|
||||
};
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
@@ -225,7 +230,9 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
|
||||
clearTimeout(authTimer);
|
||||
if (joining) q.bind.run(pubB64 as string, row.id);
|
||||
q.seen.run(now(), row.id);
|
||||
// Версия — только для показа в панели: не подписана, но и доверия не даёт.
|
||||
const version = typeof m.version === "string" && VERSION_RE.test(m.version) ? m.version : null;
|
||||
q.seen.run(now(), version, row.id);
|
||||
|
||||
const old = conns.get(row.id);
|
||||
conn = { ws, seq: 0, alive: true, pending: new Map() };
|
||||
@@ -303,15 +310,15 @@ export function createNodes(deps: { uidb: Database.Database; ctrlLocal: Ctrl; ba
|
||||
const nodes: unknown[] = [];
|
||||
if (LOCAL) {
|
||||
const h = health.get(0) ?? null;
|
||||
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, health: h });
|
||||
nodes.push({ id: 0, name: h?.server ?? "VPN", local: true, online: h !== null, pending: false, last_seen: null, version: deps.version || null, health: h });
|
||||
}
|
||||
for (const n of q.all.all() as NodeRow[]) {
|
||||
nodes.push({
|
||||
id: n.id, name: n.name, local: false, online: conns.has(n.id),
|
||||
pending: n.pub === null, last_seen: n.last_seen, health: health.get(n.id) ?? null,
|
||||
pending: n.pub === null, last_seen: n.last_seen, version: n.version ?? null, health: health.get(n.id) ?? null,
|
||||
});
|
||||
}
|
||||
res.json({ hub: hubReady(), local: LOCAL, nodes });
|
||||
res.json({ hub: hubReady(), local: LOCAL, version: deps.version || null, nodes });
|
||||
});
|
||||
|
||||
router.post("/", (req: Request, res: Response) => {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-ui",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -355,7 +355,7 @@ app.use("/api/v1", ext);
|
||||
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
||||
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
||||
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
|
||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
|
||||
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname, version: VERSION });
|
||||
app.use("/ui/nodes", requireAuth, nodes.router);
|
||||
|
||||
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
|
||||
|
||||
@@ -25,6 +25,8 @@ html, body, #root { height: 100%; }
|
||||
--neutral-bg: #e4dac9; --neutral-text: #7a6a55; --neutral-dot: #a99a85;
|
||||
--error-bg: #ffdad6; --error-text: #410002; --error-dot: #ba1a1a;
|
||||
--warn-dot: #e08a2c;
|
||||
--tertiary-container: #ffdcad; /* design.md: tertiary-container */
|
||||
--on-tertiary-container: #7a4a14;
|
||||
|
||||
--danger-border: #e6c4bc;
|
||||
--danger-text: #b34a3a;
|
||||
@@ -67,6 +69,8 @@ html, body, #root { height: 100%; }
|
||||
--neutral-bg: #34291c; --neutral-text: #d0c0a4; --neutral-dot: #b0a085;
|
||||
--error-bg: #5c1414; --error-text: #ffb4ab; --error-dot: #ff5449;
|
||||
--warn-dot: #ffb76b;
|
||||
--tertiary-container: #4a3218; /* тёплый тёмный, как у статусов в тёмной теме */
|
||||
--on-tertiary-container: #ffdcad;
|
||||
|
||||
--danger-border: #6e4540;
|
||||
--danger-text: #ffb4ab;
|
||||
@@ -449,6 +453,16 @@ body {
|
||||
line-height: 1.2;
|
||||
}
|
||||
.server-gen-wrap { align-self: flex-start; margin-top: 2px; }
|
||||
.server-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 2px; }
|
||||
.server-chips .server-gen-wrap, .server-chips .server-gen-chip { margin-top: 0; }
|
||||
.server-ver-chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
background: var(--neutral-bg);
|
||||
color: var(--neutral-text);
|
||||
}
|
||||
.server-ver-chip--warn { background: var(--tertiary-container); color: var(--on-tertiary-container); }
|
||||
.server-gen-chip {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function App() {
|
||||
const [statusText, setStatusText] = useState('');
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const [hubEnabled, setHubEnabled] = useState(false);
|
||||
const [panelVersion, setPanelVersion] = useState<string | null>(null);
|
||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
@@ -55,6 +56,7 @@ export default function App() {
|
||||
const list: NodeInfo[] = data.nodes ?? [];
|
||||
setNodes(list);
|
||||
setHubEnabled(!!data.hub);
|
||||
setPanelVersion(data.version ?? null);
|
||||
const cur = getServerId();
|
||||
if (cur !== null && list.some(n => n.id === cur)) return;
|
||||
let saved: number | null = null;
|
||||
@@ -311,6 +313,7 @@ export default function App() {
|
||||
nodes={nodes}
|
||||
serverId={serverId}
|
||||
hubEnabled={hubEnabled}
|
||||
panelVersion={panelVersion}
|
||||
onSelect={pickServer}
|
||||
onAdd={() => setModal({})}
|
||||
onJoin={n => setModal({ node: n })}
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
// Copyright (c) 2026 Ivan Vasilev
|
||||
// This source code is licensed under the MIT license found in the
|
||||
// LICENSE file in the root directory of this source tree.
|
||||
import { type NodeInfo, genLabel } from '../lib/shared';
|
||||
import { type NodeInfo, genLabel, cmpVersion } from '../lib/shared';
|
||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
||||
|
||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
||||
// Версия Forgetting на сервере. Нода, которая отстаёт от панели или не сообщает версию
|
||||
// (агент старше 0.3.4), подсвечена: её стоит обновить — install.sh, «Обновить».
|
||||
function versionChip(n: NodeInfo, panel: string | null) {
|
||||
if (n.pending) return null;
|
||||
let tip = '', warn = false;
|
||||
if (!n.version) {
|
||||
warn = true;
|
||||
tip = 'Версия неизвестна: нода старше 0.3.4 — обнови её (install.sh → «Обновить»)';
|
||||
} else if (panel && !n.local) {
|
||||
const d = cmpVersion(n.version, panel);
|
||||
if (d < 0) { warn = true; tip = `Панель на ${panel} — обнови ноду (install.sh → «Обновить»)`; }
|
||||
else if (d > 0) { warn = true; tip = `Нода новее панели (${panel}) — обнови панель`; }
|
||||
}
|
||||
const chip = (
|
||||
<span className={`chip server-ver-chip${warn ? ' server-ver-chip--warn' : ''}`}>
|
||||
{n.version ? `v${n.version}` : 'v?.?.?'}
|
||||
</span>
|
||||
);
|
||||
return tip ? <span className="tip-wrap server-gen-wrap" data-tip={tip}>{chip}</span> : chip;
|
||||
}
|
||||
|
||||
export default function ServerBar({
|
||||
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
|
||||
nodes, serverId, hubEnabled, panelVersion, onSelect, onAdd, onJoin, onDelete,
|
||||
onRestartAwg, restarting, showRestart = true,
|
||||
}: {
|
||||
nodes: NodeInfo[];
|
||||
panelVersion: string | null;
|
||||
serverId: number | null;
|
||||
hubEnabled: boolean;
|
||||
onSelect: (id: number) => void;
|
||||
@@ -52,9 +74,12 @@ export default function ServerBar({
|
||||
) : !n.online ? (
|
||||
<span className="server-card-ip">нет связи</span>
|
||||
) : h && (
|
||||
<>
|
||||
<span className="server-card-ip">{h.ip}</span>
|
||||
{(h.module || h.tools) ? (
|
||||
<span className="server-card-ip">{h.ip}</span>
|
||||
)}
|
||||
{/* Плашки: поколение AWG (только на связи) и версия Forgetting (известна и без связи) */}
|
||||
{!n.pending && (
|
||||
<span className="server-chips">
|
||||
{h && n.online && ((h.module || h.tools) ? (
|
||||
<span
|
||||
className="tip-wrap server-gen-wrap"
|
||||
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
||||
@@ -63,8 +88,9 @@ export default function ServerBar({
|
||||
</span>
|
||||
) : (
|
||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{versionChip(n, panelVersion)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
||||
|
||||
@@ -60,9 +60,20 @@ export interface NodeInfo {
|
||||
online: boolean;
|
||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
||||
last_seen: number | null;
|
||||
version: string | null; // версия Forgetting на сервере; null — нода ещё не подключалась или агент старше 0.3.4
|
||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
||||
}
|
||||
|
||||
// Сравнение версий вида 0.3.3 (хвост после «-» не учитывается): <0 — a старше b.
|
||||
export function cmpVersion(a: string, b: string): number {
|
||||
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||||
if (d) return d;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
||||
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
||||
// перечитывает данные уже с новым сервером.
|
||||
|
||||
@@ -12,7 +12,7 @@ import './masterkeys.css';
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface MasterKey { id: number; uuid: string; 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;
|
||||
@@ -103,6 +103,25 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
catch (e) { showMsg(errText(e)); }
|
||||
}, [fetchLink, showMsg]);
|
||||
|
||||
// Лимит ниже числа устройств: сервер сразу отвяжет лишних, начиная с подключившихся последними, —
|
||||
// поэтому сперва спросить и назвать, кого именно.
|
||||
const saveLimit = useCallback(async (k: MasterKey) => {
|
||||
const limit = Number(limitEdit);
|
||||
const list = devs[k.id] ?? [];
|
||||
const extra = list.length - limit;
|
||||
if (Number.isInteger(limit) && extra > 0) {
|
||||
const names = [...list].sort((a, b) => b.created_at - a.created_at || b.id - a.id).slice(0, extra)
|
||||
.map(d => `«${d.device_name || d.id}»`).join(', ');
|
||||
if (!confirm(`Лимит ${limit} меньше, чем устройств (${list.length}). Будут отвязаны подключившиеся последними: ${names}. Продолжить?`)) return;
|
||||
}
|
||||
try {
|
||||
const r = await apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: limit });
|
||||
const n: number = r.unbound?.length ?? 0;
|
||||
showMsg(n ? `Лимит сохранён, отвязано устройств: ${n}` : 'Лимит сохранён');
|
||||
await load();
|
||||
} catch (e) { showMsg(errText(e)); }
|
||||
}, [limitEdit, devs, token, load, showMsg]);
|
||||
|
||||
const fold = (id: number) => setFolded(prev => {
|
||||
const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n;
|
||||
});
|
||||
@@ -138,6 +157,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{/* Слева — настройки самого ключа */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Настройки ключа</h4>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">UUID</span>
|
||||
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
|
||||
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
||||
@@ -146,8 +178,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<div className="mk-setting-ctl">
|
||||
<input id={`mk-limit-${k.id}`} className="field mk-limit-input" type="number" min={1} max={100}
|
||||
value={limitEdit} onChange={e => setLimitEdit(e.target.value)} />
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
act(() => apiFetch('PATCH', `/ui/masterkeys/${k.id}`, token, { device_limit: Number(limitEdit) }), 'Лимит сохранён')}>
|
||||
<button className="btn btn--tonal" onClick={() => saveLimit(k)}>
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
@@ -221,6 +252,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
|
||||
// полностью — в подсказке, по клику копируется.
|
||||
const uuidCell = (k: MasterKey) => (
|
||||
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<code className="mk-device-id">{k.uuid}</code>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
const caret = (k: MasterKey) => (
|
||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||
@@ -285,7 +327,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
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>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={7}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
@@ -295,7 +338,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<Fragment key={k.id}>
|
||||
<tr className="mk-tr-master">
|
||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
||||
<td colSpan={6}></td>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={5}></td>
|
||||
<td>{meter(k, list.length)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||
</tr>
|
||||
@@ -339,7 +383,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -352,7 +396,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<div className="mk-master">
|
||||
{caret(k)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{meter(k, list.length)}
|
||||
{actions(k)}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,19 @@ LICENSE file in the root directory of this source tree.
|
||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
|
||||
.mk-uuid-btn {
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
|
||||
}
|
||||
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
|
||||
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
||||
.mk-master-uuid {
|
||||
display: block;
|
||||
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
||||
color: var(--neutral-text);
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mk-name .mk-caret { margin-left: -8px; }
|
||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||
@@ -111,6 +124,10 @@ LICENSE file in the root directory of this source tree.
|
||||
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.mk-uuid {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||
}
|
||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||
|
||||
|
||||
+42
-14
@@ -51,7 +51,7 @@ export interface Servers {
|
||||
}
|
||||
|
||||
interface MasterRow {
|
||||
id: number; label: string; secret: Buffer; device_limit: number;
|
||||
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
|
||||
server_id: number; created_at: number; revoked_at: number | null;
|
||||
}
|
||||
interface DeviceRow {
|
||||
@@ -195,6 +195,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// таблицы колонки может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||
// uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки,
|
||||
// ни при правке метки. Существующим ключам выдаётся здесь один раз.
|
||||
if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid"))
|
||||
uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT");
|
||||
{
|
||||
const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?");
|
||||
for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[])
|
||||
setUuid.run(crypto.randomUUID(), id);
|
||||
}
|
||||
uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)");
|
||||
uidb.pragma("foreign_keys = ON");
|
||||
|
||||
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
|
||||
@@ -222,7 +232,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
||||
masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"),
|
||||
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
|
||||
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
masterInsert: uidb.prepare<[string, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||
masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"),
|
||||
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 = ?"),
|
||||
masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
|
||||
@@ -577,7 +588,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||
// без отдельного запроса списка.
|
||||
send(req, res, 201, {
|
||||
device: dev.id, config: cfg,
|
||||
device: dev.id, config: cfg, key_uuid: master.uuid,
|
||||
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
|
||||
});
|
||||
}));
|
||||
@@ -594,7 +605,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
return;
|
||||
}
|
||||
send(req, res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
send(req, res, 200, { key_uuid: master.uuid, 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) => {
|
||||
@@ -614,6 +625,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
send(req, res, 200, {
|
||||
name: master.label,
|
||||
key_uuid: master.uuid,
|
||||
device_limit: master.device_limit,
|
||||
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
|
||||
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
||||
@@ -674,6 +686,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
});
|
||||
};
|
||||
const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся
|
||||
// Мастер-ключ в пути — числовой id или uuid.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const masterIdOf = (v: string) => UUID_RE.test(v)
|
||||
? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1
|
||||
: idOf(v);
|
||||
const LABEL = /^[^\r\n<>]{1,40}$/;
|
||||
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
|
||||
|
||||
@@ -707,7 +724,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
function masterOut(m: MasterRow & { devices: number }) {
|
||||
return {
|
||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
||||
id: m.id, uuid: m.uuid, 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) } : {}),
|
||||
};
|
||||
@@ -728,7 +745,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
|
||||
let id = 0;
|
||||
uidb.transaction(() => {
|
||||
id = Number(q.masterInsert.run(label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
|
||||
id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
|
||||
for (const sid of ids) q.serverAdd.run(id, sid);
|
||||
})();
|
||||
return masterOutById(id);
|
||||
@@ -764,8 +781,19 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
ids = next;
|
||||
}
|
||||
q.masterUpdate.run(nl as string, nd as number, m.id);
|
||||
// Лимит стал меньше, чем устройств: лишние отвязываются, начиная с подключившихся последними, —
|
||||
// первые, кто занял места, их сохраняют. Как отзыв в панели: пиры снимаются, устройство получит 410.
|
||||
const unbound: number[] = [];
|
||||
const devices = q.devicesOf.all(m.id) as DeviceRow[];
|
||||
if (devices.length > (nd as number)) {
|
||||
const newest = [...devices].sort((a, b) => b.created_at - a.created_at || b.id - a.id);
|
||||
for (const d of newest.slice(0, devices.length - (nd as number))) {
|
||||
affected.push(...forgetDevice(d));
|
||||
unbound.push(d.id);
|
||||
}
|
||||
}
|
||||
await reconcileAll(affected);
|
||||
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids };
|
||||
return { id: m.id, label: nl as string, device_limit: nd as number, servers: ids, unbound };
|
||||
}
|
||||
|
||||
// Новая ссылка; устройства продолжают работать: у них auth_pub, а не secret.
|
||||
@@ -849,12 +877,12 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
|
||||
});
|
||||
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
|
||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(idOf(req.params.id), null), req.body, null)); }));
|
||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(idOf(req.params.id), null))); }));
|
||||
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(idOf(req.params.id), null), req)); }));
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(idOf(req.params.id), null)) }); }));
|
||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
|
||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); }));
|
||||
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); }));
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(req.params.id), null)) }); }));
|
||||
|
||||
const devices = Router();
|
||||
devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); }));
|
||||
@@ -866,7 +894,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// область проверялась через их мастер-ключ.
|
||||
function apiRouter(scopeOf: (req: Request) => number[]) {
|
||||
const r = Router();
|
||||
const master = (req: Request) => masterFor(idOf(req.params.id), scopeOf(req));
|
||||
const master = (req: Request) => masterFor(masterIdOf(req.params.id), scopeOf(req));
|
||||
const device = (req: Request) => deviceFor(idOf(req.params.device), master(req));
|
||||
|
||||
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "awg-cli",
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"tsx": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ trap 'exec >&- 2>&-; wait "$LOG_PID" 2>/dev/null || true' EXIT
|
||||
# ⚠️ Дублирует .env из репозитория: установщик собирает URL релиза и печатает
|
||||
# баннер ДО того, как архив с .env скачан, поэтому взять их оттуда не может.
|
||||
# При бампе версии правь оба места.
|
||||
VERSION="0.3.3"
|
||||
VERSION="0.3.4"
|
||||
CHANNEL="${CHANNEL:-Beta}"
|
||||
|
||||
# Откуда брать архив и как называется продукт — задаётся окружением, в коде
|
||||
|
||||
Reference in New Issue
Block a user