diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index e5d5f40..72c55b1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -43,7 +43,7 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" mkdir "awgcontrol-${VERSION}" - cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/" + cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" # Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b772b94..5fb11c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" mkdir "awgcontrol-${VERSION}" - cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/" + cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/" tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/" - name: Publish release diff --git a/awg-agent/index.ts b/awg-agent/index.ts new file mode 100644 index 0000000..f907e99 --- /dev/null +++ b/awg-agent/index.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Ivan Vasilev +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// +// Агент ноды. Сам открывает исходящее WSS-соединение к core (awg-ui), доказывает +// свою личность подписью Ed25519 и исполняет присланные запросы на локальном +// awg-ctrl (127.0.0.1). Входящих портов у ноды нет, awg-ctrl остаётся закрытым от сети. +// Протокол и формат join-строки — docs/node-protocol.md. +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import type { TLSSocket } from "tls"; +import axios from "axios"; +import WebSocket from "ws"; + +const CORE_HOST = process.env.CORE_HOST ?? ""; +const CORE_PORT = Number(process.env.CORE_PORT) || 0; +const CORE_PIN = process.env.CORE_PIN ?? ""; // SHA-256 SPKI сертификата core, base64url +const NODE_ID = Number(process.env.NODE_ID) || 0; +const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`; +const NODE_KEY_FILE = process.env.NODE_KEY_FILE ?? path.join(__dirname, "node.key"); +const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnezia/amneziawg/internal_auth_private.key"; +const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? ""; +let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения + +if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) { + console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`"); + process.exit(1); +} + +// ── Ключи ────────────────────────────────────────────────────────────────── +// Личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит. +function loadNodeKey(): crypto.KeyObject { + if (!fs.existsSync(NODE_KEY_FILE)) { + const { privateKey } = crypto.generateKeyPairSync("ed25519"); + fs.writeFileSync(NODE_KEY_FILE, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 }); + console.log("agent: создан ключ ноды " + NODE_KEY_FILE); + } + return crypto.createPrivateKey(fs.readFileSync(NODE_KEY_FILE)); +} +const nodeKey = loadNodeKey(); +const nodePubRaw = (() => { + const der = crypto.createPublicKey(nodeKey).export({ type: "spki", format: "der" }) as Buffer; + return der.subarray(der.length - 32).toString("base64url"); +})(); + +let internalKey: crypto.KeyObject | null = null; +try { internalKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); } +catch { console.warn("agent: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE); } + +// Тот же короткоживущий токен, что awg-ui шлёт своему awg-ctrl. +function mintInternalToken(): string { + if (!internalKey) throw new Error("no internal key"); + const t = Math.floor(Date.now() / 1000); + const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url"); + const p = Buffer.from(JSON.stringify({ iss: "awg-agent", iat: t, exp: t + 60 })).toString("base64url"); + return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), internalKey).toString("base64url")}`; +} + +// Дублируется в awg-ui/nodes.ts. +const authMsg = (nonce: string) => Buffer.from(`awg-node-v1\n${nonce}\n${NODE_ID}`); + +// ── Запросы core → awg-ctrl ──────────────────────────────────────────────── +const METHODS = new Set(["GET", "POST", "PUT", "DELETE"]); +// Агент — не открытый прокси: только API самого awg-ctrl. +function pathAllowed(p: unknown): p is string { + if (typeof p !== "string" || p.length > 2048 || !/^\/[\x21-\x7e]*$/.test(p)) return false; + try { + const u = new URL(p, "http://x"); + if (u.origin !== "http://x" || u.pathname.split("/").some(s => s === ".." || s === ".")) return false; + return u.pathname === "/health" || u.pathname.startsWith("/api/") || u.pathname.startsWith("/awg/"); + } catch { return false; } +} + +async function execute(m: { method?: unknown; path?: unknown; body?: unknown }): Promise<{ status: number; data: unknown }> { + const method = typeof m.method === "string" ? m.method.toUpperCase() : ""; + if (!METHODS.has(method) || !pathAllowed(m.path)) return { status: 400, data: { error: "Запрос не разрешён агентом" } }; + try { + const r = await axios({ + method, url: CTRL + m.path, + headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" }, + data: m.body, validateStatus: () => true, timeout: 110_000, + }); + return { status: r.status, data: r.data }; + } catch { + return { status: 502, data: { error: "awg-ctrl недоступен" } }; + } +} + +// ── Соединение с core ────────────────────────────────────────────────────── +function pinOf(sock: TLSSocket): string | null { + const raw = sock.getPeerCertificate(true)?.raw; + if (!raw) return null; + const spki = new crypto.X509Certificate(raw).publicKey.export({ type: "spki", format: "der" }) as Buffer; + return crypto.createHash("sha256").update(spki).digest("base64url"); +} + +// После первого успешного входа секрет больше не нужен — убираем его с диска. +function forgetJoinSecret() { + joinSecret = ""; + delete process.env.JOIN_SECRET; + if (!CLI_ENV_FILE) return; + try { + const kept = fs.readFileSync(CLI_ENV_FILE, "utf8").split("\n").filter(l => !l.startsWith("JOIN_SECRET=")); + fs.writeFileSync(CLI_ENV_FILE, kept.join("\n")); + } catch { /* не критично: секрет одноразовый и с истекающим сроком */ } +} + +let backoff = 1000; +const sleep = (ms: number) => new Promise(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 = { + 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(); diff --git a/awg-agent/package.json b/awg-agent/package.json new file mode 100644 index 0000000..b1456b0 --- /dev/null +++ b/awg-agent/package.json @@ -0,0 +1,19 @@ +{ + "name": "awg-agent", + "version": "0.3.0", + "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" + } +} diff --git a/awg-agent/tsconfig.json b/awg-agent/tsconfig.json new file mode 100644 index 0000000..954c4e3 --- /dev/null +++ b/awg-agent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["index.ts"] +} diff --git a/awg-ui/mock-server.ts b/awg-ui/mock-server.ts index 15e817a..9b700ae 100644 --- a/awg-ui/mock-server.ts +++ b/awg-ui/mock-server.ts @@ -23,6 +23,25 @@ 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 = { + 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; @@ -40,22 +59,42 @@ const find = (id: string) => masters.find(m => m.id === Number(id)); app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" })); app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" })); app.post("/logout", (_q, r) => r.json({ ok: true })); -app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } })); +app.get("/health", (q, r) => { + const id = srv(q), n = mnodes.find(x => x.id === id); + r.json({ status: "ok", server: n?.name ?? "VPN (mock)", ip: n?.ip ?? "203.0.113.7", gen: n?.gen ?? GEN, awg: { status: "ok", peers: usersOf[id].length, module: "3.0.1", tools: "1.0.0" } }); +}); + +// ── серверы ── +const nodeView = () => [ + { id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } }, + ...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null, + health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })), +]; +app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() })); +app.post("/ui/nodes", (q, r) => { + const name = String(q.body.name ?? ""); + if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" }); + const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true }); + r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 }); +}); +app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); }); +app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); }); app.post("/awg/restart", (_q, r) => r.json({ up: true })); app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length })); -app.get("/api/users", (_q, r) => r.json({ users })); -app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) })); -app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" })); +app.get("/api/users", (q, r) => r.json({ users: U_(q) })); +app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) })); +app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" })); app.post("/api/users", (q, r) => { + const users = U_(q); const name = String(q.body.name ?? ""); if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" }); if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" }); const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 }; users.push(u); r.status(201).json(u); }); -app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); }); -app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); }); +app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); }); +app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); }); app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys })); app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); }); @@ -81,4 +120,5 @@ app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.de app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); }); app.post("/ui/devices/:id/psk", (_q, r) => r.json({})); -app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)")); +const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080; +app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`)); diff --git a/awg-ui/nodes.ts b/awg-ui/nodes.ts new file mode 100644 index 0000000..15fd2a8 --- /dev/null +++ b/awg-ui/nodes.ts @@ -0,0 +1,345 @@ +// 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 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(); + const health = new Map(); + 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); + + /** Сервер по умолчанию: локальный, а без него — первая нода. */ + 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(); + 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); + } + + 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); + 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_keys"] 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); + res.json({ success: true, id }); + }); + + return { router, ctrlFor, exists, defaultId, start }; +} diff --git a/awg-ui/package.json b/awg-ui/package.json index 7ea5937..921500e 100644 --- a/awg-ui/package.json +++ b/awg-ui/package.json @@ -15,20 +15,22 @@ "better-sqlite3": "^12.10.0", "qrcode": "^1.5.0", "react": "^18.3.0", - "react-dom": "^18.3.0" + "react-dom": "^18.3.0", + "ws": "^8.22.0" }, "devDependencies": { - "concurrently": "^9.0.0", "@types/better-sqlite3": "^7.6.13", - "@types/qrcode": "^1.5.0", "@types/express": "^4.17.0", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "@types/ws": "^8.18.1", "@vitejs/plugin-react": "^4.3.0", + "concurrently": "^9.0.0", "express": "^4.19.0", "tsx": "^4.7.0", "typescript": "^5.4.0", "vite": "^5.4.0" } -} \ No newline at end of file +} diff --git a/awg-ui/server.ts b/awg-ui/server.ts index 217271f..cb80414 100644 --- a/awg-ui/server.ts +++ b/awg-ui/server.ts @@ -8,6 +8,7 @@ import crypto from "crypto"; import axios from "axios"; import Database from "better-sqlite3"; import { createSub } from "./sub"; +import { createNodes } from "./nodes"; const app = express(); const PORT = Number(process.env.PORT) || 8080; @@ -180,10 +181,10 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => { if (!label || !/^[\w \-]{1,40}$/.test(label)) { res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return; } - // TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится - // список серверов — валидировать его против реального реестра серверов. + const sid = server_id === undefined ? nodes.defaultId() : Number(server_id); + if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; } const { key, hash, prefix } = genApiKey(); - const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000)); + const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000)); // Открытое значение отдаётся один раз — дальше в БД только хэш. res.status(201).json({ id: info.lastInsertRowid, label, prefix, key }); }); @@ -195,25 +196,24 @@ app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => { res.json({ success: true, id }); }); +// Сервер, к которому относится запрос панели: заголовок X-Server-Id (0 — локальный +// awg-ctrl, остальное — нода). Без заголовка — сервер по умолчанию. +function serverIdOf(req: Request): number { + const raw = req.headers["x-server-id"]; + if (raw === undefined) return nodes.defaultId(); + const n = Number(raw); + return Number.isInteger(n) && n >= 0 ? n : nodes.defaultId(); +} + async function proxy(req: Request, res: Response) { try { - const r = await axios({ - method: req.method, - url: CTRL + req.originalUrl, - headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" }, - data: req.body, - validateStatus: () => true, - }); + const r = await nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body); res.status(r.status).json(r.data); } catch { res.status(502).json({ error: "awg-ctrl недоступен" }); } } -// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий -// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный -// awg-ctrl по (req as ExtRequest).apiKey.server_id. - interface ExtRequest extends Request { apiKey?: ApiKeyRow; } function requireApiKey(req: Request, res: Response, next: NextFunction) { @@ -243,6 +243,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string } const ext = express.Router(); ext.use(requireApiKey); +// Ключ привязан к server_id — все вызовы уходят на его сервер. +const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0); + // Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не // отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ // тот же, что у proxy() в такой ситуации. @@ -253,26 +256,26 @@ const wrap = (fn: Handler) => (req: Request, res: Response) => { ext.post("/users", wrap(async (req, res) => { const { name } = (req.body ?? {}) as { name?: string }; - const r = await ctrl("POST", "/api/users", { name }); + const r = await ctrlOf(req)("POST", "/api/users", { name }); if (r.status >= 400) { res.status(r.status).json(r.data); return; } const u = r.data as ExtUser; res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); })); -ext.get("/users", wrap(async (_req, res) => { - const r = await ctrl("GET", "/api/users/stats"); +ext.get("/users", wrap(async (req, res) => { + const r = await ctrlOf(req)("GET", "/api/users/stats"); res.status(r.status).json(r.data); })); ext.get("/users/:name", wrap(async (req, res) => { - const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`); + const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`); if (r.status >= 400) { res.status(r.status).json(r.data); return; } const u = r.data as ExtUser; res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key }); })); ext.delete("/users/:name", wrap(async (req, res) => { - const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); + const r = await ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`); res.status(r.status).json(r.data); })); @@ -282,7 +285,12 @@ app.use("/api/v1", ext); // sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный // листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется. -const sub = createSub({ uidb, ctrl, baseDir: __dirname }); +// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db. +const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname }); +app.use("/ui/nodes", requireAuth, nodes.router); + +// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг. +const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname }); app.use("/ui/masterkeys", requireAuth, sub.masterKeys); app.use("/ui/devices", requireAuth, sub.devices); @@ -295,4 +303,5 @@ app.get("*", (_req: Request, res: Response) => { }); app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); }); +nodes.start(); sub.start(); \ No newline at end of file diff --git a/awg-ui/src/App.css b/awg-ui/src/App.css index e5c268c..09115f0 100644 --- a/awg-ui/src/App.css +++ b/awg-ui/src/App.css @@ -375,6 +375,7 @@ body { .server-bar { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; } .server-card { @@ -389,6 +390,45 @@ body { user-select: none; } .server-card--active { border-color: var(--primary-container); } +.server-card--inactive { cursor: pointer; } +.server-card--inactive:hover { background: var(--state-primary); } +.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } + +/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */ +.server-add { + background: transparent; + border: 1.5px dashed var(--outline); + color: var(--neutral-text); +} +.server-add:disabled { opacity: .6; cursor: not-allowed; } + +/* Модалка добавления ноды */ +.node-scrim { + position: fixed; inset: 0; z-index: 60; + background: var(--scrim); + display: flex; align-items: center; justify-content: center; + padding: 16px; +} +.node-modal { + width: 100%; max-width: 520px; + background: var(--surface-bright); + border: 1px solid var(--outline-variant); + border-radius: var(--r-card); + padding: 20px; + display: flex; flex-direction: column; gap: 12px; +} +.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); } +.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); } +.node-modal-cmd { + display: block; + font-family: var(--mono); font-size: 12px; + background: var(--surface-container); + border-radius: var(--r-table); + padding: 12px; + word-break: break-all; + user-select: all; +} +.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; } .server-card-info { display: flex; flex-direction: column; @@ -656,6 +696,9 @@ tbody td:last-child { padding-right: 20px; } border-color: var(--primary-container); padding: 12px 14px; } + .server-card--inactive { border-color: var(--outline-variant); } + .server-add { width: 100%; justify-content: center; } + .node-modal-actions .btn { flex: 1; } .server-toggle { display: flex; } .server-online-chip { display: none; } diff --git a/awg-ui/src/App.tsx b/awg-ui/src/App.tsx index 3a47af5..c751dbb 100644 --- a/awg-ui/src/App.tsx +++ b/awg-ui/src/App.tsx @@ -4,20 +4,27 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import axios from 'axios'; import './App.css'; -import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared'; +import { + TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId, + type NodeInfo, type Brand, +} from './lib/shared'; import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons'; import ServerBar from './components/ServerBar'; +import AddServerModal from './components/AddServerModal'; import { TABS } from './tabs'; // Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token, -// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит +// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит // из реестра TABS — App про конкретные вкладки ничего не знает. export default function App() { const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? ''); const [loginUser, setLoginUser] = useState(''); const [loginPass, setLoginPass] = useState(''); const [statusText, setStatusText] = useState(''); - const [serverInfo, setServerInfo] = useState(null); + const [nodes, setNodes] = useState([]); + const [hubEnabled, setHubEnabled] = useState(false); + const [serverId, setServerIdState] = useState(null); + const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null); const [msg, setMsg] = useState(''); const [drawerOpen, setDrawerOpen] = useState(false); const [activeTab, setActiveTab] = useState(TABS[0].id); @@ -33,17 +40,34 @@ export default function App() { setTimeout(() => setMsg(''), 3000); }, []); - // Проверяет токен и подтягивает данные сервера (/health). Данные конкретных - // вкладок (юзеры, ключи) грузят сами вкладки. - const startSession = useCallback(async (tok: string) => { - const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } }); - setServerInfo({ - name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2', - mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '', - }); - setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?')); + // Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок + // (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним). + const pickServer = useCallback((id: number | null) => { + setServerId(id); + setServerIdState(id); + try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ } }, []); + // Список серверов (core + ноды) с их состоянием. Если выбранного больше нет — + // берём сохранённый, иначе первый. + const loadNodes = useCallback(async (tok: string) => { + const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } }); + const list: NodeInfo[] = data.nodes ?? []; + setNodes(list); + setHubEnabled(!!data.hub); + const cur = getServerId(); + if (cur !== null && list.some(n => n.id === cur)) return; + let saved: number | null = null; + try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ } + pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null); + }, [pickServer]); + + // Проверяет токен и подтягивает список серверов. Данные конкретных вкладок + // (юзеры, ключи) грузят сами вкладки. + const startSession = useCallback(async (tok: string) => { + await loadNodes(tok); + }, [loadNodes]); + const login = useCallback(async () => { try { const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass }); @@ -69,10 +93,23 @@ export default function App() { localStorage.removeItem(TOKEN_KEY); setToken(''); setStatusText(''); - setServerInfo(null); + setNodes([]); + setServerId(null); + setServerIdState(null); setDrawerOpen(false); }, [token]); + const deleteNode = useCallback(async (n: NodeInfo) => { + if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return; + try { + await apiFetch('DELETE', `/ui/nodes/${n.id}`, token); + showMsg('Сервер удалён'); + await loadNodes(token); + } catch (e) { + showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить'); + } + }, [token, showMsg, loadNodes]); + // Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl). // Соединения клиентов кратковременно прерываются — поэтому подтверждение. const restartAwg = useCallback(async () => { @@ -111,6 +148,13 @@ export default function App() { } }, [startSession]); + // Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель. + useEffect(() => { + if (!token) return; + const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000); + return () => clearInterval(t); + }, [token, loadNodes]); + // Имя продукта и версия — с сервера, до логина (роут без авторизации). // Если не ответил, остаётся фолбэк из констант. useEffect(() => { @@ -143,8 +187,7 @@ export default function App() { return () => document.removeEventListener('keydown', h); }, [drawerOpen]); - const isOnline = statusText.startsWith('online:'); - const statusLabel = statusText.replace(/^(online|offline):/, ''); + const statusLabel = statusText.replace(/^(online|offline):/, ''); const tab = TABS.find(t => t.id === activeTab) ?? TABS[0]; // Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён. const chrome = tab.chrome ?? {}; @@ -161,7 +204,7 @@ export default function App() { {theme === 'dark' ? : } - {token && showRestart && ( + {token && showRestart && serverId !== null && ( + ) : !node && ( + + )} + + + + + ); +} diff --git a/awg-ui/src/components/ServerBar.tsx b/awg-ui/src/components/ServerBar.tsx index 64fca2c..e5d8ca5 100644 --- a/awg-ui/src/components/ServerBar.tsx +++ b/awg-ui/src/components/ServerBar.tsx @@ -1,56 +1,104 @@ // Copyright (c) 2026 Ivan Vasilev // This source code is licensed under the MIT license found in the // LICENSE file in the root directory of this source tree. -import { type ServerInfo, genLabel } from '../lib/shared'; -import { IcoRefresh } from './icons'; +import { type NodeInfo, genLabel } from '../lib/shared'; +import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons'; -// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под -// ней. Общий для вкладок — показывает сервер, к которому относится содержимое -// вкладки (пиры / абоненты). +// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и +// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает +// серверы, между которыми переключается содержимое вкладки (пиры / абоненты). export default function ServerBar({ - serverInfo, serverOnline, onRestartAwg, restarting, - showRestart = true, + nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete, + onRestartAwg, restarting, showRestart = true, }: { - serverInfo: ServerInfo | null; - serverOnline: boolean; + nodes: NodeInfo[]; + serverId: number | null; + hubEnabled: boolean; + onSelect: (id: number) => void; + onAdd: () => void; + onJoin: (n: NodeInfo) => void; + onDelete: (n: NodeInfo) => void; onRestartAwg: () => void; restarting: boolean; showRestart?: boolean; }) { return (
- {serverInfo && ( -
- {/* toggle — виден на мобильном вместо чипа */} + {nodes.map(n => { + const h = n.health; + const active = n.id === serverId; + return ( - )} + ); + })} + + + {/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */} - {showRestart && ( + {showRestart && serverId !== null && (