Files
maeneko 5c3bf21918 Show each server's Forgetting version in the panel
- awg-agent sends its install's VERSION (from the CLI's env, else the
  project's .env) in the auth frame; the hub keeps it in nodes.version
  (added by migration), so an offline node still shows the last one.
- GET /ui/nodes returns version per server (0 is the panel's own) and the
  panel's version at the top.
- ServerBar: a vX.Y.Z chip next to the AWG generation. A node older or
  newer than the panel, or one that sends no version (agent before
  0.3.4, shown as v?.?.?), gets a warm chip with an "update it" tooltip.
- App.css: tertiary-container tokens from design.md (light) and a warm
  dark pair; they did not exist yet.
- Mock server: panel 0.3.3, Frankfurt behind on 0.3.2, Amsterdam with no
  version.
2026-09-27 18:03:57 +03:00

182 lines
10 KiB
TypeScript

// 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 ?? ""; // одноразовый, живёт до первого успешного подключения
// Версия Forgetting на этой ноде — панель показывает её в карточке сервера. CLI передаёт её из
// .env в корне проекта; при запуске вручную читаем тот же .env сами.
function envFromFile(file: string, key: string): string {
try {
const m = fs.readFileSync(file, "utf8").match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"));
return m ? m[1].trim() : "";
} catch { return ""; }
}
const VERSION = process.env.VERSION || envFromFile(path.join(__dirname, "..", ".env"), "VERSION");
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 };
if (VERSION) auth.version = VERSION;
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();