12 Commits
Author SHA1 Message Date
maeneko 3eede2f015 Bump to Beta 0.3.2
release / release (push) Successful in 3m35s
All seven places the version is written down: .env, the copy in
install.sh, the four package.json files and the /ui/brand example in the
README.

A master key can serve its devices from several servers; a server that
joins a core as a node keeps its own panel.
2026-09-26 21:44:25 +03:00
maeneko 2054122110 Serve a master key's devices from several servers
A master key now has a set of servers (the panel's own and/or nodes). A
device's single WireGuard key becomes a peer on every server of its key,
each with its own IP and PSK, and config.servers[] lists them all.

- ui.db: master_servers (the key's set, never empty), device_peers (which
  key is on which server, with the ip/psk that server issued),
  peer_tombstones (peers to delete) and server_profiles (last known
  profile of each server).
- Nothing is pushed blindly: reconcile() brings one server to the desired
  state - deletes tombstones, adds peers, rekeys with PUT, fills ip/psk,
  rotates a pending PSK - one pass per server at a time. It runs right
  after every action, when a node connects and once a minute, so a node
  that was offline catches up on rekeys, PSK changes and removals.
- config is built from ui.db, so an offline node stays in it and rev does
  not flap; a server is listed once its peer carries the device's current
  key. An empty config is never sent (502 instead); register and rekey
  roll back if no server accepted the key.
- Existing keys and devices migrate on start. With one server the config
  and its rev are byte-identical to 0.3.1 (checked by running both
  versions on the same ui.db), so existing clients notice nothing.
- Panel: server checkboxes in the key settings instead of the placeholder
  (the last one cannot be removed), "на N из M серверов" on devices that
  have not reached every server yet, device stats summed over servers.
- SUB_HOST: the panel's public address for sen:// links and endpoints;
  install.sh writes it. Without it a panel with no VPN of its own used
  the address it was opened at.
- A node referenced by a key's server set cannot be deleted.
2026-09-26 21:43:58 +03:00
maeneko 129f29d6be Keep the node's own panel when it joins a core
A server that already works as panel + VPN can now also be a node of
another panel without losing its own: `awg-ctrl join` keeps awg-ui running
next to the agent and writes NODE_PANEL=on. `--no-panel` stops it as
before. Both panels talk to the same awg-ctrl, which stays the source of
truth, so they do not get in each other's way.

The CLI runs awg-ctrl, awg-ui and awg-agent on such a node, all behind the
awg-ctrl health gate; a node installed by install.sh has no UI_PASS and
still runs only awg-ctrl + agent. unjoin also drops NODE_PANEL.
2026-09-26 21:43:45 +03:00
maeneko 2f2f0e268a Bump to Beta 0.3.1
release / release (push) Successful in 3m18s
Covers all seven places the version is written down: .env, the copy
install.sh keeps because it prints its banner and builds the release URL
before the archive with .env exists, the four package.json files (awg-agent
is new), and the /ui/brand example in the README.

Multiple servers under one panel (core + nodes), the installer's role
question and its full log.
2026-09-26 20:19:50 +03:00
maeneko 2e773deba2 Fix the installer's generated password, keep going if the log dies
Found by running install.sh end to end in a sandbox for all three roles.

- The suggested UI password and JWT_SECRET came from
  `tr < /dev/urandom | head -c N || openssl rand`. Under pipefail tr's
  SIGPIPE made the pipeline fail every time, so openssl ran too and the two
  outputs were glued together (a 24-character password). rand_alnum() reads
  a finite 1 KiB instead.
- The log filter runs sed with LC_ALL=C, so broken UTF-8 in someone else's
  output (make.log, dmesg) cannot stop it, and tee -p keeps writing to the
  screen if the log writer dies instead of taking the installer down with a
  silent SIGPIPE.
- Output: labels before awg-quick up and apt-mark hold, no double blank
  lines, "маршрут" only where there is a VPN, a hint when the agent log is
  still empty.
2026-09-26 20:19:44 +03:00
maeneko aa31b7b8a2 Ask for the server role in the installer, log everything
- install.sh asks the role first: panel + VPN (as before), panel only
  (skips AmneziaWG, the kernel module and the headers/virt checks) or node
  (no panel; takes the awgjoin:// string and checks the panel's cert pin
  with openssl before building anything). ROLE= / JOIN= skip the questions;
  an update keeps the previous role.
- The previous cli.env is read before a reinstall removes the project
  directory. Before this, keeping data silently lost SUB_PORT / SUB_TLS
  and broke every issued sen:// link.
- Output: a header, numbered steps, menus, a summary before installing and
  a final block per role. The full output of apt, DKMS, npm, awg-quick,
  ufw and systemctl is shown as a dimmed block under its step, including
  what used to go to /dev/null.
- The log starts with the first line: checks, questions with answers
  (password masked), all command output. It is written without colour
  codes, mode 600.
2026-09-26 19:48:34 +03:00
maeneko 789538756a Manage several servers from one panel: core + nodes
A node is a VPN server without a panel. awg-agent on it dials the panel
(core) over an outbound WSS connection and runs the panel's requests on its
own awg-ctrl, which stays on 127.0.0.1 and is unchanged. No inbound port on
the node, works behind NAT.

- awg-ui/nodes.ts: node registry in ui.db, WSS hub on NODE_PORT with a
  self-signed cert whose SPKI pin goes into the join string, RPC with
  timeouts, health polling, /ui/nodes routes.
- Nodes enroll with a one-time awgjoin:// string (secret stored hashed,
  1 hour) and then authenticate by their own Ed25519 key. The agent checks
  the pin before sending anything and only forwards /health, /api/*, /awg/*.
- server_id 0 is the panel's own awg-ctrl; LOCAL_NODE=off makes the panel
  run without a VPN of its own. The panel picks a server with X-Server-Id,
  API keys use their server_id. sen:// still serves one server.
- UI: a card per server in the server bar, switching remounts the tab,
  «Добавить сервер» gives the join command.
- CLI: agent service, roles from cli.env, join / unjoin commands.
- Release archives include awg-agent.
2026-09-26 19:48:33 +03:00
maeneko 1278ad527d Add /sub/v1/devices and /sub/v1/peek, count the slots on register
release / release (push) Successful in 3m14s
- GET /sub/v1/devices: a device lists the devices of its own master key
  (name, platform, version, last seen, and which one is asking). Read-only:
  unbinding is still only DELETE /sub/v1/device for oneself.
- POST /sub/v1/peek: how many slots of a key are taken, asked with the
  link's secret before any device exists, so the client can show it while
  the link is only pasted. Wrong secrets count against the failure limit.
- register also answers with devices / device_limit, so the client needs no
  second request to show the slot it just took.
2026-09-25 17:24:52 +03:00
maeneko 6e010a0141 Bump to Beta 0.3.0
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

First release with the sen:// subscription and master keys.
2026-09-25 17:24:52 +03:00
maeneko 923bd87fe9 Add sen:// subscription with master keys
A master key is a sen:// link with a device limit. Each device
generates its own WireGuard keypair and registers only the public key,
then polls a signed /sub/v1/config, so params and generation changes
reach users without re-importing a key.

- awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and
  rebuildConf take peers into account (output unchanged without them)
- awg-ui: link codec and signatures (sen.ts), registry, panel routes
  and the public listener on SUB_PORT (sub.ts)
- responses are always Ed25519-signed; TLS with a pinned self-signed
  certificate is optional
- devices report a client version, shown as platform-version
- "Мастер-ключи" tab: a table with the devices nested under each key,
  a device-limit meter, per-key settings and a placeholder for the
  servers a key serves; cards on mobile
- install.sh/CLI: subscription signing key, optional certificate, port;
  kept across reinstalls with KEEP_DATA
- add the missing mock-server.ts used by npm run dev:mock
2026-09-25 17:24:52 +03:00
maeneko d5e00ddaf8 Bump to Beta 0.2.2
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.
2026-09-10 17:51:19 +03:00
maeneko 2b46d04af9 Support Debian in the installer
install.sh only knew Ubuntu. On Debian 13 the dependency step died on
software-properties-common, which trixie no longer ships, and on
linux-headers-generic, which Debian never had — so add-apt-repository and the
Ubuntu headers meta package are both off the table there.

The distro family now comes from /etc/os-release (derivatives via ID_LIKE) and
shows up as its own line in the compatibility checklist. On Debian the amnezia
PPA is wired by hand: the key is fetched from keyserver.ubuntu.com by its full
fingerprint and checked after download, then a signed-by source is written on
the Ubuntu series whose glibc is not newer than the Debian release's
(bullseye→focal, bookworm→jammy, trixie→noble; AMNEZIA_PPA_SUITE overrides).

Headers are installed as a separate step so a missing package gets precise
advice instead of a generic failure. Debian's archive keeps only the latest
kernel build, so a server that hasn't rebooted in a while has no headers for
its running kernel; the hint names linux-image/linux-headers for the kernel's
own flavour (cloud-amd64 and so on) plus a reboot.

iptables joins the dependencies — PostUp calls it, and a minimal Debian 13 on
nftables doesn't have it. IP forwarding moves to its own file in
/etc/sysctl.d: Debian 13 no longer reads /etc/sysctl.conf at boot, so the old
line would have held only until the first reboot.
2026-09-10 17:51:19 +03:00
14 changed files with 483 additions and 137 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting BRAND=Forgetting
CHANNEL=Beta CHANNEL=Beta
VERSION=0.3.1 VERSION=0.3.2
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+51 -24
View File
@@ -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>
+5 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}