7 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
24 changed files with 2422 additions and 736 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.0
VERSION=0.3.2
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release
+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.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `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 /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+170
View File
@@ -0,0 +1,170 @@
// 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.
//
// Агент ноды. Сам открывает исходящее WSS-соединение к core (awg-ui), доказывает
// свою личность подписью Ed25519 и исполняет присланные запросы на локальном
// awg-ctrl (127.0.0.1). Входящих портов у ноды нет, awg-ctrl остаётся закрытым от сети.
// Протокол и формат join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import path from "path";
import type { TLSSocket } from "tls";
import axios from "axios";
import WebSocket from "ws";
const CORE_HOST = process.env.CORE_HOST ?? "";
const CORE_PORT = Number(process.env.CORE_PORT) || 0;
const CORE_PIN = process.env.CORE_PIN ?? ""; // SHA-256 SPKI сертификата core, base64url
const NODE_ID = Number(process.env.NODE_ID) || 0;
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
const NODE_KEY_FILE = process.env.NODE_KEY_FILE ?? path.join(__dirname, "node.key");
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnezia/amneziawg/internal_auth_private.key";
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
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);
}
// ── Ключи ──────────────────────────────────────────────────────────────────
// Личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
function loadNodeKey(): crypto.KeyObject {
if (!fs.existsSync(NODE_KEY_FILE)) {
const { privateKey } = crypto.generateKeyPairSync("ed25519");
fs.writeFileSync(NODE_KEY_FILE, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
console.log("agent: создан ключ ноды " + NODE_KEY_FILE);
}
return crypto.createPrivateKey(fs.readFileSync(NODE_KEY_FILE));
}
const nodeKey = loadNodeKey();
const nodePubRaw = (() => {
const der = crypto.createPublicKey(nodeKey).export({ type: "spki", format: "der" }) as Buffer;
return der.subarray(der.length - 32).toString("base64url");
})();
let internalKey: crypto.KeyObject | null = null;
try { internalKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); }
catch { console.warn("agent: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE); }
// Тот же короткоживущий токен, что awg-ui шлёт своему awg-ctrl.
function mintInternalToken(): string {
if (!internalKey) throw new Error("no internal key");
const t = Math.floor(Date.now() / 1000);
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "awg-agent", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), internalKey).toString("base64url")}`;
}
// Дублируется в awg-ui/nodes.ts.
const authMsg = (nonce: string) => Buffer.from(`awg-node-v1\n${nonce}\n${NODE_ID}`);
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
const METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);
// Агент — не открытый прокси: только API самого awg-ctrl.
function pathAllowed(p: unknown): p is string {
if (typeof p !== "string" || p.length > 2048 || !/^\/[\x21-\x7e]*$/.test(p)) return false;
try {
const u = new URL(p, "http://x");
if (u.origin !== "http://x" || u.pathname.split("/").some(s => s === ".." || s === ".")) return false;
return u.pathname === "/health" || u.pathname.startsWith("/api/") || u.pathname.startsWith("/awg/");
} catch { return false; }
}
async function execute(m: { method?: unknown; path?: unknown; body?: unknown }): Promise<{ status: number; data: unknown }> {
const method = typeof m.method === "string" ? m.method.toUpperCase() : "";
if (!METHODS.has(method) || !pathAllowed(m.path)) return { status: 400, data: { error: "Запрос не разрешён агентом" } };
try {
const r = await axios({
method, url: CTRL + m.path,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: m.body, validateStatus: () => true, timeout: 110_000,
});
return { status: r.status, data: r.data };
} catch {
return { status: 502, data: { error: "awg-ctrl недоступен" } };
}
}
// ── Соединение с core ──────────────────────────────────────────────────────
function pinOf(sock: TLSSocket): string | null {
const raw = sock.getPeerCertificate(true)?.raw;
if (!raw) return null;
const spki = new crypto.X509Certificate(raw).publicKey.export({ type: "spki", format: "der" }) as Buffer;
return crypto.createHash("sha256").update(spki).digest("base64url");
}
// После первого успешного входа секрет больше не нужен — убираем его с диска.
function forgetJoinSecret() {
joinSecret = "";
delete process.env.JOIN_SECRET;
if (!CLI_ENV_FILE) return;
try {
const kept = fs.readFileSync(CLI_ENV_FILE, "utf8").split("\n").filter(l => !l.startsWith("JOIN_SECRET="));
fs.writeFileSync(CLI_ENV_FILE, kept.join("\n"));
} catch { /* не критично: секрет одноразовый и с истекающим сроком */ }
}
let backoff = 1000;
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
function connectOnce(): Promise<{ authFailed: boolean }> {
return new Promise(resolve => {
// Сертификат самоподписанный — цепочку не проверяем, доверие даёт pin (см. 'upgrade').
const ws = new WebSocket(`wss://${CORE_HOST}:${CORE_PORT}/node/v1`, {
rejectUnauthorized: false, handshakeTimeout: 10_000, maxPayload: 4 * 1024 * 1024,
});
let pinned = false, ready = false, deadTimer: NodeJS.Timeout | undefined;
const armDead = () => { clearTimeout(deadTimer); deadTimer = setTimeout(() => ws.terminate(), 60_000); };
// До этого момента ничего не отправляем и не читаем: секрет и подпись уходят только
// тому, чей сертификат совпал с pin из join-строки.
ws.on("upgrade", res => {
const got = pinOf(res.socket as TLSSocket);
if (got === CORE_PIN) { pinned = true; return; }
console.error("agent: сертификат core не совпал с pin — соединение отклонено");
ws.terminate();
});
ws.on("ping", armDead);
ws.on("message", async raw => {
if (!pinned) return;
let m: any;
try { m = JSON.parse(raw.toString()); } catch { ws.terminate(); return; }
if (m.t === "hello" && typeof m.nonce === "string") {
const auth: Record<string, unknown> = {
t: "auth", node_id: NODE_ID,
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
};
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
ws.send(JSON.stringify(auth));
} else if (m.t === "ready") {
ready = true; backoff = 1000; armDead();
if (joinSecret) { forgetJoinSecret(); console.log("agent: нода привязана к core"); }
console.log("agent: подключено к core");
} else if (m.t === "req" && ready && Number.isInteger(m.id)) {
const r = await execute(m);
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: "res", id: m.id, ...r }));
}
});
ws.on("error", e => { if (!pinned || !ready) console.error("agent: " + (e as Error).message); });
ws.on("close", code => {
clearTimeout(deadTimer);
if (code === 4003) console.error("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)");
resolve({ authFailed: code === 4003 });
});
});
}
async function main() {
console.log(`agent: нода #${NODE_ID} → wss://${CORE_HOST}:${CORE_PORT}`);
for (;;) {
const { authFailed } = await connectOnce();
const wait = authFailed ? 60_000 : backoff;
backoff = Math.min(backoff * 2, 60_000);
await sleep(wait);
}
}
main();
+19
View File
@@ -0,0 +1,19 @@
{
"name": "awg-agent",
"version": "0.3.2",
"private": true,
"description": "Агент ноды: держит исходящее WSS-соединение к core и исполняет запросы на локальном awg-ctrl",
"scripts": {
"check": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.5.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["index.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.0",
"version": "0.3.2",
"description": "",
"main": "dist/index.js",
"scripts": {
+60 -14
View File
@@ -23,39 +23,80 @@ const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
}));
// ── серверы: core (id 0), нода в сети и нода, ещё не подключавшаяся ──
// Пользователи хранятся по серверам; X-Server-Id выбирает набор, как в настоящей панели.
const usersOf: Record<number, U[]> = {
0: users,
1: ["dave", "erin"].map((name, i) => ({
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
key_gen: "2", online: i === 0, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
})),
};
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 }
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 },
];
let nodeId = 2;
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-ключи ──
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
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[] }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: 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")] }];
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, 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));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } }));
app.get("/health", (q, r) => {
const id = srv(q), n = mnodes.find(x => x.id === id);
r.json({ status: "ok", server: n?.name ?? "VPN (mock)", ip: n?.ip ?? "203.0.113.7", gen: n?.gen ?? GEN, awg: { status: "ok", peers: usersOf[id].length, module: "3.0.1", tools: "1.0.0" } });
});
// ── серверы ──
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,
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.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 });
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
});
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); });
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
app.get("/api/users", (_q, r) => r.json({ users }));
app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" }));
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => {
const users = U_(q);
const name = String(q.body.name ?? "");
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
users.push(u); r.status(201).json(u);
});
app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
@@ -65,10 +106,14 @@ 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: [] };
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));
});
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/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 }); });
@@ -76,9 +121,10 @@ app.get("/ui/masterkeys/:id/link", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
r.json({ link: encodeSenLink({ tls: false, addrs: [{ host: "203.0.113.7", port: 41234 }], secret: m.secret, signPub, name: m.label }), tls: false });
});
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.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 }));
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
+363
View File
@@ -0,0 +1,363 @@
// 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.
//
// Несколько серверов под одной панелью: core (эта awg-ui) и nodes — VPN-серверы,
// которые сами открывают к core исходящее WSS-соединение (awg-agent). Реестр нод
// лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос
// на своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины (как было до нод); при
// LOCAL_NODE=off его нет, core работает как чистая панель. Протокол и формат
// join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import https from "https";
import path from "path";
import { spawnSync } from "child_process";
import { Router, type Request, type Response } from "express";
import type Database from "better-sqlite3";
import { WebSocketServer, WebSocket } from "ws";
import { spkiPin, publicKeyFromRaw } from "./sen";
export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
interface NodeRow {
id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null;
}
interface Health {
server: string; ip: string; gen: string; peers: number;
awg_up: boolean; module: string; tools: string;
}
interface Conn {
ws: WebSocket; seq: number; alive: boolean;
pending: Map<number, { resolve: (r: { status: number; data: any }) => void; timer: NodeJS.Timeout }>;
}
const JOIN_TTL = 3600; // секунд жизни одноразового секрета
const AUTH_TIMEOUT = 10_000; // на challenge–response после открытия сокета
const HEALTH_EVERY = 15_000;
const PING_EVERY = 20_000;
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}$/;
// Сообщение, которое нода подписывает своим ключом. Дублируется в 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 }) {
const { uidb, ctrlLocal } = deps;
const NODE_PORT = Number(process.env.NODE_PORT) || 0;
const LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() !== "off";
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
// ── TLS: самоподписанный сертификат, pin которого зашит в join-строки ────
// Живёт столько же, сколько core: пересоздать = переподключить все ноды.
let tls: { key: Buffer; cert: Buffer } | null = null;
let pin: Buffer | null = null;
if (NODE_PORT) {
const keyF = file(process.env.NODE_TLS_KEY_FILE, "node_tls.key");
const crtF = file(process.env.NODE_TLS_CERT_FILE, "node_tls.crt");
try {
if (!fs.existsSync(keyF) && !fs.existsSync(crtF)) {
const r = spawnSync("openssl", [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", keyF, "-out", crtF, "-days", "3650", "-subj", "/CN=awg-node",
], { stdio: "ignore" });
if (r.status !== 0) throw new Error("openssl");
fs.chmodSync(keyF, 0o600); fs.chmodSync(crtF, 0o600);
console.log("nodes: создан самоподписанный сертификат " + crtF);
}
tls = { key: fs.readFileSync(keyF), cert: fs.readFileSync(crtF) };
pin = spkiPin(tls.cert.toString());
} catch {
console.warn("nodes: сертификат хаба недоступен (" + crtF + ") — приём нод отключён");
tls = null;
}
}
// ── Схема ──────────────────────────────────────────────────────────────
uidb.exec(`
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
pub TEXT,
join_hash BLOB,
join_expires INTEGER,
created_at INTEGER NOT NULL,
last_seen INTEGER
);
`);
const q = {
all: uidb.prepare("SELECT * FROM nodes ORDER BY id"),
byId: uidb.prepare<[number]>("SELECT * FROM nodes WHERE id = ?"),
insert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO nodes (name, join_hash, join_expires, created_at) VALUES (?, ?, ?, ?)"),
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 = ?"),
delete: uidb.prepare<[number]>("DELETE FROM nodes WHERE id = ?"),
};
const now = () => Math.floor(Date.now() / 1000);
const sha = (b: Buffer) => crypto.createHash("sha256").update(b).digest();
// ── RPC к нодам ────────────────────────────────────────────────────────
const conns = new Map<number, Conn>();
const health = new Map<number, Health>();
const OFFLINE = { status: 502, data: { error: "Сервер недоступен" } };
function rpc(id: number, method: string, urlPath: string, body?: unknown): Promise<{ status: number; data: any }> {
const c = conns.get(id);
if (!c || c.ws.readyState !== WebSocket.OPEN) return Promise.resolve(OFFLINE);
return new Promise(resolve => {
const rid = ++c.seq;
const timer = setTimeout(() => {
c.pending.delete(rid);
resolve({ status: 504, data: { error: "Сервер не ответил" } });
}, urlPath.startsWith("/awg/") ? RPC_SLOW : RPC_TIMEOUT);
c.pending.set(rid, { resolve, timer });
c.ws.send(JSON.stringify({ t: "req", id: rid, method, path: urlPath, body }));
});
}
/** Контроллер нужного сервера. 0 — локальный awg-ctrl, остальное — нода по WSS. */
function ctrlFor(serverId: number): Ctrl {
if (serverId === 0) {
if (!LOCAL) return async () => ({ status: 404, data: { error: "Локальный сервер не настроен" } });
return ctrlLocal;
}
if (!q.byId.get(serverId)) return async () => ({ status: 404, data: { error: "Сервер не найден" } });
return (m, p, b) => rpc(serverId, m, p, b);
}
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 {
if (LOCAL) return 0;
return (q.all.all() as NodeRow[])[0]?.id ?? 0;
}
async function refreshHealth(id: number) {
const r = await (id === 0 ? ctrlLocal("GET", "/health") : rpc(id, "GET", "/health")).catch(() => null);
const d = r?.data;
if (d && typeof d === "object" && typeof d.server === "string") {
health.set(id, {
server: d.server, ip: d.ip ?? "", gen: d.gen ?? "2", peers: d.awg?.peers ?? 0,
awg_up: d.awg?.status === "ok", module: d.awg?.module ?? "", tools: d.awg?.tools ?? "",
});
} else health.delete(id);
}
// ── Хаб: приём соединений от нод ───────────────────────────────────────
const failHits = new Map<string, { n: number; reset: number }>();
const failed = (ip: string) => {
const t = Date.now(), e = failHits.get(ip);
if (!e || e.reset < t) failHits.set(ip, { n: 1, reset: t + 15 * 60_000 });
else e.n++;
};
const blocked = (ip: string) => {
const e = failHits.get(ip);
return !!e && e.reset > Date.now() && e.n >= 20;
};
function dropConn(id: number, c: Conn) {
for (const p of c.pending.values()) { clearTimeout(p.timer); p.resolve(OFFLINE); }
c.pending.clear();
if (conns.get(id) === c) { conns.delete(id); health.delete(id); }
}
function onConnection(ws: WebSocket, req: { socket: { remoteAddress?: string } }) {
const ip = req.socket.remoteAddress ?? "unknown";
if (blocked(ip)) { ws.close(4029, "rate_limited"); return; }
const nonce = crypto.randomBytes(16).toString("base64url");
let conn: Conn | null = null;
let nodeId = 0;
const authTimer = setTimeout(() => ws.close(4001, "auth_timeout"), AUTH_TIMEOUT);
const deny = () => { failed(ip); ws.close(4003, "unauthorized"); };
function authenticate(m: any) {
if (m?.t !== "auth" || !Number.isInteger(m.node_id) || typeof m.sig !== "string") return deny();
const row = q.byId.get(m.node_id) as NodeRow | undefined;
if (!row) return deny();
let pubB64: string | null = row.pub;
const joining = pubB64 === null;
if (joining) {
const j = m.join;
if (!row.join_hash || !row.join_expires || row.join_expires < now()
|| typeof j?.secret !== "string" || typeof j?.pub !== "string") return deny();
const h = sha(Buffer.from(j.secret, "base64url"));
if (h.length !== row.join_hash.length || !crypto.timingSafeEqual(h, row.join_hash)) return deny();
pubB64 = j.pub;
}
try {
const key = publicKeyFromRaw(Buffer.from(pubB64 as string, "base64url"));
if (!crypto.verify(null, authMsg(nonce, row.id), key, Buffer.from(m.sig, "base64url"))) return deny();
} catch { return deny(); }
clearTimeout(authTimer);
if (joining) q.bind.run(pubB64 as string, row.id);
q.seen.run(now(), row.id);
const old = conns.get(row.id);
conn = { ws, seq: 0, alive: true, pending: new Map() };
conns.set(row.id, conn);
nodeId = row.id;
if (old) { dropConn(row.id, old); conns.set(row.id, conn); old.ws.close(4000, "replaced"); }
ws.send(JSON.stringify({ t: "ready" }));
console.log(`nodes: «${row.name}» (#${row.id}) подключена${joining ? " (первое подключение)" : ""}`);
void refreshHealth(row.id);
for (const cb of onlineHandlers) { try { cb(row.id); } catch { /* подписчик сам разберётся */ } }
}
ws.on("message", raw => {
let m: any;
try { m = JSON.parse(raw.toString()); } catch { ws.close(4002, "bad_frame"); return; }
if (!conn) { authenticate(m); return; }
if (m?.t === "res" && Number.isInteger(m.id)) {
const p = conn.pending.get(m.id);
if (!p) return;
conn.pending.delete(m.id);
clearTimeout(p.timer);
p.resolve({ status: Number(m.status) || 502, data: m.data });
}
});
ws.on("pong", () => { if (conn) conn.alive = true; });
ws.on("close", () => {
clearTimeout(authTimer);
if (conn) { dropConn(nodeId, conn); console.log(`nodes: #${nodeId} отключена`); }
});
ws.on("error", () => { /* close придёт следом */ });
ws.send(JSON.stringify({ t: "hello", nonce }));
}
function start() {
if (LOCAL) {
void refreshHealth(0);
for (const cb of onlineHandlers) { try { cb(0); } catch { /* подписчик сам разберётся */ } }
}
if (!NODE_PORT || !tls) return;
const server = https.createServer(tls);
const wss = new WebSocketServer({ server, path: "/node/v1", maxPayload: 4 * 1024 * 1024 });
wss.on("connection", onConnection);
server.listen(NODE_PORT, () => console.log(`nodes: hub on :${NODE_PORT} (wss)`));
setInterval(() => {
for (const [id, c] of conns) {
if (!c.alive) { c.ws.terminate(); dropConn(id, c); continue; }
c.alive = false;
try { c.ws.ping(); } catch { /* закроется само */ }
}
}, PING_EVERY).unref();
setInterval(() => { for (const id of conns.keys()) void refreshHealth(id); }, HEALTH_EVERY).unref();
}
// ── Роуты панели (монтируются в server.ts за JWT) ──────────────────────
const joinString = (host: string, id: number, secret: Buffer) => "awgjoin://" + Buffer.from(JSON.stringify({
v: 1, h: host, p: NODE_PORT, pin: (pin as Buffer).toString("base64url"), n: id, s: secret.toString("base64url"),
})).toString("base64url");
const hostOf = (req: Request): string => {
const given = (req.body as { host?: unknown } | undefined)?.host;
if (typeof given === "string" && HOST_RE.test(given)) return given;
return (req.headers.host ?? "").replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
};
const idOf = (req: Request) => Number(req.params.id);
const validId = (n: number) => Number.isInteger(n) && n >= 1;
const hubReady = () => NODE_PORT > 0 && tls !== null && pin !== null;
const router = Router();
router.get("/", async (_req: Request, res: Response) => {
if (LOCAL) await refreshHealth(0);
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 });
}
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,
});
}
res.json({ hub: hubReady(), local: LOCAL, nodes });
});
router.post("/", (req: Request, res: Response) => {
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
const name = (req.body as { name?: unknown } | undefined)?.name;
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); return; }
const host = hostOf(req);
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
const secret = crypto.randomBytes(16);
const id = Number(q.insert.run(name, sha(secret), now() + JOIN_TTL, now()).lastInsertRowid);
res.status(201).json({ id, name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
});
// Новая join-строка для ноды, которая ещё не подключалась (истёк срок / потеряли).
router.post("/:id/join", (req: Request, res: Response) => {
if (!hubReady()) { res.status(503).json({ error: "Приём нод не настроен (NODE_PORT / сертификат)" }); return; }
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
if (n.pub !== null) { res.status(409).json({ error: "Нода уже подключена" }); return; }
const host = hostOf(req);
if (!HOST_RE.test(host)) { res.status(400).json({ error: "Не удалось определить адрес core" }); return; }
const secret = crypto.randomBytes(16);
q.rejoin.run(sha(secret), now() + JOIN_TTL, id);
res.json({ id, name: n.name, join: joinString(host, id, secret), expires_in: JOIN_TTL });
});
router.patch("/:id", (req: Request, res: Response) => {
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
const name = (req.body as { name?: unknown } | undefined)?.name;
if (typeof name !== "string" || !NAME_RE.test(name)) { res.status(400).json({ error: "Неверное имя" }); return; }
q.rename.run(name, id);
res.json({ id, name });
});
router.delete("/:id", (req: Request, res: Response) => {
const id = idOf(req);
const n = validId(id) ? q.byId.get(id) as NodeRow | undefined : undefined;
if (!n) { res.status(404).json({ error: "Не найден" }); return; }
// Ключи API и мастер-ключи ссылаются на server_id — не оставляем их висеть в воздухе.
const used = (["api_keys", "master_servers"] as const).some(t => {
try { return (uidb.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE server_id = ?`).get(id) as { n: number }).n > 0; }
catch { return false; }
});
if (used) { res.status(409).json({ error: "К серверу привязаны ключи API или мастер-ключи — сначала удалите их" }); return; }
const c = conns.get(id);
if (c) { dropConn(id, c); c.ws.close(4004, "removed"); }
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 });
});
return { router, ctrlFor, exists, isOnline, ids, onOnline, defaultId, start };
}
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.0",
"version": "0.3.2",
"private": true,
"scripts": {
"dev": "vite",
@@ -15,20 +15,22 @@
"better-sqlite3": "^12.10.0",
"qrcode": "^1.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react-dom": "^18.3.0",
"ws": "^8.22.0"
},
"devDependencies": {
"concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.0",
"concurrently": "^9.0.0",
"express": "^4.19.0",
"tsx": "^4.7.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
}
}
+29 -20
View File
@@ -8,6 +8,7 @@ import crypto from "crypto";
import axios from "axios";
import Database from "better-sqlite3";
import { createSub } from "./sub";
import { createNodes } from "./nodes";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -180,10 +181,10 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
@@ -195,25 +196,24 @@ app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
res.json({ success: true, id });
});
// Сервер, к которому относится запрос панели: заголовок X-Server-Id (0 — локальный
// awg-ctrl, остальное — нода). Без заголовка — сервер по умолчанию.
function serverIdOf(req: Request): number {
const raw = req.headers["x-server-id"];
if (raw === undefined) return nodes.defaultId();
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : nodes.defaultId();
}
async function proxy(req: Request, res: Response) {
try {
const r = await axios({
method: req.method,
url: CTRL + req.originalUrl,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: req.body,
validateStatus: () => true,
});
const r = await nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body);
res.status(r.status).json(r.data);
} catch {
res.status(502).json({ error: "awg-ctrl недоступен" });
}
}
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
@@ -243,6 +243,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router();
ext.use(requireApiKey);
// Ключ привязан к server_id — все вызовы уходят на его сервер.
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
// тот же, что у proxy() в такой ситуации.
@@ -253,26 +256,26 @@ const wrap = (fn: Handler) => (req: Request, res: Response) => {
ext.post("/users", wrap(async (req, res) => {
const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name });
const r = await ctrlOf(req)("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.get("/users", wrap(async (_req, res) => {
const r = await ctrl("GET", "/api/users/stats");
ext.get("/users", wrap(async (req, res) => {
const r = await ctrlOf(req)("GET", "/api/users/stats");
res.status(r.status).json(r.data);
}));
ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.delete("/users/:name", wrap(async (req, res) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
}));
@@ -282,7 +285,12 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка: мастер-ключ отдаёт набор серверов (локальный и/или ноды).
const sub = createSub({ uidb, servers: nodes, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
@@ -295,4 +303,5 @@ app.get("*", (_req: Request, res: Response) => {
});
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
nodes.start();
sub.start();
+43
View File
@@ -375,6 +375,7 @@ body {
.server-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.server-card {
@@ -389,6 +390,45 @@ body {
user-select: none;
}
.server-card--active { border-color: var(--primary-container); }
.server-card--inactive { cursor: pointer; }
.server-card--inactive:hover { background: var(--state-primary); }
.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */
.server-add {
background: transparent;
border: 1.5px dashed var(--outline);
color: var(--neutral-text);
}
.server-add:disabled { opacity: .6; cursor: not-allowed; }
/* Модалка добавления ноды */
.node-scrim {
position: fixed; inset: 0; z-index: 60;
background: var(--scrim);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.node-modal {
width: 100%; max-width: 520px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.node-modal-cmd {
display: block;
font-family: var(--mono); font-size: 12px;
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
word-break: break-all;
user-select: all;
}
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.server-card-info {
display: flex;
flex-direction: column;
@@ -656,6 +696,9 @@ tbody td:last-child { padding-right: 20px; }
border-color: var(--primary-container);
padding: 12px 14px;
}
.server-card--inactive { border-color: var(--outline-variant); }
.server-add { width: 100%; justify-content: center; }
.node-modal-actions .btn { flex: 1; }
.server-toggle { display: flex; }
.server-online-chip { display: none; }
+84 -19
View File
@@ -4,20 +4,27 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './App.css';
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
type NodeInfo, type Brand,
} from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar';
import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
// из реестра TABS — App про конкретные вкладки ничего не знает.
export default function App() {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [statusText, setStatusText] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -33,17 +40,34 @@ export default function App() {
setTimeout(() => setMsg(''), 3000);
}, []);
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
// вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
// Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
// (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним).
const pickServer = useCallback((id: number | null) => {
setServerId(id);
setServerIdState(id);
try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ }
}, []);
// Список серверов (core + ноды) с их состоянием. Если выбранного больше нет —
// берём сохранённый, иначе первый.
const loadNodes = useCallback(async (tok: string) => {
const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } });
const list: NodeInfo[] = data.nodes ?? [];
setNodes(list);
setHubEnabled(!!data.hub);
const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null;
try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ }
pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null);
}, [pickServer]);
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
// (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
await loadNodes(tok);
}, [loadNodes]);
const login = useCallback(async () => {
try {
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
@@ -69,10 +93,23 @@ export default function App() {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setStatusText('');
setServerInfo(null);
setNodes([]);
setServerId(null);
setServerIdState(null);
setDrawerOpen(false);
}, [token]);
const deleteNode = useCallback(async (n: NodeInfo) => {
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
showMsg('Сервер удалён');
await loadNodes(token);
} catch (e) {
showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить');
}
}, [token, showMsg, loadNodes]);
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
const restartAwg = useCallback(async () => {
@@ -111,6 +148,13 @@ export default function App() {
}
}, [startSession]);
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
useEffect(() => {
if (!token) return;
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
@@ -143,8 +187,7 @@ export default function App() {
return () => document.removeEventListener('keydown', h);
}, [drawerOpen]);
const isOnline = statusText.startsWith('online:');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
const chrome = tab.chrome ?? {};
@@ -161,7 +204,7 @@ export default function App() {
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && (
{token && showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
@@ -257,19 +300,41 @@ export default function App() {
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
serverInfo={serverInfo}
serverOnline={isOnline}
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
)}
<tab.Page token={token} showMsg={showMsg} />
{serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
</p>
) : (
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
)}
</main>
</div>
</>
)}
{modal && (
<AddServerModal
token={token}
node={modal.node}
onClose={() => setModal(null)}
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
+102
View File
@@ -0,0 +1,102 @@
// 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 { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, copyText, type NodeInfo } from '../lib/shared';
import { IcoCopy } from './icons';
interface Joined { id: number; name: string; join: string; expires_in: number }
// Добавление ноды: имя → одноразовая join-строка, которую выполняют на самой ноде
// (`awg-ctrl join …`). С `node` открывается сразу за новой строкой для уже
// созданной, но ещё не подключавшейся ноды.
export default function AddServerModal({ token, node, onClose, onChanged, showMsg }: {
token: string;
node?: NodeInfo;
onClose: () => void;
onChanged: () => void;
showMsg: (text: string) => void;
}) {
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<Joined | null>(null);
const [error, setError] = useState('');
const call = async (fn: () => Promise<Joined>) => {
setBusy(true); setError('');
try { setResult(await fn()); onChanged(); }
catch (e) { setError(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось' : 'Не удалось'); }
finally { setBusy(false); }
};
const create = () => call(() =>
apiFetch('POST', '/ui/nodes', token, { name: name.trim(), host: window.location.hostname }));
useEffect(() => {
if (node) void call(() => apiFetch('POST', `/ui/nodes/${node.id}/join`, token, { host: window.location.hostname }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const command = result ? `awg-ctrl join '${result.join}'` : '';
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Добавить сервер" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">{node ? `Подключение: ${node.name}` : 'Добавить сервер'}</h3>
{!node && !result && (
<>
<p className="node-modal-hint">
Нода — обычный сервер с установленным Forgetting. Она сама подключится к этой панели,
открывать на ней порты не нужно.
</p>
<input
className="field"
placeholder="Название сервера"
value={name}
maxLength={40}
autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && name.trim() && !busy && create()}
/>
</>
)}
{result && (
<>
<p className="node-modal-hint">
Выполните на сервере «{result.name}» от root. Ссылка одноразовая и действует
{' '}{Math.round(result.expires_in / 60)} мин.
</p>
<code className="node-modal-cmd">{command}</code>
<p className="node-modal-hint">
Канал шифруется TLS с проверкой отпечатка сертификата этой панели: подменить ноду
или панель по дороге не выйдет.
</p>
</>
)}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
{result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
) : !node && (
<button className="btn btn--primary" onClick={create} disabled={busy || !name.trim()}>
Создать
</button>
)}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div>
</div>
</div>
);
}
+84 -36
View File
@@ -1,56 +1,104 @@
// 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 ServerInfo, genLabel } from '../lib/shared';
import { IcoRefresh } from './icons';
import { type NodeInfo, genLabel } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting,
showRestart = true,
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
onRestartAwg, restarting, showRestart = true,
}: {
serverInfo: ServerInfo | null;
serverOnline: boolean;
nodes: NodeInfo[];
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
onRestartAwg: () => void;
restarting: boolean;
showRestart?: boolean;
}) {
return (
<div className="server-bar">
{serverInfo && (
<div className="server-card server-card--active">
{/* toggle — виден на мобильном вместо чипа */}
{nodes.map(n => {
const h = n.health;
const active = n.id === serverId;
return (
<div
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
aria-hidden="true"
key={n.id}
className={`server-card server-card--${active ? 'active' : 'inactive'}`}
role="button"
tabIndex={0}
aria-pressed={active}
onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
>
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span>
{(serverInfo.mod || serverInfo.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
{/* toggle — виден на мобильном вместо чипа */}
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info">
<span className="server-card-name">{h?.server || n.name}</span>
{n.pending ? (
<span className="server-card-ip">ждёт подключения</span>
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
<>
<span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
)}
</>
)}
</div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
{n.pending && (
<button
className="btn-icon btn-icon--neutral"
aria-label="Новая ссылка подключения"
onClick={e => { e.stopPropagation(); onJoin(n); }}
>
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
<IcoCopy />
</button>
)}
{!n.local && (
<button
className="btn-icon btn-icon--danger"
aria-label={`Удалить сервер ${n.name}`}
onClick={e => { e.stopPropagation(); onDelete(n); }}
>
<IcoTrash />
</button>
)}
</div>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
);
})}
<span
className="tip-wrap"
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
>
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
<IcoPlus /> Добавить сервер
</button>
</span>
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && (
{showRestart && serverId !== null && (
<button
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
onClick={onRestartAwg}
@@ -61,4 +109,4 @@ export default function ServerBar({
)}
</div>
);
}
}
+25 -3
View File
@@ -47,10 +47,32 @@ export interface PageProps {
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server';
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo {
id: number;
name: string;
local: boolean; // id 0 — локальный awg-ctrl этой же машины
online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null;
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
}
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
let currentServerId: number | null = null;
export const setServerId = (id: number | null) => { currentServerId = id; };
export const getServerId = () => currentServerId;
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
const r = await axios({ method, url: path, headers, data: body });
return r.data;
}
+2 -2
View File
@@ -2,7 +2,7 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
@@ -26,7 +26,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
+51 -24
View File
@@ -3,19 +3,25 @@
// LICENSE file in the root directory of this source tree.
import { Fragment, useState, useEffect, useCallback } from 'react';
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 './masterkeys.css';
// Вкладка «Мастер-ключи»: sen://-подписки для SenAWG. Мастер-ключ — это ссылка
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
// на них) и здесь только отображаются и отзываются. Формат — 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 {
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;
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»; без версии — просто платформа.
const platformLabel = (d: Device): string =>
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 [enabled, setEnabled] = useState(true);
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 [limit, setLimit] = useState('3');
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 list: MasterKey[] = d.keys ?? [];
setKeys(list); setEnabled(!!d.enabled); setTls(!!d.tls);
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
apiFetch('GET', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
const all = await Promise.all(list.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
@@ -57,10 +62,14 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const create = useCallback(async () => {
if (!label.trim()) return;
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('');
}, 'Мастер-ключ создан');
}, [label, limit, token, act]);
}, [label, limit, nodes, token, act]);
const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
@@ -146,20 +155,32 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
</section>
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
<section className="mk-box">
<h4 className="mk-box-title">Включённые серверы</h4>
<label className="mk-server">
<input type="checkbox" checked disabled />
<span className="mk-server-name">{server?.name ?? 'Текущий сервер'}</span>
{server?.ip && <code className="mk-server-ip">{server.ip}</code>}
{server?.gen && <span className="mk-server-gen">{genLabel(server.gen as AwgGen)}</span>}
</label>
<span className="tip-wrap" data-tip="Появится вместе с поддержкой нескольких серверов">
<button className="btn mk-server-add" disabled><IcoPlus /> Добавить сервер</button>
</span>
<h4 className="mk-box-title">Серверы ключа</h4>
{nodes.map(n => {
const on = k.servers.includes(n.id);
const last = on && k.servers.length === 1;
const h = n.health;
return (
<label className={`mk-server${last ? ' mk-server--locked' : ''}`} key={n.id}>
<input type="checkbox" checked={on} disabled={last}
onChange={() => {
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>
</div>
);
@@ -181,8 +202,13 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const deviceActions = (d: Device) => (
<div className="actions">
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><IcoRefresh /></button>
<button className="btn-icon" title="Новый PSK" aria-label="Новый PSK" onClick={async () => {
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={() => {
if (confirm('Отозвать устройство «' + (d.device_name || d.id) + '»?'))
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>
</span>
<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>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<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.tx)}</td>
<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 className="td-actions mk-td-actions">{deviceActions(d)}</td>
</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-ip, .mk-server-gen { font-size: 12px; color: var(--neutral-text); }
.mk-server-ip { font-family: var(--mono); margin-left: auto; }
.mk-server-add {
width: 100%; justify-content: center;
background: transparent;
border: 1px dashed var(--outline);
color: var(--neutral-dot);
cursor: not-allowed;
}
.mk-server { cursor: pointer; }
.mk-server:hover { background: var(--state-primary); }
.mk-server--locked { cursor: default; }
.mk-server--locked:hover { background: var(--surface-container); }
.mk-server-dot { padding: 0; background: none; }
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
+355 -72
View File
@@ -8,6 +8,14 @@
//
// awg-ctrl про мастер-ключи ничего не знает: сюда он отдаёт только пиров по
// публичному ключу (/api/peers*) и общий профиль сервера (/api/profile).
//
// Несколько серверов. Мастер-ключ отдаёт набор серверов (master_servers): локальный
// awg-ctrl (id 0) и/или ноды. Устройство регистрирует один ключ WireGuard, и он
// становится пиром на каждом сервере набора — IP и PSK на каждом свои (device_peers).
// Нода может быть не в сети, поэтому изменения не «толкаются», а сводятся: device_peers
// хранит, какой ключ сейчас стоит на сервере, peer_tombstones — что там надо удалить,
// а reconcile() приводит сервер к нужному состоянию, как только до него можно достучаться
// (сразу при действии, при подключении ноды и раз в минуту).
import crypto from "crypto";
import fs from "fs";
import http from "http";
@@ -22,6 +30,16 @@ import {
type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
/** Что подписке нужно от реестра серверов (awg-ui/nodes.ts). */
export interface Servers {
ctrlFor(id: number): Ctrl;
exists(id: number): boolean;
isOnline(id: number): boolean;
ids(): number[];
defaultId(): number;
onOnline(cb: (id: number) => void): void;
}
interface MasterRow {
id: number; label: string; secret: Buffer; device_limit: number;
server_id: number; created_at: number; revoked_at: number | null;
@@ -31,9 +49,19 @@ interface DeviceRow {
pub_key: string; auth_pub: string; created_at: number; last_seen: number | null;
rekey_requested: number; version: string;
}
interface LinkRow {
device_id: number; server_id: number; peer_pub: string | null;
ip: string | null; psk: string | null; psk_pending: number;
}
interface Profile {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
}
const TS_WINDOW = 300; // секунд, ±
const DEFAULT_LIMIT = 3;
const MAX_SERVERS = 32; // серверов в одном мастер-ключе
const SYNC_EVERY = 60_000; // фоновое сведение серверов
const B64_PUB = /^[A-Za-z0-9+/]{43}=$/;
const CLIENT_VERSION = /^[\w.+-]{1,32}$/; // версия приложения, чисто информационная
@@ -53,12 +81,15 @@ function makeLimiter(max: number, windowMs: number) {
};
}
export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) {
const { uidb, ctrl } = deps;
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
const { uidb, servers } = deps;
// ── Настройка из окружения ─────────────────────────────────────────────
const SUB_PORT = Number(process.env.SUB_PORT) || 0;
const SUB_TLS = (process.env.SUB_TLS ?? "off").toLowerCase() === "on";
// Публичный адрес панели для ссылок и endpoints. Без него — адрес локального сервера,
// а у панели без своего VPN — тот, по которому к ней пришли.
const SUB_HOST = (process.env.SUB_HOST ?? "").trim();
const file = (env: string | undefined, name: string) => env || path.join(deps.baseDir, name);
let signKey: crypto.KeyObject | null = null;
@@ -109,6 +140,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
rekey_requested INTEGER NOT NULL DEFAULT 0,
UNIQUE (master_id, device_id)
);
-- Серверы мастер-ключа. master_keys.server_id остался от времени одного сервера и
-- дальше не читается (кроме переноса ниже).
CREATE TABLE IF NOT EXISTS master_servers (
master_id INTEGER NOT NULL REFERENCES master_keys(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
PRIMARY KEY (master_id, server_id)
);
-- Пир устройства на сервере. peer_pub — ключ, который сейчас стоит на сервере
-- (NULL — ещё не добавлен); расходится с devices.pub_key после смены ключа, пока
-- сервер не догнали. ip/psk — как их выдал awg-ctrl этого сервера.
CREATE TABLE IF NOT EXISTS device_peers (
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL,
peer_pub TEXT,
ip TEXT,
psk TEXT,
psk_pending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (device_id, server_id)
);
-- Пиры, которые надо удалить с сервера, когда до него дотянемся.
CREATE TABLE IF NOT EXISTS peer_tombstones (
server_id INTEGER NOT NULL,
pub_key TEXT NOT NULL,
PRIMARY KEY (server_id, pub_key)
);
-- Последний известный профиль сервера: config собирается из него, даже когда
-- нода не в сети, — иначе сервер пропадал бы из подписки на время обрыва.
CREATE TABLE IF NOT EXISTS server_profiles (
server_id INTEGER PRIMARY KEY,
profile TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
// таблицы колонки может не быть.
@@ -116,14 +179,30 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
uidb.pragma("foreign_keys = ON");
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
// уже стоит там под devices.pub_key (ip/psk подтянет reconcile). У ключа всегда есть хотя
// бы один сервер, поэтому «ни одной строки» бывает только у ключей из старой схемы.
uidb.exec(`
INSERT OR IGNORE INTO master_servers (master_id, server_id)
SELECT id, server_id FROM master_keys
WHERE id NOT IN (SELECT master_id FROM master_servers);
INSERT OR IGNORE INTO device_peers (device_id, server_id, peer_pub)
SELECT d.id, m.server_id, d.pub_key FROM devices d JOIN master_keys m ON m.id = d.master_id
WHERE d.id NOT IN (SELECT device_id FROM device_peers);
`);
const q = {
masters: uidb.prepare("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE revoked_at IS NULL ORDER BY id"),
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, created_at) VALUES (?, ?, ?, ?)"),
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
masterDelete: uidb.prepare<[number]>("DELETE FROM master_keys WHERE id = ?"),
serversOf: uidb.prepare<[number]>("SELECT server_id FROM master_servers WHERE master_id = ? ORDER BY server_id"),
serverAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO master_servers (master_id, server_id) VALUES (?, ?)"),
serverDel: uidb.prepare<[number, number]>("DELETE FROM master_servers WHERE master_id = ? AND server_id = ?"),
usedServers: uidb.prepare("SELECT server_id FROM master_servers UNION SELECT server_id FROM peer_tombstones"),
devicesOf: uidb.prepare<[number]>("SELECT * FROM devices WHERE master_id = ? ORDER BY id"),
deviceById: uidb.prepare<[number]>("SELECT * FROM devices WHERE id = ?"),
deviceByKey: uidb.prepare<[number, string]>("SELECT * FROM devices WHERE master_id = ? AND device_id = ?"),
@@ -133,47 +212,171 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
deviceVersion: uidb.prepare<[string, number]>("UPDATE devices SET version = ? WHERE id = ?"),
deviceDelete: uidb.prepare<[number]>("DELETE FROM devices WHERE id = ?"),
deviceSeen: uidb.prepare<[number, number]>("UPDATE devices SET last_seen = ? WHERE id = ?"),
deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 WHERE id = ?"),
devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"),
deviceFlag: uidb.prepare<[number, number]>("UPDATE devices SET rekey_requested = 1 WHERE id = ? OR master_id = ?"),
linksOf: uidb.prepare<[number]>("SELECT * FROM device_peers WHERE device_id = ? ORDER BY server_id"),
linkAdd: uidb.prepare<[number, number]>("INSERT OR IGNORE INTO device_peers (device_id, server_id) VALUES (?, ?)"),
linkDel: uidb.prepare<[number, number]>("DELETE FROM device_peers WHERE device_id = ? AND server_id = ?"),
linkSet: uidb.prepare<[string | null, string | null, string | null, number, number, number]>(
"UPDATE device_peers SET peer_pub = ?, ip = ?, psk = ?, psk_pending = ? WHERE device_id = ? AND server_id = ?"),
linkPskDue: uidb.prepare<[number]>("UPDATE device_peers SET psk_pending = 1 WHERE device_id = ? AND peer_pub IS NOT NULL"),
// Что на сервере расходится с нужным: пира нет, стоит старый ключ, не знаем ip/psk, ждёт новый PSK.
linksToFix: uidb.prepare<[number]>(`
SELECT l.*, d.pub_key AS dev_pub, d.master_id FROM device_peers l JOIN devices d ON d.id = l.device_id
WHERE l.server_id = ? AND (l.peer_pub IS NULL OR l.peer_pub <> d.pub_key
OR l.ip IS NULL OR l.psk IS NULL OR l.psk_pending = 1)`),
tombsOf: uidb.prepare<[number]>("SELECT pub_key FROM peer_tombstones WHERE server_id = ?"),
tombAdd: uidb.prepare<[number, string]>("INSERT OR IGNORE INTO peer_tombstones (server_id, pub_key) VALUES (?, ?)"),
tombDel: uidb.prepare<[number, string]>("DELETE FROM peer_tombstones WHERE server_id = ? AND pub_key = ?"),
profileGet: uidb.prepare<[number]>("SELECT profile FROM server_profiles WHERE server_id = ?"),
profileSet: uidb.prepare<[number, string, number]>(
"INSERT INTO server_profiles (server_id, profile, updated_at) VALUES (?, ?, ?) ON CONFLICT(server_id) DO UPDATE SET profile = excluded.profile, updated_at = excluded.updated_at"),
};
const now = () => Math.floor(Date.now() / 1000);
const hasCtrlOk = (s: number) => s >= 200 && s < 300;
const serverIdsOf = (masterId: number) => (q.serversOf.all(masterId) as { server_id: number }[]).map(r => r.server_id);
// ── Общие куски ────────────────────────────────────────────────────────
async function profile() {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status)) throw new Error("profile");
return r.data as {
name: string; endpoint: string; server_pub: string; gen: string;
dns: string[]; keepalive: string; mtu: number; awg: Record<string, string>;
};
// ── Сведение серверов ──────────────────────────────────────────────────
function storedProfile(id: number): Profile | null {
const row = q.profileGet.get(id) as { profile: string } | undefined;
try { return row ? JSON.parse(row.profile) as Profile : null; } catch { return null; }
}
async function refreshProfile(id: number, ctrl: Ctrl): Promise<boolean> {
const r = await ctrl("GET", "/api/profile");
if (!hasCtrlOk(r.status) || typeof r.data?.endpoint !== "string") return false;
const json = JSON.stringify(r.data);
const row = q.profileGet.get(id) as { profile: string } | undefined;
if (row?.profile !== json) q.profileSet.run(id, json, now());
return true;
}
// Один проход на сервер за раз: параллельные проходы добавили бы одного пира дважды.
const locks = new Map<number, Promise<void>>();
function reconcile(id: number): Promise<void> {
const next = (locks.get(id) ?? Promise.resolve()).then(() => reconcileNow(id)).catch(e => {
console.warn(`sub: сервер #${id} не сведён: ${(e as Error).message}`);
});
locks.set(id, next);
return next;
}
const reconcileAll = (ids: Iterable<number>) => Promise.all([...new Set(ids)].map(reconcile));
async function reconcileNow(id: number) {
if (!servers.exists(id) || !servers.isOnline(id)) return;
const ctrl = servers.ctrlFor(id);
if (!await refreshProfile(id, ctrl)) return; // сервер не отвечает — попробуем позже
// Список пиров сервера — лениво, только если понадобится (409, потерянный ip/psk).
let listed: Map<string, { ip: string; psk_key: string }> | null = null;
const onServer = async (pub: string) => {
if (!listed) {
const r = await ctrl("GET", "/api/peers");
if (!hasCtrlOk(r.status)) throw new Error(`peers ${r.status}`);
listed = new Map((r.data.peers as { pub_key: string; ip: string; psk_key: string }[]).map(p => [p.pub_key, p]));
}
return listed.get(pub) ?? null;
};
const gone = (s: number) => s === 502 || s === 504; // связь пропала посреди прохода
for (const { pub_key } of q.tombsOf.all(id) as { pub_key: string }[]) {
const r = await ctrl("DELETE", `/api/peers/${pubParam(pub_key)}`);
if (hasCtrlOk(r.status) || r.status === 404) q.tombDel.run(id, pub_key);
else if (gone(r.status)) return;
}
type Peer = { ip: string; psk_key: string };
for (const l of q.linksToFix.all(id) as (LinkRow & { dev_pub: string; master_id: number })[]) {
// null — не вышло, попробуем в следующий проход; "gone" — связь пропала, проход прекращаем.
const add = async (): Promise<Peer | null | "gone"> => {
const r = await ctrl("POST", "/api/peers", { pub_key: l.dev_pub, owner: `m${l.master_id}/d${l.device_id}` });
if (hasCtrlOk(r.status)) return r.data;
if (r.status === 409) return onServer(l.dev_pub); // уже добавлен, но ответ потерялся
return gone(r.status) ? "gone" : null;
};
let peer: Peer | null | "gone";
if (l.peer_pub === null) {
peer = await add();
} else if (l.peer_pub !== l.dev_pub) {
// Ключ сменили, пока сервер был недоступен: тот же IP и PSK, новый ключ.
const r = await ctrl("PUT", `/api/peers/${pubParam(l.peer_pub)}`, { pub_key: l.dev_pub });
peer = hasCtrlOk(r.status) ? r.data
: r.status === 404 ? await add() // старого пира на сервере уже нет
: r.status === 409 ? await onServer(l.dev_pub)
: gone(r.status) ? "gone" : null;
} else {
peer = await onServer(l.dev_pub) ?? await add(); // ip/psk не знаем или пир пропал с сервера
}
if (peer === "gone") return;
if (!peer) continue;
let pending = 0;
if (l.psk_pending === 1) {
const r = await ctrl("POST", `/api/peers/${pubParam(l.dev_pub)}/psk`);
if (hasCtrlOk(r.status)) peer = r.data as Peer;
else if (gone(r.status)) return;
else pending = 1; // пир на месте, PSK сменим позже
}
q.linkSet.run(l.dev_pub, peer.ip, peer.psk_key, pending, l.device_id, id);
}
}
/** Устройство удаляется: его пиров — в список на удаление, строку — из реестра. */
function forgetDevice(dev: DeviceRow): number[] {
const links = q.linksOf.all(dev.id) as LinkRow[];
uidb.transaction(() => {
for (const l of links) if (l.peer_pub) q.tombAdd.run(l.server_id, l.peer_pub);
q.deviceDelete.run(dev.id); // device_peers уйдут каскадом
})();
return links.map(l => l.server_id);
}
// ── config ─────────────────────────────────────────────────────────────
const hostOf = (endpoint: string) => endpoint.slice(0, endpoint.lastIndexOf(":"));
async function buildConfig(dev: DeviceRow) {
const [prof, peers] = await Promise.all([profile(), ctrl("GET", "/api/peers")]);
if (!hasCtrlOk(peers.status)) throw new Error("peers");
const peer = (peers.data.peers as { pub_key: string; ip: string; psk_key: string }[])
.find(p => p.pub_key === dev.pub_key);
if (!peer) throw new Error("peer missing");
function subHost(req?: Request): string {
if (SUB_HOST) return SUB_HOST;
const local = servers.exists(0) ? storedProfile(0) : null;
if (local) return hostOf(local.endpoint);
return (req?.hostname ?? "").replace(/^\[|\]$/g, "");
}
// В servers[] — только серверы, где уже стоит текущий ключ устройства и известны ip/psk.
// Сервер, который ещё не догнали (нода не в сети при регистрации или смене ключа), появится,
// когда его сведут, — rev при этом сменится, и клиент подтянет.
function buildConfig(dev: DeviceRow, req?: Request) {
const list = [];
for (const l of q.linksOf.all(dev.id) as LinkRow[]) {
const prof = storedProfile(l.server_id);
if (!prof || l.peer_pub !== dev.pub_key || !l.ip || !l.psk) continue;
list.push({
id: l.server_id, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: l.psk, address: `${l.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
});
}
const cfg = {
rekey: dev.rekey_requested === 1,
endpoints: [`${hostOf(prof.endpoint)}:${SUB_PORT}`],
servers: [{
id: 0, name: prof.name, endpoint: prof.endpoint, server_pub: prof.server_pub,
psk: peer.psk_key, address: `${peer.ip}/32`, dns: prof.dns,
keepalive: prof.keepalive, mtu: prof.mtu, gen: prof.gen, awg: prof.awg,
}],
endpoints: [`${subHost(req)}:${SUB_PORT}`],
servers: list,
};
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
return { rev, ...cfg };
}
async function dropPeer(pub: string) {
await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно
// Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель
// из-за временной недоступности нод — пусть лучше живёт со старым конфигом.
async function readyConfig(dev: DeviceRow, req: Request) {
let cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
await reconcileAll((q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id));
cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
}
if (cfg.servers.length === 0) throw new Error("no servers");
return cfg;
}
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
@@ -245,34 +448,38 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
const master = q.masterBySecret.get(Buffer.from(secretB, "base64url")) as MasterRow | undefined;
if (!master) { send(res, 404, { error: "not_found" }); return; }
const serverIds = serverIdsOf(master.id).filter(id => servers.exists(id));
if (serverIds.length === 0) { send(res, 502, { error: "unavailable" }); return; }
let deviceRowId = 0;
let oldPub: string | null = null;
let touched: number[] = [];
try {
uidb.transaction(() => {
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); }
if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление
else if ((q.deviceCount.get(master.id) as { n: number }).n >= master.device_limit)
throw Object.assign(new Error("limit"), { code: "limit" });
deviceRowId = Number(q.deviceInsert.run(master.id, deviceId, name, platform, pubKey, authPubS, now(), version).lastInsertRowid);
for (const sid of serverIds) q.linkAdd.run(deviceRowId, sid);
})();
} catch (e: any) {
if (e?.code === "limit") { send(res, 403, { error: "device_limit" }); return; }
send(res, 409, { error: "conflict" }); return; // UNIQUE: pub_key/auth_pub уже заняты
}
if (oldPub) await dropPeer(oldPub);
const r = await ctrl("POST", "/api/peers", { pub_key: pubKey, owner: `m${master.id}/d${deviceRowId}` });
if (!hasCtrlOk(r.status)) {
q.deviceDelete.run(deviceRowId);
send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" });
await reconcileAll([...touched, ...serverIds]);
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
const cfg = buildConfig(dev, req);
if (cfg.servers.length === 0) {
// Ни один сервер пира не принял — регистрацию откатываем, клиент повторит позже.
await reconcileAll(forgetDevice(dev));
send(res, 502, { error: "unavailable" });
return;
}
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(res, 201, {
device: dev.id, config: await buildConfig(dev),
device: dev.id, config: cfg,
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
@@ -298,7 +505,7 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(res, 200, { config: await buildConfig(dev) });
send(res, 200, { config: await readyConfig(dev, req) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
@@ -317,20 +524,31 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
});
}));
// Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же IP и PSK),
// где нет — когда он появится. Если не принял ни один — ключ не меняем, клиент повторит.
sub.post("/sub/v1/rekey", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
const pub = (req.body as { pub_key?: unknown } | undefined)?.pub_key;
if (typeof pub !== "string" || !B64_PUB.test(pub)) { send(res, 400, { error: "bad_request" }); return; }
const r = await ctrl("PUT", `/api/peers/${pubParam(dev.pub_key)}`, { pub_key: pub });
if (!hasCtrlOk(r.status)) { send(res, r.status === 409 ? 409 : 502, { error: r.status === 409 ? "conflict" : "unavailable" }); return; }
q.deviceRekeyed.run(pub, dev.id);
send(res, 200, { config: await buildConfig(q.deviceById.get(dev.id) as DeviceRow) });
if (pub === dev.pub_key) { send(res, 200, { config: await readyConfig(dev, req) }); return; }
try { q.devicePub.run(pub, 0, dev.id); }
catch { send(res, 409, { error: "conflict" }); return; } // этот ключ уже у другого устройства
const ids = (q.linksOf.all(dev.id) as LinkRow[]).map(l => l.server_id);
await reconcileAll(ids);
const cfg = buildConfig(q.deviceById.get(dev.id) as DeviceRow, req);
if (cfg.servers.length === 0) {
q.devicePub.run(dev.pub_key, dev.rekey_requested, dev.id);
await reconcileAll(ids); // вернуть старый ключ туда, где успели сменить
send(res, 502, { error: "unavailable" });
return;
}
send(res, 200, { config: cfg });
}));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
await dropPeer(dev.pub_key);
q.deviceDelete.run(dev.id);
await reconcileAll(forgetDevice(dev));
send(res, 200, { ok: true });
}));
@@ -345,6 +563,15 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
const LABEL = /^[^\r\n<>]{1,40}$/;
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
// Набор серверов из тела запроса: непустой, без повторов, только существующие.
function parseServers(v: unknown): number[] | string {
if (!Array.isArray(v) || v.length === 0) return "Нужен хотя бы один сервер";
if (v.length > MAX_SERVERS) return `Не больше ${MAX_SERVERS} серверов`;
const ids = [...new Set(v)];
if (!ids.every(id => Number.isInteger(id) && servers.exists(id as number))) return "Такого сервера нет";
return (ids as number[]).sort((a, b) => a - b);
}
const masterKeys = Router();
masterKeys.get("/", (_req, res) => {
@@ -353,30 +580,62 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
tls: tlsPin !== null,
keys: (q.masters.all() as (MasterRow & { devices: number })[]).map(m => ({
id: m.id, label: m.label, device_limit: m.device_limit,
devices: m.devices, server_id: m.server_id, created_at: m.created_at,
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
})),
});
});
masterKeys.post("/", (req, res) => {
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
if (typeof label !== "string" || !LABEL.test(label)) { res.status(400).json({ error: "Метка: до 40 символов, без переводов строк и <>" }); return; }
const limit = device_limit === undefined ? DEFAULT_LIMIT : device_limit;
if (!limitOk(limit)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
const info = q.masterInsert.run(label, crypto.randomBytes(16), limit, now());
res.status(201).json({ id: Number(info.lastInsertRowid), label, device_limit: limit, devices: 0 });
const ids = parseServers(wanted === undefined ? [servers.defaultId()] : wanted);
if (typeof ids === "string") { res.status(400).json({ error: wanted === undefined ? "Сначала добавьте сервер" : ids }); return; }
let id = 0;
uidb.transaction(() => {
id = Number(q.masterInsert.run(label, crypto.randomBytes(16), limit, ids[0], now()).lastInsertRowid);
for (const sid of ids) q.serverAdd.run(id, sid);
})();
res.status(201).json({ id, label, device_limit: limit, devices: 0, servers: ids });
});
masterKeys.patch("/:id", (req, res) => {
masterKeys.patch("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
const nl = label ?? m.label, nd = device_limit ?? m.device_limit;
if (typeof nl !== "string" || !LABEL.test(nl)) { res.status(400).json({ error: "Неверная метка" }); return; }
if (!limitOk(nd)) { res.status(400).json({ error: "Лимит устройств: от 1 до 100" }); return; }
let ids = serverIdsOf(m.id);
const affected: number[] = [];
if (wanted !== undefined) {
const next = parseServers(wanted);
if (typeof next === "string") { res.status(400).json({ error: next }); return; }
const added = next.filter(s => !ids.includes(s)), removed = ids.filter(s => !next.includes(s));
const devices = q.devicesOf.all(m.id) as DeviceRow[];
uidb.transaction(() => {
for (const sid of removed) {
q.serverDel.run(m.id, sid);
for (const d of devices) {
const l = (q.linksOf.all(d.id) as LinkRow[]).find(x => x.server_id === sid);
if (l?.peer_pub) q.tombAdd.run(sid, l.peer_pub);
q.linkDel.run(d.id, sid);
}
}
for (const sid of added) {
q.serverAdd.run(m.id, sid);
for (const d of devices) q.linkAdd.run(d.id, sid);
}
})();
affected.push(...added, ...removed);
ids = next;
}
q.masterUpdate.run(nl, nd, m.id);
res.json({ id: m.id, label: nl, device_limit: nd });
});
await reconcileAll(affected);
res.json({ id: m.id, label: nl, device_limit: nd, servers: ids });
}));
masterKeys.post("/:id/rotate", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
@@ -395,50 +654,64 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
masterKeys.delete("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
q.masterDelete.run(m.id); // devices уйдут каскадом
const affected: number[] = [];
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterDelete.run(m.id); // master_servers уйдут каскадом
await reconcileAll(affected);
res.json({ success: true, id: m.id });
}));
masterKeys.get("/:id/link", aw(async (req, res) => {
masterKeys.get("/:id/link", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
if (!signKey || !signPub || !SUB_PORT) { res.status(503).json({ error: "Подписка не настроена на сервере" }); return; }
const prof = await profile();
const host = subHost(req);
if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; }
const link = encodeSenLink({
tls: tlsPin !== null,
addrs: [{ host: hostOf(prof.endpoint), port: SUB_PORT }],
addrs: [{ host, port: SUB_PORT }],
secret: m.secret, signPub, tlsPin: tlsPin ?? undefined, name: m.label,
});
res.json({ link, tls: tlsPin !== null });
}));
});
// Статистика устройства — по всем серверам ключа: онлайн, если есть свежий handshake хоть
// на одном; трафик суммируется. servers_ok — на скольких серверах уже стоит его текущий ключ.
masterKeys.get("/:id/devices", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const stats = new Map<string, { online: boolean; lastHandshake: number; rx: number; tx: number }>();
const r = await ctrl("GET", "/api/peers");
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
await Promise.all(serverIdsOf(m.id).filter(id => servers.isOnline(id)).map(async id => {
const r = await servers.ctrlFor(id)("GET", "/api/peers");
if (!hasCtrlOk(r.status)) return;
for (const p of r.data.peers as { pub_key: string; online: boolean; lastHandshake: number; rx: number; tx: number }[]) {
const s = stats.get(p.pub_key) ?? { online: false, lastHandshake: 0, rx: 0, tx: 0 };
stats.set(p.pub_key, {
online: s.online || p.online, lastHandshake: Math.max(s.lastHandshake, p.lastHandshake),
rx: s.rx + p.rx, tx: s.tx + p.tx,
});
}
}));
res.json({
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => ({
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
...pick(stats.get(d.pub_key)),
})),
devices: (q.devicesOf.all(m.id) as DeviceRow[]).map(d => {
const links = q.linksOf.all(d.id) as LinkRow[];
const s = stats.get(d.pub_key);
return {
id: d.id, device_id: d.device_id, device_name: d.device_name, platform: d.platform, version: d.version, created_at: d.created_at,
last_seen: d.last_seen, rekey_requested: d.rekey_requested === 1,
online: s?.online ?? false, lastHandshake: s?.lastHandshake ?? 0, rx: s?.rx ?? 0, tx: s?.tx ?? 0,
servers_total: links.length, servers_ok: links.filter(l => l.peer_pub === d.pub_key && l.ip).length,
};
}),
});
}));
function pick(p?: { online: boolean; lastHandshake: number; rx: number; tx: number }) {
return { online: p?.online ?? false, lastHandshake: p?.lastHandshake ?? 0, rx: p?.rx ?? 0, tx: p?.tx ?? 0 };
}
const devices = Router();
devices.delete("/:id", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
await dropPeer(d.pub_key);
q.deviceDelete.run(d.id);
await reconcileAll(forgetDevice(d));
res.json({ success: true, id: d.id });
}));
@@ -449,15 +722,25 @@ export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir:
res.json({ id: d.id });
});
// Новый PSK на каждом сервере; недоступные получат его, когда появятся.
devices.post("/:id/psk", aw(async (req, res) => {
const d = q.deviceById.get(idOf(req)) as DeviceRow | undefined;
if (!d) { res.status(404).json({ error: "Не найдено" }); return; }
const r = await ctrl("POST", `/api/peers/${pubParam(d.pub_key)}/psk`);
res.status(hasCtrlOk(r.status) ? 200 : 502).json(hasCtrlOk(r.status) ? { id: d.id } : { error: "awg-ctrl отказал" });
q.linkPskDue.run(d.id);
await reconcileAll((q.linksOf.all(d.id) as LinkRow[]).map(l => l.server_id));
const pending = (q.linksOf.all(d.id) as LinkRow[]).filter(l => l.psk_pending === 1).length;
res.json({ id: d.id, pending });
}));
// ── Листенер ───────────────────────────────────────────────────────────
// ── Листенер и фоновое сведение ────────────────────────────────────────
servers.onOnline(id => { void reconcile(id); });
function start() {
// Сводим и без подписки: у ключей могут остаться хвосты на удаление.
setInterval(() => {
void reconcileAll((q.usedServers.all() as { server_id: number }[]).map(r => r.server_id));
}, SYNC_EVERY).unref();
if (!signKey) return;
if (!SUB_PORT) { console.warn("sub: SUB_PORT не задан — подписка отключена"); signKey = null; return; }
const server = tlsMaterial ? https.createServer(tlsMaterial, sub) : http.createServer(sub);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.0",
"version": "0.3.2",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+118 -7
View File
@@ -1,7 +1,7 @@
// 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 { spawn } from "child_process";
import { spawn, spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync,
mkdirSync, openSync, closeSync,
unlinkSync } from "fs";
@@ -100,14 +100,57 @@ const SERVICES = {
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_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 зашит в
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
},
pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"),
},
// Агент ноды: сам звонит в core (CORE_HOST/PORT/PIN из join-строки) и исполняет запросы
// на локальном awg-ctrl. Запускается вместо awg-ui — см. ROLE.
agent: {
label: "awg-agent",
cwd: path.join(ROOT, "awg-agent"),
entry: "index.ts",
env: {
CORE_HOST: process.env.CORE_HOST ?? "",
CORE_PORT: process.env.CORE_PORT ?? "",
CORE_PIN: process.env.CORE_PIN ?? "",
NODE_ID: process.env.NODE_ID ?? "",
NODE_KEY_FILE: process.env.NODE_KEY_FILE ?? "/etc/amnezia/amneziawg/node.key",
JOIN_SECRET: process.env.JOIN_SECRET ?? "",
CLI_ENV_FILE: envFile,
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
},
pidFile: "/tmp/awg-agent.pid",
logFile: path.join(LOGS, "awg-agent.log"),
},
} as const;
type SvcName = keyof typeof SERVICES;
const ALL: SvcName[] = ["awgctrl", "ui"];
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
const IS_NODE = !!process.env.CORE_HOST;
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
// Нода со своей панелью: работающий сервер «панель + 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 {
const { pidFile } = SERVICES[name];
@@ -270,7 +313,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
// ensureInterfaceUp() бросил исключение) — сразу выходим.
async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
for (let i = 0; i < attempts; i++) {
if (!isRunning(name)) return false;
@@ -281,12 +324,16 @@ async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
}
async function startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
await start("awgctrl");
const rest = ALL.filter(n => n !== "awgctrl");
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
console.error(` ${grey("■")} awg-ctrl не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
await start("ui");
for (const n of rest) await start(n);
}
async function stopAll() { for (const n of ALL) await stop(n); }
@@ -299,6 +346,68 @@ function updateEnvVar(content: string, key: string, value: string): string {
: content.trimEnd() + `\n${key}=${value}\n`;
}
function removeEnvVar(content: string, key: string): string {
return content.split("\n").filter(l => !l.startsWith(`${key}=`)).join("\n");
}
// Роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск делаем свежим процессом CLI, а не в этом же.
function restartFresh(): void {
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
}
// `join <awgjoin://…> [--no-panel]`: подключить эту установку к главной панели как ноду. Рядом
// запускается агент; awg-ctrl и пользователи остаются как были. Своя панель (awg-ui) продолжает
// работать, если она здесь есть, — с --no-panel она останавливается.
async function joinCore(link?: string, flag?: string) {
if (!link?.startsWith("awgjoin://") || (flag !== undefined && flag !== "--no-panel")) {
console.error(" Usage: awg-ctrl join <awgjoin://…> [--no-panel] (ссылку даёт панель: «Добавить сервер»)");
process.exit(1);
}
let j: { v?: number; h?: string; p?: number; pin?: string; n?: number; s?: string };
try { j = JSON.parse(Buffer.from(link.slice("awgjoin://".length), "base64url").toString("utf8")); }
catch { console.error(" Ссылка повреждена"); process.exit(1); }
const ok = j.v === 1 && typeof j.h === "string" && /^[A-Za-z0-9.:_-]{1,253}$/.test(j.h)
&& Number.isInteger(j.p) && j.p! > 0 && j.p! < 65536
&& typeof j.pin === "string" && /^[A-Za-z0-9_-]{43}$/.test(j.pin)
&& Number.isInteger(j.n) && j.n! > 0 && typeof j.s === "string" && /^[A-Za-z0-9_-]{22}$/.test(j.s);
if (!ok) { console.error(" Ссылка неверного формата или от другой версии панели"); process.exit(1); }
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") : "";
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!,
NODE_PANEL: keepPanel ? "on" : "off",
};
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
writeFileSync(envFile, content, { mode: 0o600 });
if (!keepPanel) await stop("ui");
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}` +
(keepPanel ? " · своя панель продолжает работать" : process.env.UI_PASS ? " · своя панель остановлена" : ""));
restartFresh();
}
async function unjoinCore() {
if (!IS_NODE) { console.log(" Эта установка не подключена к core"); return; }
await stop("agent");
let content = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
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 });
console.log(` ${green("✓")} Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart`);
// Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти.
if (!process.env.UI_PASS)
console.log(` ${dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")}`);
}
async function confirmYes(q: string): Promise<boolean> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const a = await new Promise<string>(r => rl.question(`${q} [y/N] `, r));
rl.close();
return /^y/i.test(a.trim());
}
async function setCredentials(field?: "user" | "pass") {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
@@ -393,9 +502,9 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>";
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) {
case "start": await startAll(); break;
@@ -407,6 +516,8 @@ async function main() {
else if (sub === "pass") await setCredentials("pass");
else await setCredentials();
break;
case "join": await joinCore(sub, arg); break;
case "unjoin": await unjoinCore(); break;
default: console.log(USAGE); process.exit(1);
}
}
+887 -521
View File
File diff suppressed because it is too large Load Diff