Compare commits
5
Commits
1e73aaae6a
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1278ad527d | ||
|
|
6e010a0141 | ||
|
|
923bd87fe9 | ||
|
|
d5e00ddaf8 | ||
|
|
2b46d04af9 |
@@ -11,4 +11,4 @@
|
|||||||
|
|
||||||
BRAND=Forgetting
|
BRAND=Forgetting
|
||||||
CHANNEL=Beta
|
CHANNEL=Beta
|
||||||
VERSION=0.3.1
|
VERSION=0.3.0
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
mkdir "awgcontrol-${VERSION}"
|
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}/"
|
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||||
|
|
||||||
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
|
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION="${GITHUB_REF_NAME#v}"
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
mkdir "awgcontrol-${VERSION}"
|
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}/"
|
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
|
|||||||
|
|
||||||
| Метод | Ответ |
|
| Метод | Ответ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
|
||||||
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
|
||||||
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
|
||||||
|
|
||||||
|
|||||||
@@ -1,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();
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "awg-agent",
|
|
||||||
"version": "0.3.1",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es2020",
|
|
||||||
"module": "commonjs",
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["index.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-control",
|
"name": "awg-control",
|
||||||
"version": "0.3.1",
|
"version": "0.3.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+7
-47
@@ -23,25 +23,6 @@ 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),
|
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-ключи ──
|
// ── 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 }];
|
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;
|
let apiId = 1;
|
||||||
@@ -59,42 +40,22 @@ const find = (id: string) => masters.find(m => m.id === Number(id));
|
|||||||
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
|
||||||
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("/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.post("/logout", (_q, r) => r.json({ ok: true }));
|
||||||
app.get("/health", (q, r) => {
|
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" } }));
|
||||||
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.post("/awg/restart", (_q, r) => r.json({ up: true }));
|
||||||
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
|
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", (_q, r) => r.json({ users }));
|
||||||
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
|
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: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
|
app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" }));
|
||||||
app.post("/api/users", (q, r) => {
|
app.post("/api/users", (q, r) => {
|
||||||
const users = U_(q);
|
|
||||||
const name = String(q.body.name ?? "");
|
const name = String(q.body.name ?? "");
|
||||||
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
|
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: "Пользователь уже существует" });
|
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 };
|
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);
|
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.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 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.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.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") }); });
|
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") }); });
|
||||||
@@ -120,5 +81,4 @@ 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/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
|
||||||
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
|
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
|
||||||
|
|
||||||
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
|
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
|
||||||
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
|
|
||||||
|
|||||||
-345
@@ -1,345 +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);
|
|
||||||
|
|
||||||
/** Сервер по умолчанию: локальный, а без него — первая нода. */
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
+4
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-ui",
|
"name": "awg-ui",
|
||||||
"version": "0.3.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -15,19 +15,17 @@
|
|||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.0",
|
||||||
"qrcode": "^1.5.0",
|
"qrcode": "^1.5.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0"
|
||||||
"ws": "^8.22.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"@types/qrcode": "^1.5.0",
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/qrcode": "^1.5.0",
|
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/ws": "^8.18.1",
|
|
||||||
"@vitejs/plugin-react": "^4.3.0",
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
"concurrently": "^9.0.0",
|
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
|
|||||||
+20
-29
@@ -8,7 +8,6 @@ import crypto from "crypto";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import { createSub } from "./sub";
|
import { createSub } from "./sub";
|
||||||
import { createNodes } from "./nodes";
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT) || 8080;
|
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)) {
|
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
|
||||||
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
|
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
|
||||||
}
|
}
|
||||||
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
|
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
|
||||||
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
|
// список серверов — валидировать его против реального реестра серверов.
|
||||||
const { key, hash, prefix } = genApiKey();
|
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 });
|
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 });
|
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) {
|
async function proxy(req: Request, res: Response) {
|
||||||
try {
|
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);
|
res.status(r.status).json(r.data);
|
||||||
} catch {
|
} catch {
|
||||||
res.status(502).json({ error: "awg-ctrl недоступен" });
|
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; }
|
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
|
||||||
|
|
||||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
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();
|
const ext = express.Router();
|
||||||
ext.use(requireApiKey);
|
ext.use(requireApiKey);
|
||||||
|
|
||||||
// Ключ привязан к server_id — все вызовы уходят на его сервер.
|
|
||||||
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
|
|
||||||
|
|
||||||
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
|
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
|
||||||
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
|
||||||
// тот же, что у proxy() в такой ситуации.
|
// тот же, что у proxy() в такой ситуации.
|
||||||
@@ -256,26 +253,26 @@ const wrap = (fn: Handler) => (req: Request, res: Response) => {
|
|||||||
|
|
||||||
ext.post("/users", wrap(async (req, res) => {
|
ext.post("/users", wrap(async (req, res) => {
|
||||||
const { name } = (req.body ?? {}) as { name?: string };
|
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; }
|
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||||
const u = r.data as ExtUser;
|
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 });
|
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) => {
|
ext.get("/users", wrap(async (_req, res) => {
|
||||||
const r = await ctrlOf(req)("GET", "/api/users/stats");
|
const r = await ctrl("GET", "/api/users/stats");
|
||||||
res.status(r.status).json(r.data);
|
res.status(r.status).json(r.data);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
ext.get("/users/:name", wrap(async (req, res) => {
|
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; }
|
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
|
||||||
const u = r.data as ExtUser;
|
const u = r.data as ExtUser;
|
||||||
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
|
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) => {
|
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);
|
res.status(r.status).json(r.data);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -285,12 +282,7 @@ app.use("/api/v1", ext);
|
|||||||
|
|
||||||
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
|
||||||
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
|
||||||
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
|
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
|
||||||
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/masterkeys", requireAuth, sub.masterKeys);
|
||||||
app.use("/ui/devices", requireAuth, sub.devices);
|
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}`); });
|
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
|
||||||
nodes.start();
|
|
||||||
sub.start();
|
sub.start();
|
||||||
@@ -375,7 +375,6 @@ body {
|
|||||||
.server-bar {
|
.server-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.server-card {
|
.server-card {
|
||||||
@@ -390,45 +389,6 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.server-card--active { border-color: var(--primary-container); }
|
.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 {
|
.server-card-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -696,9 +656,6 @@ tbody td:last-child { padding-right: 20px; }
|
|||||||
border-color: var(--primary-container);
|
border-color: var(--primary-container);
|
||||||
padding: 12px 14px;
|
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-toggle { display: flex; }
|
||||||
.server-online-chip { display: none; }
|
.server-online-chip { display: none; }
|
||||||
|
|
||||||
|
|||||||
+18
-83
@@ -4,27 +4,20 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import {
|
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
|
||||||
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 { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
|
||||||
import ServerBar from './components/ServerBar';
|
import ServerBar from './components/ServerBar';
|
||||||
import AddServerModal from './components/AddServerModal';
|
|
||||||
import { TABS } from './tabs';
|
import { TABS } from './tabs';
|
||||||
|
|
||||||
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
|
||||||
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
|
||||||
// из реестра TABS — App про конкретные вкладки ничего не знает.
|
// из реестра TABS — App про конкретные вкладки ничего не знает.
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
|
||||||
const [loginUser, setLoginUser] = useState('');
|
const [loginUser, setLoginUser] = useState('');
|
||||||
const [loginPass, setLoginPass] = useState('');
|
const [loginPass, setLoginPass] = useState('');
|
||||||
const [statusText, setStatusText] = useState('');
|
const [statusText, setStatusText] = useState('');
|
||||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
|
||||||
const [hubEnabled, setHubEnabled] = useState(false);
|
|
||||||
const [serverId, setServerIdState] = useState<number | null>(null);
|
|
||||||
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
const [activeTab, setActiveTab] = useState(TABS[0].id);
|
||||||
@@ -40,33 +33,16 @@ export default function App() {
|
|||||||
setTimeout(() => setMsg(''), 3000);
|
setTimeout(() => setMsg(''), 3000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
|
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
|
||||||
// (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) => {
|
const startSession = useCallback(async (tok: string) => {
|
||||||
await loadNodes(tok);
|
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
|
||||||
}, [loadNodes]);
|
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 () => {
|
const login = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,23 +69,10 @@ export default function App() {
|
|||||||
localStorage.removeItem(TOKEN_KEY);
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
setToken('');
|
setToken('');
|
||||||
setStatusText('');
|
setStatusText('');
|
||||||
setNodes([]);
|
setServerInfo(null);
|
||||||
setServerId(null);
|
|
||||||
setServerIdState(null);
|
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}, [token]);
|
}, [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).
|
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
|
||||||
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
|
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
|
||||||
const restartAwg = useCallback(async () => {
|
const restartAwg = useCallback(async () => {
|
||||||
@@ -148,13 +111,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [startSession]);
|
}, [startSession]);
|
||||||
|
|
||||||
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) return;
|
|
||||||
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, [token, loadNodes]);
|
|
||||||
|
|
||||||
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
|
||||||
// Если не ответил, остаётся фолбэк из констант.
|
// Если не ответил, остаётся фолбэк из констант.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -187,6 +143,7 @@ export default function App() {
|
|||||||
return () => document.removeEventListener('keydown', h);
|
return () => document.removeEventListener('keydown', h);
|
||||||
}, [drawerOpen]);
|
}, [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 tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
|
||||||
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
|
||||||
@@ -204,7 +161,7 @@ export default function App() {
|
|||||||
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{token && showRestart && serverId !== null && (
|
{token && showRestart && (
|
||||||
<button
|
<button
|
||||||
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
className={`awg-restart${restarting ? ' spinning' : ''}`}
|
||||||
aria-label="Перезапустить AWG"
|
aria-label="Перезапустить AWG"
|
||||||
@@ -300,41 +257,19 @@ export default function App() {
|
|||||||
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
|
||||||
{showServerBar && (
|
{showServerBar && (
|
||||||
<ServerBar
|
<ServerBar
|
||||||
nodes={nodes}
|
serverInfo={serverInfo}
|
||||||
serverId={serverId}
|
serverOnline={isOnline}
|
||||||
hubEnabled={hubEnabled}
|
|
||||||
onSelect={pickServer}
|
|
||||||
onAdd={() => setModal({})}
|
|
||||||
onJoin={n => setModal({ node: n })}
|
|
||||||
onDelete={deleteNode}
|
|
||||||
onRestartAwg={restartAwg}
|
onRestartAwg={restartAwg}
|
||||||
restarting={restarting}
|
restarting={restarting}
|
||||||
showRestart={showRestart}
|
showRestart={showRestart}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{serverId === null && showServerBar ? (
|
<tab.Page token={token} showMsg={showMsg} />
|
||||||
<p className="login-sub">
|
|
||||||
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
|
|
||||||
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modal && (
|
|
||||||
<AddServerModal
|
|
||||||
token={token}
|
|
||||||
node={modal.node}
|
|
||||||
onClose={() => setModal(null)}
|
|
||||||
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
|
|
||||||
showMsg={showMsg}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{msg && <div className="snack">{msg}</div>}
|
{msg && <div className="snack">{msg}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +1,56 @@
|
|||||||
// Copyright (c) 2026 Ivan Vasilev
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { type NodeInfo, genLabel } from '../lib/shared';
|
import { type ServerInfo, genLabel } from '../lib/shared';
|
||||||
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
|
import { IcoRefresh } from './icons';
|
||||||
|
|
||||||
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
|
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
|
||||||
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
|
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
|
||||||
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
|
// вкладки (пиры / абоненты).
|
||||||
export default function ServerBar({
|
export default function ServerBar({
|
||||||
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
|
serverInfo, serverOnline, onRestartAwg, restarting,
|
||||||
onRestartAwg, restarting, showRestart = true,
|
showRestart = true,
|
||||||
}: {
|
}: {
|
||||||
nodes: NodeInfo[];
|
serverInfo: ServerInfo | null;
|
||||||
serverId: number | null;
|
serverOnline: boolean;
|
||||||
hubEnabled: boolean;
|
|
||||||
onSelect: (id: number) => void;
|
|
||||||
onAdd: () => void;
|
|
||||||
onJoin: (n: NodeInfo) => void;
|
|
||||||
onDelete: (n: NodeInfo) => void;
|
|
||||||
onRestartAwg: () => void;
|
onRestartAwg: () => void;
|
||||||
restarting: boolean;
|
restarting: boolean;
|
||||||
showRestart?: boolean;
|
showRestart?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="server-bar">
|
<div className="server-bar">
|
||||||
{nodes.map(n => {
|
{serverInfo && (
|
||||||
const h = n.health;
|
<div className="server-card server-card--active">
|
||||||
const active = n.id === serverId;
|
|
||||||
return (
|
|
||||||
<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); } }}
|
|
||||||
>
|
|
||||||
{/* toggle — виден на мобильном вместо чипа */}
|
{/* toggle — виден на мобильном вместо чипа */}
|
||||||
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
|
<div
|
||||||
|
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
<div className="server-toggle-thumb" />
|
<div className="server-toggle-thumb" />
|
||||||
</div>
|
</div>
|
||||||
{/* чип онлайн/офлайн — виден на десктопе */}
|
{/* чип онлайн/офлайн — виден на десктопе */}
|
||||||
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
|
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
|
||||||
<span className="chip-dot" />
|
<span className="chip-dot" />
|
||||||
</span>
|
</span>
|
||||||
<div className="server-card-info">
|
<div className="server-card-info">
|
||||||
<span className="server-card-name">{h?.server || n.name}</span>
|
<span className="server-card-name">{serverInfo.name}</span>
|
||||||
{n.pending ? (
|
<span className="server-card-ip">{serverInfo.ip}</span>
|
||||||
<span className="server-card-ip">ждёт подключения</span>
|
{(serverInfo.mod || serverInfo.tools) ? (
|
||||||
) : !n.online ? (
|
|
||||||
<span className="server-card-ip">нет связи</span>
|
|
||||||
) : h && (
|
|
||||||
<>
|
|
||||||
<span className="server-card-ip">{h.ip}</span>
|
|
||||||
{(h.module || h.tools) ? (
|
|
||||||
<span
|
<span
|
||||||
className="tip-wrap server-gen-wrap"
|
className="tip-wrap server-gen-wrap"
|
||||||
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
|
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
|
||||||
>
|
>
|
||||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
|
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
|
<span className="server-card-peers">{serverInfo.peers} peers</span>
|
||||||
{n.pending && (
|
|
||||||
<button
|
|
||||||
className="btn-icon btn-icon--neutral"
|
|
||||||
aria-label="Новая ссылка подключения"
|
|
||||||
onClick={e => { e.stopPropagation(); onJoin(n); }}
|
|
||||||
>
|
|
||||||
<IcoCopy />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!n.local && (
|
|
||||||
<button
|
|
||||||
className="btn-icon btn-icon--danger"
|
|
||||||
aria-label={`Удалить сервер ${n.name}`}
|
|
||||||
onClick={e => { e.stopPropagation(); onDelete(n); }}
|
|
||||||
>
|
|
||||||
<IcoTrash />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
|
||||||
<span
|
|
||||||
className="tip-wrap"
|
|
||||||
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
|
|
||||||
>
|
|
||||||
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
|
|
||||||
<IcoPlus /> Добавить сервер
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
|
||||||
{showRestart && serverId !== null && (
|
{showRestart && (
|
||||||
<button
|
<button
|
||||||
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
|
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
|
||||||
onClick={onRestartAwg}
|
onClick={onRestartAwg}
|
||||||
|
|||||||
@@ -49,30 +49,8 @@ export interface Brand { brand: string; channel: string; version: string }
|
|||||||
|
|
||||||
export const TOKEN_KEY = 'awg_token';
|
export const TOKEN_KEY = 'awg_token';
|
||||||
export const THEME_KEY = 'awg_theme';
|
export const THEME_KEY = 'awg_theme';
|
||||||
export const SERVER_KEY = 'awg_server';
|
|
||||||
|
|
||||||
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
|
|
||||||
export interface NodeInfo {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
local: boolean; // id 0 — локальный awg-ctrl этой же машины
|
|
||||||
online: boolean;
|
|
||||||
pending: boolean; // нода создана, но ещё ни разу не подключалась
|
|
||||||
last_seen: number | null;
|
|
||||||
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
|
|
||||||
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
|
|
||||||
// перечитывает данные уже с новым сервером.
|
|
||||||
let currentServerId: number | null = null;
|
|
||||||
export const setServerId = (id: number | null) => { currentServerId = id; };
|
|
||||||
export const getServerId = () => currentServerId;
|
|
||||||
|
|
||||||
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
|
||||||
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
|
||||||
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
|
|
||||||
const r = await axios({ method, url: path, headers, data: body });
|
|
||||||
return r.data;
|
return r.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// LICENSE file in the root directory of this source tree.
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
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 { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
|
||||||
import './apikeys.css';
|
import './apikeys.css';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
|
|||||||
|
|
||||||
const createKey = useCallback(async () => {
|
const createKey = useCallback(async () => {
|
||||||
if (!label.trim()) return;
|
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; }
|
if (r.error) { showMsg(r.error); return; }
|
||||||
setLabel('');
|
setLabel('');
|
||||||
setCreated({ label: r.label, key: r.key });
|
setCreated({ label: r.label, key: r.key });
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "awg-cli",
|
"name": "awg-cli",
|
||||||
"version": "0.3.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
|
|||||||
+6
-104
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2026 Ivan Vasilev
|
// Copyright (c) 2026 Ivan Vasilev
|
||||||
// This source code is licensed under the MIT license found in the
|
// This source code is licensed under the MIT license found in the
|
||||||
// LICENSE file in the root directory of this source tree.
|
// 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,
|
import { readFileSync, writeFileSync, existsSync,
|
||||||
mkdirSync, openSync, closeSync,
|
mkdirSync, openSync, closeSync,
|
||||||
unlinkSync } from "fs";
|
unlinkSync } from "fs";
|
||||||
@@ -100,48 +100,14 @@ const SERVICES = {
|
|||||||
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
|
||||||
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
|
||||||
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
|
||||||
// Приём нод (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",
|
pidFile: "/tmp/awg-ui.pid",
|
||||||
logFile: path.join(LOGS, "awg-ui.log"),
|
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;
|
} as const;
|
||||||
|
|
||||||
type SvcName = keyof typeof SERVICES;
|
type SvcName = keyof typeof SERVICES;
|
||||||
|
const ALL: SvcName[] = ["awgctrl", "ui"];
|
||||||
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
|
|
||||||
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
|
|
||||||
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
|
|
||||||
const IS_NODE = !!process.env.CORE_HOST;
|
|
||||||
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
|
|
||||||
const ALL: SvcName[] = IS_NODE ? ["awgctrl", "agent"] : NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
|
|
||||||
|
|
||||||
function readPid(name: SvcName): number | null {
|
function readPid(name: SvcName): number | null {
|
||||||
const { pidFile } = SERVICES[name];
|
const { pidFile } = SERVICES[name];
|
||||||
@@ -304,7 +270,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
|
|||||||
|
|
||||||
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
|
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
|
||||||
// ensureInterfaceUp() бросил исключение) — сразу выходим.
|
// 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`;
|
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
|
||||||
for (let i = 0; i < attempts; i++) {
|
for (let i = 0; i < attempts; i++) {
|
||||||
if (!isRunning(name)) return false;
|
if (!isRunning(name)) return false;
|
||||||
@@ -315,16 +281,12 @@ async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startAll() {
|
async function startAll() {
|
||||||
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
|
|
||||||
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
|
|
||||||
|
|
||||||
await start("awgctrl");
|
await start("awgctrl");
|
||||||
const next: SvcName = IS_NODE ? "agent" : "ui";
|
|
||||||
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
|
||||||
console.error(` ${grey("■")} awg-ctrl не отвечает — ${SERVICES[next].label} не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await start(next);
|
await start("ui");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopAll() { for (const n of ALL) await stop(n); }
|
async function stopAll() { for (const n of ALL) await stop(n); }
|
||||||
@@ -337,64 +299,6 @@ function updateEnvVar(content: string, key: string, value: string): string {
|
|||||||
: content.trimEnd() + `\n${key}=${value}\n`;
|
: 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://…>`: превратить эту установку в ноду. Панель (awg-ui) на этой машине
|
|
||||||
// останавливается, вместо неё запускается агент; awg-ctrl и пользователи остаются как были.
|
|
||||||
async function joinCore(link?: string) {
|
|
||||||
if (!link?.startsWith("awgjoin://")) {
|
|
||||||
console.error(" Usage: awg-ctrl join <awgjoin://…> (ссылку даёт панель: «Добавить сервер»)");
|
|
||||||
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;
|
|
||||||
|
|
||||||
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!,
|
|
||||||
};
|
|
||||||
for (const [k, v] of Object.entries(set)) content = updateEnvVar(content, k, v);
|
|
||||||
writeFileSync(envFile, content, { mode: 0o600 });
|
|
||||||
|
|
||||||
await stop("ui"); // панель на ноде не нужна
|
|
||||||
console.log(`\n ${green("✓")} Настроено: core ${j.h}:${j.p}, нода #${j.n}`);
|
|
||||||
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"]) 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") {
|
async function setCredentials(field?: "user" | "pass") {
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
|
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
|
||||||
@@ -489,7 +393,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…>|unjoin>";
|
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>";
|
||||||
|
|
||||||
const [,,, sub] = process.argv;
|
const [,,, sub] = process.argv;
|
||||||
|
|
||||||
@@ -503,8 +407,6 @@ async function main() {
|
|||||||
else if (sub === "pass") await setCredentials("pass");
|
else if (sub === "pass") await setCredentials("pass");
|
||||||
else await setCredentials();
|
else await setCredentials();
|
||||||
break;
|
break;
|
||||||
case "join": await joinCore(sub); break;
|
|
||||||
case "unjoin": await unjoinCore(); break;
|
|
||||||
default: console.log(USAGE); process.exit(1);
|
default: console.log(USAGE); process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+298
-663
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user