5 Commits
Author SHA1 Message Date
maeneko 1278ad527d Add /sub/v1/devices and /sub/v1/peek, count the slots on register
release / release (push) Successful in 3m14s
- GET /sub/v1/devices: a device lists the devices of its own master key
  (name, platform, version, last seen, and which one is asking). Read-only:
  unbinding is still only DELETE /sub/v1/device for oneself.
- POST /sub/v1/peek: how many slots of a key are taken, asked with the
  link's secret before any device exists, so the client can show it while
  the link is only pasted. Wrong secrets count against the failure limit.
- register also answers with devices / device_limit, so the client needs no
  second request to show the slot it just took.
2026-09-25 17:24:52 +03:00
maeneko 6e010a0141 Bump to Beta 0.3.0
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

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

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

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

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

iptables joins the dependencies — PostUp calls it, and a minimal Debian 13 on
nftables doesn't have it. IP forwarding moves to its own file in
/etc/sysctl.d: Debian 13 no longer reads /etc/sysctl.conf at boot, so the old
line would have held only until the first reboot.
2026-09-10 17:51:19 +03:00
24 changed files with 742 additions and 2428 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.2
VERSION=0.3.0
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui 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 awg-agent cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui 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.2" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `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
@@ -1,170 +0,0 @@
// 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
@@ -1,19 +0,0 @@
{
"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
@@ -1,12 +0,0 @@
{
"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.2",
"version": "0.3.0",
"description": "",
"main": "dist/index.js",
"scripts": {
+14 -60
View File
@@ -23,80 +23,39 @@ 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[]; servers: number[] }
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[] }
let mkId = 1, devId = 1;
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
const masters: MK[] = [{ id: mkId++, 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 signPub = crypto.randomBytes(32);
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at });
// Мок: устройство «догнано» на всех серверах ключа, кроме 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 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 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) => {
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.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.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: 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.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.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 = 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.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.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") }); });
@@ -106,14 +65,10 @@ app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: m
app.post("/ui/masterkeys", (q, r) => {
const limit = q.body.device_limit ?? 3;
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [] };
masters.push(m); r.status(201).json(mkOut(m));
});
app.patch("/ui/masterkeys/:id", (q, r) => {
const m = find(q.params.id); if (!m) return r.status(404).json({ error: "Не найден" });
if (q.body.servers !== undefined) { if (!Array.isArray(q.body.servers) || !q.body.servers.length) return r.status(400).json({ error: "Нужен хотя бы один сервер" }); m.servers = [...q.body.servers].sort(); }
if (q.body.label) m.label = q.body.label; if (q.body.device_limit) m.device_limit = q.body.device_limit; r.json(mkOut(m));
});
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.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 }); });
@@ -121,10 +76,9 @@ 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.map(d => devOut(m, d)) }) : 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 }) : 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({ pending: 0 }));
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
-363
View File
@@ -1,363 +0,0 @@
// 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 };
}
+5 -7
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.2",
"version": "0.3.0",
"private": true,
"scripts": {
"dev": "vite",
@@ -15,22 +15,20 @@
"better-sqlite3": "^12.10.0",
"qrcode": "^1.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"ws": "^8.22.0"
"react-dom": "^18.3.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"
}
}
}
+20 -29
View File
@@ -8,7 +8,6 @@ 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;
@@ -181,10 +180,10 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
@@ -196,24 +195,25 @@ 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 nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body);
const r = await axios({
method: req.method,
url: CTRL + req.originalUrl,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: req.body,
validateStatus: () => true,
});
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,9 +243,6 @@ 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() в такой ситуации.
@@ -256,26 +253,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 ctrlOf(req)("POST", "/api/users", { name });
const r = await ctrl("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 ctrlOf(req)("GET", "/api/users/stats");
ext.get("/users", wrap(async (_req, res) => {
const r = await ctrl("GET", "/api/users/stats");
res.status(r.status).json(r.data);
}));
ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrl("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 ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
}));
@@ -285,12 +282,7 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
// Несколько серверов: 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 });
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
@@ -303,5 +295,4 @@ app.get("*", (_req: Request, res: Response) => {
});
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
nodes.start();
sub.start();
-43
View File
@@ -375,7 +375,6 @@ body {
.server-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.server-card {
@@ -390,45 +389,6 @@ 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;
@@ -696,9 +656,6 @@ 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; }
+19 -84
View File
@@ -4,27 +4,20 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './App.css';
import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
type NodeInfo, type Brand,
} from './lib/shared';
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, 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 [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 [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -40,33 +33,16 @@ export default function App() {
setTimeout(() => setMsg(''), 3000);
}, []);
// Выбрать сервер: 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]);
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
// (юзеры, ключи) грузят сами вкладки.
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
// вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
await loadNodes(tok);
}, [loadNodes]);
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 ?? '?'));
}, []);
const login = useCallback(async () => {
try {
@@ -93,23 +69,10 @@ export default function App() {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setStatusText('');
setNodes([]);
setServerId(null);
setServerIdState(null);
setServerInfo(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 () => {
@@ -148,13 +111,6 @@ export default function App() {
}
}, [startSession]);
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
useEffect(() => {
if (!token) return;
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
@@ -187,7 +143,8 @@ export default function App() {
return () => document.removeEventListener('keydown', h);
}, [drawerOpen]);
const statusLabel = statusText.replace(/^(online|offline):/, '');
const isOnline = statusText.startsWith('online:');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
const chrome = tab.chrome ?? {};
@@ -204,7 +161,7 @@ export default function App() {
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && serverId !== null && (
{token && showRestart && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
@@ -300,41 +257,19 @@ export default function App() {
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
serverInfo={serverInfo}
serverOnline={isOnline}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
)}
{serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
</p>
) : (
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
)}
<tab.Page 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
@@ -1,102 +0,0 @@
// 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>
);
}
+36 -84
View File
@@ -1,104 +1,56 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type NodeInfo, genLabel } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoRefresh } from './icons';
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
export default function ServerBar({
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
onRestartAwg, restarting, showRestart = true,
serverInfo, serverOnline, onRestartAwg, restarting,
showRestart = true,
}: {
nodes: NodeInfo[];
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
serverInfo: ServerInfo | null;
serverOnline: boolean;
onRestartAwg: () => void;
restarting: boolean;
showRestart?: boolean;
}) {
return (
<div className="server-bar">
{nodes.map(n => {
const h = n.health;
const active = n.id === serverId;
return (
{serverInfo && (
<div className="server-card server-card--active">
{/* toggle — виден на мобильном вместо чипа */}
<div
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); } }}
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
aria-hidden="true"
>
{/* 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); }}
<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 || '?'}`}
>
<IcoCopy />
</button>
)}
{!n.local && (
<button
className="btn-icon btn-icon--danger"
aria-label={`Удалить сервер ${n.name}`}
onClick={e => { e.stopPropagation(); onDelete(n); }}
>
<IcoTrash />
</button>
<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>
)}
</div>
);
})}
<span
className="tip-wrap"
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
>
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
<IcoPlus /> Добавить сервер
</button>
</span>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && serverId !== null && (
{showRestart && (
<button
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
onClick={onRestartAwg}
@@ -109,4 +61,4 @@ export default function ServerBar({
)}
</div>
);
}
}
+3 -25
View File
@@ -47,32 +47,10 @@ 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 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 const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
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 });
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, 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, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
import { apiFetch, copyText, timeAgo, 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: getServerId() ?? 0 });
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
+24 -51
View File
@@ -3,25 +3,19 @@
// 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 NodeInfo, type PageProps } from '../../lib/shared';
import { apiFetch, copyText, bytes, timeAgo, genLabel, type AwgGen, 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; servers: number[]; created_at: number }
interface MasterKey { id: number; label: string; device_limit: number; devices: 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) : '—';
@@ -32,7 +26,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const [keys, setKeys] = useState<MasterKey[]>([]);
const [enabled, setEnabled] = useState(true);
const [tls, setTls] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]); // серверы, которые можно включить в ключ
const [server, setServer] = useState<{ name: string; ip: string; gen: string } | null>(null);
const [label, setLabel] = useState('');
const [limit, setLimit] = useState('3');
const [devs, setDevs] = useState<Record<number, Device[]>>({});
@@ -46,7 +40,8 @@ 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', '/ui/nodes', token).then(r => setNodes(r.nodes ?? [])).catch(() => {});
// Сервер, который сейчас отдаётся по ключу (он пока единственный).
apiFetch('GET', '/health', token).then(h => setServer({ name: h.server, ip: h.ip, gen: h.gen })).catch(() => {});
const all = await Promise.all(list.map(k =>
apiFetch('GET', `/ui/masterkeys/${k.id}/devices`, token).then(r => [k.id, r.devices ?? []] as const)));
setDevs(Object.fromEntries(all));
@@ -62,14 +57,10 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const create = useCallback(async () => {
if (!label.trim()) return;
await act(async () => {
// Новый ключ отдаёт все серверы, которые уже подключались; набор потом правится в настройках ключа.
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 } : {}),
});
await apiFetch('POST', '/ui/masterkeys', token, { label: label.trim(), device_limit: Number(limit) });
setLabel('');
}, 'Мастер-ключ создан');
}, [label, limit, nodes, token, act]);
}, [label, limit, token, act]);
const remove = useCallback(async (k: MasterKey) => {
if (!confirm(`Удалить «${k.label}»? Все ${k.devices} устройств потеряют доступ.`)) return;
@@ -155,32 +146,20 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
</div>
</section>
{/* Справа — какие серверы отдаёт ключ: в config.servers[] каждого устройства. Снять галочку —
пиры его устройств с этого сервера удалятся; поставить — добавятся. Последний не снимается. */}
{/* Справа — какие серверы отдаются пользователю. Заглушка: сервер один и всегда включён.
TODO multi-server: список серверов из реестра, выбор по галочкам; в config.servers[]
уйдут только выбранные (подписка уже отдаёт servers[] массивом с id). */}
<section className="mk-box">
<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>
<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>
</section>
</div>
);
@@ -202,13 +181,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
const deviceActions = (d: Device) => (
<div className="actions">
<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" title="Новый PSK" aria-label="Новый PSK" onClick={() =>
act(() => apiFetch('POST', `/ui/devices/${d.id}/psk`, token), 'PSK обновлён')}><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), 'Устройство отозвано');
@@ -231,7 +205,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 ? ' · ждёт смены ключа' : ''}{syncLabel(d)}</span>
{d.rekey_requested ? ' · ждёт смены ключа' : ''}</span>
</div>
<span className={`chip chip--${d.online ? 'online' : 'offline'}`}>
<span className="chip-dot" />{d.online ? 'Онлайн' : 'Офлайн'}
@@ -302,8 +276,7 @@ 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>}
{syncLabel(d) && <span className="mk-flag">{syncLabel(d)}</span>}</td>
{d.rekey_requested && <span className="mk-flag"> · ждёт смены ключа</span>}</td>
<td></td>
<td className="td-actions mk-td-actions">{deviceActions(d)}</td>
</tr>
+7 -5
View File
@@ -162,11 +162,13 @@ 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 { 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-server-add {
width: 100%; justify-content: center;
background: transparent;
border: 1px dashed var(--outline);
color: var(--neutral-dot);
cursor: not-allowed;
}
.mk-box .tip-wrap { width: 100%; }
/* дети: вертикальная линия слева и «ножка» к каждому узлу */
+70 -353
View File
@@ -8,14 +8,6 @@
//
// 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";
@@ -30,16 +22,6 @@ 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;
@@ -49,19 +31,9 @@ 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}$/; // версия приложения, чисто информационная
@@ -81,15 +53,12 @@ function makeLimiter(max: number, windowMs: number) {
};
}
export function createSub(deps: { uidb: Database.Database; servers: Servers; baseDir: string }) {
const { uidb, servers } = deps;
export function createSub(deps: { uidb: Database.Database; ctrl: Ctrl; baseDir: string }) {
const { uidb, ctrl } = 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;
@@ -140,38 +109,6 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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
);
`);
// Версия клиента — необязательное поле, добавленное позже: у уже созданной
// таблицы колонки может не быть.
@@ -179,30 +116,14 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
masterInsert: uidb.prepare<[string, Buffer, number, number]>("INSERT INTO master_keys (label, secret, device_limit, 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 = ?"),
@@ -212,171 +133,47 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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 = ?"),
devicePub: uidb.prepare<[string, number, number]>("UPDATE devices SET pub_key = ?, rekey_requested = ? WHERE id = ?"),
deviceRekeyed: uidb.prepare<[string, number]>("UPDATE devices SET pub_key = ?, rekey_requested = 0 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);
// ── Сведение серверов ──────────────────────────────────────────────────
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> {
// ── Общие куски ────────────────────────────────────────────────────────
async function profile() {
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;
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>;
};
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(":"));
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,
});
}
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");
const cfg = {
rekey: dev.rekey_requested === 1,
endpoints: [`${subHost(req)}:${SUB_PORT}`],
servers: list,
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,
}],
};
// rev меняется при любом изменении; клиент по нему решает, трогать ли туннель.
const rev = crypto.createHash("sha256").update(JSON.stringify(cfg)).digest("hex").slice(0, 16);
return { rev, ...cfg };
}
// Конфиг, в котором есть хотя бы один сервер. Пустой не отдаём: клиент снёс бы туннель
// из-за временной недоступности нод — пусть лучше живёт со старым конфигом.
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;
async function dropPeer(pub: string) {
await ctrl("DELETE", `/api/peers/${pubParam(pub)}`); // 404 — уже нет, не страшно
}
// ── Публичная часть: /sub/v1 ───────────────────────────────────────────
@@ -448,38 +245,34 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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 touched: number[] = [];
let oldPub: string | null = null;
try {
uidb.transaction(() => {
const existing = q.deviceByKey.get(master.id, deviceId) as DeviceRow | undefined;
if (existing) touched = forgetDevice(existing); // переустановка — старые пиры на удаление
if (existing) { oldPub = existing.pub_key; q.deviceDelete.run(existing.id); }
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 уже заняты
}
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" });
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" });
return;
}
const dev = q.deviceById.get(deviceRowId) as DeviceRow;
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
// без отдельного запроса списка.
send(res, 201, {
device: dev.id, config: cfg,
device: dev.id, config: await buildConfig(dev),
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
});
}));
@@ -505,7 +298,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
// Версия клиента меняется при обновлении приложения; заголовок не подписан — это лишь подпись в панели.
const v = String(req.headers["x-sen-version"] ?? "");
if (CLIENT_VERSION.test(v) && v !== dev.version) q.deviceVersion.run(v, dev.id);
send(res, 200, { config: await readyConfig(dev, req) });
send(res, 200, { config: await buildConfig(dev) });
}));
// Устройства того же мастер-ключа: клиент показывает их на вкладке «Ключ». Только чтение — отвязать
@@ -524,31 +317,20 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
});
}));
// Новый ключ ставится на все серверы ключа: где сервер доступен — сразу (тот же 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; }
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 });
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) });
}));
sub.delete("/sub/v1/device", deviceAuth, wrap(async (req, res) => {
const dev = (req as DevReq).device as DeviceRow;
await reconcileAll(forgetDevice(dev));
await dropPeer(dev.pub_key);
q.deviceDelete.run(dev.id);
send(res, 200, { ok: true });
}));
@@ -563,15 +345,6 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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) => {
@@ -580,62 +353,30 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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, servers: serverIdsOf(m.id), created_at: m.created_at,
devices: m.devices, server_id: m.server_id, created_at: m.created_at,
})),
});
});
masterKeys.post("/", (req, res) => {
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
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 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 });
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 });
});
masterKeys.patch("/:id", aw(async (req, res) => {
masterKeys.patch("/:id", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const { label, device_limit, servers: wanted } = (req.body ?? {}) as { label?: string; device_limit?: number; servers?: unknown };
const { label, device_limit } = (req.body ?? {}) as { label?: string; device_limit?: number };
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);
await reconcileAll(affected);
res.json({ id: m.id, label: nl, device_limit: nd, servers: ids });
}));
res.json({ id: m.id, label: nl, device_limit: nd });
});
masterKeys.post("/:id/rotate", (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
@@ -654,64 +395,50 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
masterKeys.delete("/:id", aw(async (req, res) => {
const m = q.masterById.get(idOf(req)) as MasterRow | undefined;
if (!m) { res.status(404).json({ error: "Не найден" }); return; }
const affected: number[] = [];
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) affected.push(...forgetDevice(d));
q.masterDelete.run(m.id); // master_servers уйдут каскадом
await reconcileAll(affected);
for (const d of q.devicesOf.all(m.id) as DeviceRow[]) await dropPeer(d.pub_key);
q.masterDelete.run(m.id); // devices уйдут каскадом
res.json({ success: true, id: m.id });
}));
masterKeys.get("/:id/link", (req, res) => {
masterKeys.get("/:id/link", aw(async (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 host = subHost(req);
if (!host) { res.status(503).json({ error: "Не удалось определить адрес панели — задайте SUB_HOST" }); return; }
const prof = await profile();
const link = encodeSenLink({
tls: tlsPin !== null,
addrs: [{ host, port: SUB_PORT }],
addrs: [{ host: hostOf(prof.endpoint), 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 }>();
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,
});
}
}));
const r = await ctrl("GET", "/api/peers");
if (hasCtrlOk(r.status)) for (const p of r.data.peers) stats.set(p.pub_key, p);
res.json({
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,
};
}),
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)),
})),
});
}));
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 reconcileAll(forgetDevice(d));
await dropPeer(d.pub_key);
q.deviceDelete.run(d.id);
res.json({ success: true, id: d.id });
}));
@@ -722,25 +449,15 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
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; }
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 });
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 отказал" });
}));
// ── Листенер и фоновое сведение ────────────────────────────────────────
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.2",
"version": "0.3.0",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+7 -118
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, spawnSync } from "child_process";
import { spawn } from "child_process";
import { readFileSync, writeFileSync, existsSync,
mkdirSync, openSync, closeSync,
unlinkSync } from "fs";
@@ -100,57 +100,14 @@ 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;
// Роль установки. 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"];
const ALL: SvcName[] = ["awgctrl", "ui"];
function readPid(name: SvcName): number | null {
const { pidFile } = SERVICES[name];
@@ -313,7 +270,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
// ensureInterfaceUp() бросил исключение) — сразу выходим.
async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
async function waitForHealth(name: SvcName, 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;
@@ -324,16 +281,12 @@ async function waitForHealth(name: "awgctrl", 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 не отвечает — ${rest.map(n => SERVICES[n].label).join(", ")} не запущен (см. ${SERVICES.awgctrl.logFile})`);
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
for (const n of rest) await start(n);
await start("ui");
}
async function stopAll() { for (const n of ALL) await stop(n); }
@@ -346,68 +299,6 @@ 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));
@@ -502,9 +393,9 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin>";
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>";
const [,,, sub, arg] = process.argv;
const [,,, sub] = process.argv;
switch (cmd) {
case "start": await startAll(); break;
@@ -516,8 +407,6 @@ 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);
}
}
+529 -895
View File
File diff suppressed because it is too large Load Diff