9 Commits
Author SHA1 Message Date
maeneko 1e73aaae6a Bump to Beta 0.3.1
ci / check (push) Successful in 5m59s
release / release (push) Successful in 3m10s
Covers all seven places the version is written down: .env, the copy
install.sh keeps because it prints its banner and builds the release URL
before the archive with .env exists, the four package.json files (awg-agent
is new), and the /ui/brand example in the README.

Multiple servers under one panel (core + nodes), the installer's role
question and its full log.
2026-09-26 20:19:50 +03:00
maeneko e284ec5609 Fix the installer's generated password, keep going if the log dies
Found by running install.sh end to end in a sandbox for all three roles.

- The suggested UI password and JWT_SECRET came from
  `tr < /dev/urandom | head -c N || openssl rand`. Under pipefail tr's
  SIGPIPE made the pipeline fail every time, so openssl ran too and the two
  outputs were glued together (a 24-character password). rand_alnum() reads
  a finite 1 KiB instead.
- The log filter runs sed with LC_ALL=C, so broken UTF-8 in someone else's
  output (make.log, dmesg) cannot stop it, and tee -p keeps writing to the
  screen if the log writer dies instead of taking the installer down with a
  silent SIGPIPE.
- Output: labels before awg-quick up and apt-mark hold, no double blank
  lines, "маршрут" only where there is a VPN, a hint when the agent log is
  still empty.
2026-09-26 20:19:44 +03:00
maeneko ae0b4e39e4 Ask for the server role in the installer, log everything
- install.sh asks the role first: panel + VPN (as before), panel only
  (skips AmneziaWG, the kernel module and the headers/virt checks) or node
  (no panel; takes the awgjoin:// string and checks the panel's cert pin
  with openssl before building anything). ROLE= / JOIN= skip the questions;
  an update keeps the previous role.
- The previous cli.env is read before a reinstall removes the project
  directory. Before this, keeping data silently lost SUB_PORT / SUB_TLS
  and broke every issued sen:// link.
- Output: a header, numbered steps, menus, a summary before installing and
  a final block per role. The full output of apt, DKMS, npm, awg-quick,
  ufw and systemctl is shown as a dimmed block under its step, including
  what used to go to /dev/null.
- The log starts with the first line: checks, questions with answers
  (password masked), all command output. It is written without colour
  codes, mode 600.
2026-09-26 19:48:34 +03:00
maeneko 388222116a Manage several servers from one panel: core + nodes
A node is a VPN server without a panel. awg-agent on it dials the panel
(core) over an outbound WSS connection and runs the panel's requests on its
own awg-ctrl, which stays on 127.0.0.1 and is unchanged. No inbound port on
the node, works behind NAT.

- awg-ui/nodes.ts: node registry in ui.db, WSS hub on NODE_PORT with a
  self-signed cert whose SPKI pin goes into the join string, RPC with
  timeouts, health polling, /ui/nodes routes.
- Nodes enroll with a one-time awgjoin:// string (secret stored hashed,
  1 hour) and then authenticate by their own Ed25519 key. The agent checks
  the pin before sending anything and only forwards /health, /api/*, /awg/*.
- server_id 0 is the panel's own awg-ctrl; LOCAL_NODE=off makes the panel
  run without a VPN of its own. The panel picks a server with X-Server-Id,
  API keys use their server_id. sen:// still serves one server.
- UI: a card per server in the server bar, switching remounts the tab,
  «Добавить сервер» gives the join command.
- CLI: agent service, roles from cli.env, join / unjoin commands.
- Release archives include awg-agent.
2026-09-26 19:48:33 +03:00
maeneko 7019490e04 Add /sub/v1/devices and /sub/v1/peek, count the slots on register
ci / check (push) Successful in 5m57s
release / release (push) Successful in 3m15s
- GET /sub/v1/devices: a device lists the devices of its own master key
  (name, platform, version, last seen, and which one is asking). Read-only:
  unbinding is still only DELETE /sub/v1/device for oneself.
- POST /sub/v1/peek: how many slots of a key are taken, asked with the
  link's secret before any device exists, so the client can show it while
  the link is only pasted. Wrong secrets count against the failure limit.
- register also answers with devices / device_limit, so the client needs no
  second request to show the slot it just took.
2026-09-25 17:24:52 +03:00
maeneko 08908b002c Bump to Beta 0.3.0
release / release (push) Successful in 3m12s
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

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

- awg-ctrl: peers table and /api/peers*, /api/profile; nextIp and
  rebuildConf take peers into account (output unchanged without them)
- awg-ui: link codec and signatures (sen.ts), registry, panel routes
  and the public listener on SUB_PORT (sub.ts)
- responses are always Ed25519-signed; TLS with a pinned self-signed
  certificate is optional
- devices report a client version, shown as platform-version
- "Мастер-ключи" tab: a table with the devices nested under each key,
  a device-limit meter, per-key settings and a placeholder for the
  servers a key serves; cards on mobile
- install.sh/CLI: subscription signing key, optional certificate, port;
  kept across reinstalls with KEEP_DATA
- add the missing mock-server.ts used by npm run dev:mock
2026-09-25 17:24:52 +03:00
maenekoandClaude Opus 5 fd87884bde Bump to Beta 0.2.2
ci / check (push) Successful in 7m1s
release / release (push) Successful in 3m40s
Covers all six places the version is written down: .env, the copy install.sh
keeps because it prints its banner and builds the release URL before the
archive with .env exists, the three package.json files, and the /ui/brand
example in the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:51:19 +03:00
maenekoandClaude Opus 5 4971a90e41 Support Debian in the installer
install.sh only knew Ubuntu. On Debian 13 the dependency step died on
software-properties-common, which trixie no longer ships, and on
linux-headers-generic, which Debian never had — so add-apt-repository and the
Ubuntu headers meta package are both off the table there.

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

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

iptables joins the dependencies — PostUp calls it, and a minimal Debian 13 on
nftables doesn't have it. IP forwarding moves to its own file in
/etc/sysctl.d: Debian 13 no longer reads /etc/sysctl.conf at boot, so the old
line would have held only until the first reboot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 17:51:19 +03:00
21 changed files with 1965 additions and 625 deletions
+1 -1
View File
@@ -11,4 +11,4 @@
BRAND=Forgetting
CHANNEL=Beta
VERSION=0.3.0
VERSION=0.3.1
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
# Токен выбираем в bash, а не выражением ${{ a || b }}: так поведение не
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir "awgcontrol-${VERSION}"
cp -r awg-ctrl awg-ui cli .env "awgcontrol-${VERSION}/"
cp -r awg-ctrl awg-ui awg-agent cli .env "awgcontrol-${VERSION}/"
tar --exclude='node_modules' -czf "awgcontrol-${VERSION}.tar.gz" "awgcontrol-${VERSION}/"
- name: Publish release
+1 -1
View File
@@ -115,7 +115,7 @@ TOKEN=$(curl -s -X POST http://HOST:PORT/login \
| Метод | Ответ |
|---|---|
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.0" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /ui/brand` | `{ "brand": "Forgetting", "channel": "Beta", "version": "0.3.1" }` — **без авторизации**, панель берёт отсюда вордмарк на экране логина |
| `GET /health` | `{ "status", "server", "ip", "gen", "awg": { "status", "peers", "module", "tools" } }`. `200`, если интерфейс поднят, иначе `503` и `status: "degraded"` |
| `GET /awg/status` | `{ "up": true, "peers": 3, "publicKey": "…" }` |
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// Агент ноды. Сам открывает исходящее WSS-соединение к core (awg-ui), доказывает
// свою личность подписью Ed25519 и исполняет присланные запросы на локальном
// awg-ctrl (127.0.0.1). Входящих портов у ноды нет, awg-ctrl остаётся закрытым от сети.
// Протокол и формат join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import path from "path";
import type { TLSSocket } from "tls";
import axios from "axios";
import WebSocket from "ws";
const CORE_HOST = process.env.CORE_HOST ?? "";
const CORE_PORT = Number(process.env.CORE_PORT) || 0;
const CORE_PIN = process.env.CORE_PIN ?? ""; // SHA-256 SPKI сертификата core, base64url
const NODE_ID = Number(process.env.NODE_ID) || 0;
const CTRL = `http://127.0.0.1:${process.env.AWGCTRL_PORT ?? "3005"}`;
const NODE_KEY_FILE = process.env.NODE_KEY_FILE ?? path.join(__dirname, "node.key");
const INTERNAL_AUTH_KEY_FILE = process.env.INTERNAL_AUTH_KEY_FILE ?? "/etc/amnezia/amneziawg/internal_auth_private.key";
const CLI_ENV_FILE = process.env.CLI_ENV_FILE ?? "";
let joinSecret = process.env.JOIN_SECRET ?? ""; // одноразовый, живёт до первого успешного подключения
if (!CORE_HOST || !CORE_PORT || !CORE_PIN || !NODE_ID) {
console.error("agent: не заданы CORE_HOST / CORE_PORT / CORE_PIN / NODE_ID — выполните `awg-ctrl join <ссылка>`");
process.exit(1);
}
// ── Ключи ──────────────────────────────────────────────────────────────────
// Личность ноды (Ed25519): создаётся один раз, приватная часть с ноды не уходит.
function loadNodeKey(): crypto.KeyObject {
if (!fs.existsSync(NODE_KEY_FILE)) {
const { privateKey } = crypto.generateKeyPairSync("ed25519");
fs.writeFileSync(NODE_KEY_FILE, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
console.log("agent: создан ключ ноды " + NODE_KEY_FILE);
}
return crypto.createPrivateKey(fs.readFileSync(NODE_KEY_FILE));
}
const nodeKey = loadNodeKey();
const nodePubRaw = (() => {
const der = crypto.createPublicKey(nodeKey).export({ type: "spki", format: "der" }) as Buffer;
return der.subarray(der.length - 32).toString("base64url");
})();
let internalKey: crypto.KeyObject | null = null;
try { internalKey = crypto.createPrivateKey(fs.readFileSync(INTERNAL_AUTH_KEY_FILE)); }
catch { console.warn("agent: приватный ключ внутренней авторизации не найден: " + INTERNAL_AUTH_KEY_FILE); }
// Тот же короткоживущий токен, что awg-ui шлёт своему awg-ctrl.
function mintInternalToken(): string {
if (!internalKey) throw new Error("no internal key");
const t = Math.floor(Date.now() / 1000);
const h = Buffer.from('{"alg":"EdDSA","typ":"JWT"}').toString("base64url");
const p = Buffer.from(JSON.stringify({ iss: "awg-agent", iat: t, exp: t + 60 })).toString("base64url");
return `${h}.${p}.${crypto.sign(null, Buffer.from(`${h}.${p}`), internalKey).toString("base64url")}`;
}
// Дублируется в awg-ui/nodes.ts.
const authMsg = (nonce: string) => Buffer.from(`awg-node-v1\n${nonce}\n${NODE_ID}`);
// ── Запросы core → awg-ctrl ────────────────────────────────────────────────
const METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);
// Агент — не открытый прокси: только API самого awg-ctrl.
function pathAllowed(p: unknown): p is string {
if (typeof p !== "string" || p.length > 2048 || !/^\/[\x21-\x7e]*$/.test(p)) return false;
try {
const u = new URL(p, "http://x");
if (u.origin !== "http://x" || u.pathname.split("/").some(s => s === ".." || s === ".")) return false;
return u.pathname === "/health" || u.pathname.startsWith("/api/") || u.pathname.startsWith("/awg/");
} catch { return false; }
}
async function execute(m: { method?: unknown; path?: unknown; body?: unknown }): Promise<{ status: number; data: unknown }> {
const method = typeof m.method === "string" ? m.method.toUpperCase() : "";
if (!METHODS.has(method) || !pathAllowed(m.path)) return { status: 400, data: { error: "Запрос не разрешён агентом" } };
try {
const r = await axios({
method, url: CTRL + m.path,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: m.body, validateStatus: () => true, timeout: 110_000,
});
return { status: r.status, data: r.data };
} catch {
return { status: 502, data: { error: "awg-ctrl недоступен" } };
}
}
// ── Соединение с core ──────────────────────────────────────────────────────
function pinOf(sock: TLSSocket): string | null {
const raw = sock.getPeerCertificate(true)?.raw;
if (!raw) return null;
const spki = new crypto.X509Certificate(raw).publicKey.export({ type: "spki", format: "der" }) as Buffer;
return crypto.createHash("sha256").update(spki).digest("base64url");
}
// После первого успешного входа секрет больше не нужен — убираем его с диска.
function forgetJoinSecret() {
joinSecret = "";
delete process.env.JOIN_SECRET;
if (!CLI_ENV_FILE) return;
try {
const kept = fs.readFileSync(CLI_ENV_FILE, "utf8").split("\n").filter(l => !l.startsWith("JOIN_SECRET="));
fs.writeFileSync(CLI_ENV_FILE, kept.join("\n"));
} catch { /* не критично: секрет одноразовый и с истекающим сроком */ }
}
let backoff = 1000;
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
function connectOnce(): Promise<{ authFailed: boolean }> {
return new Promise(resolve => {
// Сертификат самоподписанный — цепочку не проверяем, доверие даёт pin (см. 'upgrade').
const ws = new WebSocket(`wss://${CORE_HOST}:${CORE_PORT}/node/v1`, {
rejectUnauthorized: false, handshakeTimeout: 10_000, maxPayload: 4 * 1024 * 1024,
});
let pinned = false, ready = false, deadTimer: NodeJS.Timeout | undefined;
const armDead = () => { clearTimeout(deadTimer); deadTimer = setTimeout(() => ws.terminate(), 60_000); };
// До этого момента ничего не отправляем и не читаем: секрет и подпись уходят только
// тому, чей сертификат совпал с pin из join-строки.
ws.on("upgrade", res => {
const got = pinOf(res.socket as TLSSocket);
if (got === CORE_PIN) { pinned = true; return; }
console.error("agent: сертификат core не совпал с pin — соединение отклонено");
ws.terminate();
});
ws.on("ping", armDead);
ws.on("message", async raw => {
if (!pinned) return;
let m: any;
try { m = JSON.parse(raw.toString()); } catch { ws.terminate(); return; }
if (m.t === "hello" && typeof m.nonce === "string") {
const auth: Record<string, unknown> = {
t: "auth", node_id: NODE_ID,
sig: crypto.sign(null, authMsg(m.nonce), nodeKey).toString("base64url"),
};
if (joinSecret) auth.join = { secret: joinSecret, pub: nodePubRaw };
ws.send(JSON.stringify(auth));
} else if (m.t === "ready") {
ready = true; backoff = 1000; armDead();
if (joinSecret) { forgetJoinSecret(); console.log("agent: нода привязана к core"); }
console.log("agent: подключено к core");
} else if (m.t === "req" && ready && Number.isInteger(m.id)) {
const r = await execute(m);
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t: "res", id: m.id, ...r }));
}
});
ws.on("error", e => { if (!pinned || !ready) console.error("agent: " + (e as Error).message); });
ws.on("close", code => {
clearTimeout(deadTimer);
if (code === 4003) console.error("agent: core отклонил ноду (неверный ключ, просроченная или использованная ссылка)");
resolve({ authFailed: code === 4003 });
});
});
}
async function main() {
console.log(`agent: нода #${NODE_ID} → wss://${CORE_HOST}:${CORE_PORT}`);
for (;;) {
const { authFailed } = await connectOnce();
const wait = authFailed ? 60_000 : backoff;
backoff = Math.min(backoff * 2, 60_000);
await sleep(wait);
}
}
main();
+19
View File
@@ -0,0 +1,19 @@
{
"name": "awg-agent",
"version": "0.3.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"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["index.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-control",
"version": "0.3.0",
"version": "0.3.1",
"description": "",
"main": "dist/index.js",
"scripts": {
+47 -7
View File
@@ -23,6 +23,25 @@ const users: U[] = ["alice", "bob", "laptop-ivan"].map((name, i) => ({
key_gen: GEN, online: i !== 1, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
}));
// ── серверы: core (id 0), нода в сети и нода, ещё не подключавшаяся ──
// Пользователи хранятся по серверам; X-Server-Id выбирает набор, как в настоящей панели.
const usersOf: Record<number, U[]> = {
0: users,
1: ["dave", "erin"].map((name, i) => ({
name, ip: `10.9.0.${i + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"),
key_gen: "2", online: i === 0, lastHandshake: now() - rnd(600), rx: rnd(5e8), tx: rnd(2e8),
})),
};
const srv = (q: express.Request) => { const n = Number(q.headers["x-server-id"]); return Number.isInteger(n) && usersOf[n] ? n : 0; };
const U_ = (q: express.Request) => usersOf[srv(q)];
interface MNode { id: number; name: string; ip: string; gen: string; online: boolean; pending: boolean }
const mnodes: MNode[] = [
{ id: 1, name: "Frankfurt", ip: "198.51.100.4", gen: "2", online: true, pending: false },
{ id: 2, name: "Tokyo", ip: "", gen: "2", online: false, pending: true },
];
let nodeId = 2;
const joinStr = (id: number) => "awgjoin://" + Buffer.from(JSON.stringify({ v: 1, h: "localhost", p: 8443, pin: crypto.randomBytes(32).toString("base64url"), n: id, s: crypto.randomBytes(16).toString("base64url") })).toString("base64url");
// ── API-ключи ──
const apiKeys = [{ id: 1, label: "ci-bot", prefix: "awgk_Xk2mQ9aB…", server_id: 0, created_at: now() - 86400 * 5, last_used: now() - 3600 as number | null }];
let apiId = 1;
@@ -40,22 +59,42 @@ const find = (id: string) => masters.find(m => m.id === Number(id));
app.get("/ui/brand", (_q, r) => r.json({ brand: "Forgetting", channel: "Beta", version: "0.2.2-mock" }));
app.post("/login", (q, r) => q.body.user === "admin" && q.body.pass === "admin" ? r.json({ token: "mock." + crypto.randomBytes(8).toString("hex") + ".sig" }) : r.status(401).json({ error: "Неверный логин или пароль" }));
app.post("/logout", (_q, r) => r.json({ ok: true }));
app.get("/health", (_q, r) => r.json({ status: "ok", server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, awg: { status: "ok", peers: users.length, module: "3.0.1", tools: "1.0.0" } }));
app.get("/health", (q, r) => {
const id = srv(q), n = mnodes.find(x => x.id === id);
r.json({ status: "ok", server: n?.name ?? "VPN (mock)", ip: n?.ip ?? "203.0.113.7", gen: n?.gen ?? GEN, awg: { status: "ok", peers: usersOf[id].length, module: "3.0.1", tools: "1.0.0" } });
});
// ── серверы ──
const nodeView = () => [
{ id: 0, name: "VPN (mock)", local: true, online: true, pending: false, last_seen: null, health: { server: "VPN (mock)", ip: "203.0.113.7", gen: GEN, peers: users.length, awg_up: true, module: "3.0.1", tools: "1.0.0" } },
...mnodes.map(n => ({ id: n.id, name: n.name, local: false, online: n.online, pending: n.pending, last_seen: n.online ? now() - 5 : null,
health: n.online ? { server: n.name, ip: n.ip, gen: n.gen, peers: (usersOf[n.id] ?? []).length, awg_up: true, module: "3.0.1", tools: "1.0.0" } : null })),
];
app.get("/ui/nodes", (_q, r) => r.json({ hub: true, local: true, nodes: nodeView() }));
app.post("/ui/nodes", (q, r) => {
const name = String(q.body.name ?? "");
if (!name || name.length > 40) return r.status(400).json({ error: "Имя: до 40 символов, без переводов строк и <>" });
const id = ++nodeId; mnodes.push({ id, name, ip: "", gen: "2", online: false, pending: true });
r.status(201).json({ id, name, join: joinStr(id), expires_in: 3600 });
});
app.post("/ui/nodes/:id/join", (q, r) => { const n = mnodes.find(x => x.id === Number(q.params.id)); n ? r.json({ id: n.id, name: n.name, join: joinStr(n.id), expires_in: 3600 }) : r.status(404).json({ error: "Не найден" }); });
app.delete("/ui/nodes/:id", (q, r) => { const i = mnodes.findIndex(x => x.id === Number(q.params.id)); if (i < 0) return r.status(404).json({ error: "Не найден" }); mnodes.splice(i, 1); r.json({ success: true }); });
app.post("/awg/restart", (_q, r) => r.json({ up: true }));
app.get("/awg/status", (_q, r) => r.json({ up: true, peers: users.length }));
app.get("/api/users", (_q, r) => r.json({ users }));
app.get("/api/users/stats", (_q, r) => r.json({ users: users.map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (_q, r) => r.json({ total: users.length, reissued: users.length, regenerated: [], backup: "" }));
app.get("/api/users", (q, r) => r.json({ users: U_(q) }));
app.get("/api/users/stats", (q, r) => r.json({ users: U_(q).map(({ vpn_key, pub_key, ...u }) => u) }));
app.post("/api/users/reissue", (q, r) => r.json({ total: U_(q).length, reissued: U_(q).length, regenerated: [], backup: "" }));
app.post("/api/users", (q, r) => {
const users = U_(q);
const name = String(q.body.name ?? "");
if (!/^[a-zA-Z0-9_-]{1,32}$/.test(name)) return r.status(400).json({ error: "Имя: буквы, цифры, _ и -, до 32 символов" });
if (users.some(u => u.name === name)) return r.status(409).json({ error: "Пользователь уже существует" });
const u: U = { name, ip: `10.9.0.${users.length + 2}`, pub_key: b64(32), vpn_key: "vpn://" + crypto.randomBytes(48).toString("base64url"), key_gen: GEN, online: false, lastHandshake: 0, rx: 0, tx: 0 };
users.push(u); r.status(201).json(u);
});
app.post("/api/users/:name", (q, r) => { const u = users.find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
app.delete("/api/users/:name", (q, r) => { const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.post("/api/users/:name", (q, r) => { const u = U_(q).find(x => x.name === q.params.name); u ? r.json(u) : r.status(404).json({ error: "Пользователь не найден" }); });
app.delete("/api/users/:name", (q, r) => { const users = U_(q); const i = users.findIndex(x => x.name === q.params.name); if (i >= 0) users.splice(i, 1); r.json({ success: true }); });
app.get("/ui/apikeys", (_q, r) => r.json({ keys: apiKeys }));
app.post("/ui/apikeys", (q, r) => { const k = { id: ++apiId, label: q.body.label, prefix: "awgk_" + crypto.randomBytes(4).toString("hex") + "…", server_id: 0, created_at: now(), last_used: null }; apiKeys.push(k); r.status(201).json({ ...k, key: "awgk_" + crypto.randomBytes(24).toString("base64url") }); });
@@ -81,4 +120,5 @@ app.delete("/ui/devices/:id", (q, r) => { for (const m of masters) m.devs = m.de
app.post("/ui/devices/:id/rekey", (q, r) => { masters.forEach(m => m.devs.forEach(d => { if (d.id === Number(q.params.id)) d.rekey_requested = true; })); r.json({}); });
app.post("/ui/devices/:id/psk", (_q, r) => r.json({}));
app.listen(8080, () => console.log("mock: http://localhost:8080 (login admin / admin)"));
const MOCK_PORT = Number(process.env.MOCK_PORT) || 8080;
app.listen(MOCK_PORT, () => console.log(`mock: http://localhost:${MOCK_PORT} (login admin / admin)`));
+345
View File
@@ -0,0 +1,345 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
//
// Несколько серверов под одной панелью: core (эта awg-ui) и nodes — VPN-серверы,
// которые сами открывают к core исходящее WSS-соединение (awg-agent). Реестр нод
// лежит в ui.db, RPC к ноде идёт по этому соединению, а нода исполняет запрос
// на своём 127.0.0.1-awg-ctrl. awg-ctrl про ноды ничего не знает.
//
// server_id 0 — локальный awg-ctrl этой же машины (как было до нод); при
// LOCAL_NODE=off его нет, core работает как чистая панель. Протокол и формат
// join-строки — docs/node-protocol.md.
import crypto from "crypto";
import fs from "fs";
import https from "https";
import path from "path";
import { spawnSync } from "child_process";
import { Router, type Request, type Response } from "express";
import type Database from "better-sqlite3";
import { WebSocketServer, WebSocket } from "ws";
import { spkiPin, publicKeyFromRaw } from "./sen";
export type Ctrl = (method: string, urlPath: string, body?: unknown) => Promise<{ status: number; data: any }>;
interface NodeRow {
id: number; name: string; pub: string | null;
join_hash: Buffer | null; join_expires: number | null;
created_at: number; last_seen: number | null;
}
interface Health {
server: string; ip: string; gen: string; peers: number;
awg_up: boolean; module: string; tools: string;
}
interface Conn {
ws: WebSocket; seq: number; alive: boolean;
pending: Map<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 };
}
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "awg-ui",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"scripts": {
"dev": "vite",
@@ -15,20 +15,22 @@
"better-sqlite3": "^12.10.0",
"qrcode": "^1.5.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react-dom": "^18.3.0",
"ws": "^8.22.0"
},
"devDependencies": {
"concurrently": "^9.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/qrcode": "^1.5.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@types/qrcode": "^1.5.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^4.3.0",
"concurrently": "^9.0.0",
"express": "^4.19.0",
"tsx": "^4.7.0",
"typescript": "^5.4.0",
"vite": "^5.4.0"
}
}
}
+29 -20
View File
@@ -8,6 +8,7 @@ import crypto from "crypto";
import axios from "axios";
import Database from "better-sqlite3";
import { createSub } from "./sub";
import { createNodes } from "./nodes";
const app = express();
const PORT = Number(process.env.PORT) || 8080;
@@ -180,10 +181,10 @@ app.post("/ui/apikeys", requireAuth, (req: Request, res: Response) => {
if (!label || !/^[\w \-]{1,40}$/.test(label)) {
res.status(400).json({ error: "Метка: буквы, цифры, пробел, _ и -, до 40 символов" }); return;
}
// TODO multi-server: server_id пока всегда 0 (текущий сервер). Когда появится
// список серверов — валидировать его против реального реестра серверов.
const sid = server_id === undefined ? nodes.defaultId() : Number(server_id);
if (!Number.isInteger(sid) || !nodes.exists(sid)) { res.status(400).json({ error: "Такого сервера нет" }); return; }
const { key, hash, prefix } = genApiKey();
const info = keyStmts.insert.run(label, hash, prefix, Number(server_id) || 0, Math.floor(Date.now() / 1000));
const info = keyStmts.insert.run(label, hash, prefix, sid, Math.floor(Date.now() / 1000));
// Открытое значение отдаётся один раз — дальше в БД только хэш.
res.status(201).json({ id: info.lastInsertRowid, label, prefix, key });
});
@@ -195,25 +196,24 @@ app.delete("/ui/apikeys/:id", requireAuth, (req: Request, res: Response) => {
res.json({ success: true, id });
});
// Сервер, к которому относится запрос панели: заголовок X-Server-Id (0 — локальный
// awg-ctrl, остальное — нода). Без заголовка — сервер по умолчанию.
function serverIdOf(req: Request): number {
const raw = req.headers["x-server-id"];
if (raw === undefined) return nodes.defaultId();
const n = Number(raw);
return Number.isInteger(n) && n >= 0 ? n : nodes.defaultId();
}
async function proxy(req: Request, res: Response) {
try {
const r = await axios({
method: req.method,
url: CTRL + req.originalUrl,
headers: { Authorization: `Bearer ${mintInternalToken()}`, "Content-Type": "application/json" },
data: req.body,
validateStatus: () => true,
});
const r = await nodes.ctrlFor(serverIdOf(req))(req.method, req.originalUrl, req.body);
res.status(r.status).json(r.data);
} catch {
res.status(502).json({ error: "awg-ctrl недоступен" });
}
}
// TODO multi-server: ключ привязан к server_id (пока всегда 0 = текущий
// сервер). Когда серверов станет несколько — маршрутизировать ctrl() на нужный
// awg-ctrl по (req as ExtRequest).apiKey.server_id.
interface ExtRequest extends Request { apiKey?: ApiKeyRow; }
function requireApiKey(req: Request, res: Response, next: NextFunction) {
@@ -243,6 +243,9 @@ interface ExtUser { name: string; ip: string; vpn_key: string; key_gen: string }
const ext = express.Router();
ext.use(requireApiKey);
// Ключ привязан к server_id — все вызовы уходят на его сервер.
const ctrlOf = (req: Request) => nodes.ctrlFor((req as ExtRequest).apiKey?.server_id ?? 0);
// Express 4 не ловит отказы async-обработчиков: незакрытый reject (awg-ctrl не
// отвечает, внутренний ключ не загружен) уронил бы весь процесс панели. Ответ
// тот же, что у proxy() в такой ситуации.
@@ -253,26 +256,26 @@ const wrap = (fn: Handler) => (req: Request, res: Response) => {
ext.post("/users", wrap(async (req, res) => {
const { name } = (req.body ?? {}) as { name?: string };
const r = await ctrl("POST", "/api/users", { name });
const r = await ctrlOf(req)("POST", "/api/users", { name });
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.status(201).json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.get("/users", wrap(async (_req, res) => {
const r = await ctrl("GET", "/api/users/stats");
ext.get("/users", wrap(async (req, res) => {
const r = await ctrlOf(req)("GET", "/api/users/stats");
res.status(r.status).json(r.data);
}));
ext.get("/users/:name", wrap(async (req, res) => {
const r = await ctrl("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrlOf(req)("POST", `/api/users/${encodeURIComponent(req.params.name)}`);
if (r.status >= 400) { res.status(r.status).json(r.data); return; }
const u = r.data as ExtUser;
res.json({ name: u.name, ip: u.ip, gen: u.key_gen, vpn_key: u.vpn_key });
}));
ext.delete("/users/:name", wrap(async (req, res) => {
const r = await ctrl("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
const r = await ctrlOf(req)("DELETE", `/api/users/${encodeURIComponent(req.params.name)}`);
res.status(r.status).json(r.data);
}));
@@ -282,7 +285,12 @@ app.use("/api/v1", ext);
// sen://-подписка: мастер-ключи и устройства (роуты панели, за JWT) и публичный
// листенер /sub/v1 на своём порту SUB_PORT. Публичная часть сюда не монтируется.
const sub = createSub({ uidb, ctrl, baseDir: __dirname });
// Несколько серверов: core (эта панель) + ноды по исходящему WSS. Реестр — в ui.db.
const nodes = createNodes({ uidb, ctrlLocal: ctrl, baseDir: __dirname });
app.use("/ui/nodes", requireAuth, nodes.router);
// sen://-подписка пока обслуживает один сервер — по умолчанию. Несколько нод — следующий шаг.
const sub = createSub({ uidb, ctrl: (m, p, b) => nodes.ctrlFor(nodes.defaultId())(m, p, b), baseDir: __dirname });
app.use("/ui/masterkeys", requireAuth, sub.masterKeys);
app.use("/ui/devices", requireAuth, sub.devices);
@@ -295,4 +303,5 @@ app.get("*", (_req: Request, res: Response) => {
});
app.listen(PORT, () => { console.log(`ui: listening on :${PORT}`); });
nodes.start();
sub.start();
+43
View File
@@ -375,6 +375,7 @@ body {
.server-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.server-card {
@@ -389,6 +390,45 @@ body {
user-select: none;
}
.server-card--active { border-color: var(--primary-container); }
.server-card--inactive { cursor: pointer; }
.server-card--inactive:hover { background: var(--state-primary); }
.server-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* «Добавить сервер»: призрачная кнопка с пунктиром (design.md, 4.2) */
.server-add {
background: transparent;
border: 1.5px dashed var(--outline);
color: var(--neutral-text);
}
.server-add:disabled { opacity: .6; cursor: not-allowed; }
/* Модалка добавления ноды */
.node-scrim {
position: fixed; inset: 0; z-index: 60;
background: var(--scrim);
display: flex; align-items: center; justify-content: center;
padding: 16px;
}
.node-modal {
width: 100%; max-width: 520px;
background: var(--surface-bright);
border: 1px solid var(--outline-variant);
border-radius: var(--r-card);
padding: 20px;
display: flex; flex-direction: column; gap: 12px;
}
.node-modal-title { margin: 0; font-size: 16px; font-weight: 500; color: var(--on-surface); }
.node-modal-hint { margin: 0; font-size: 12px; color: var(--neutral-text); }
.node-modal-cmd {
display: block;
font-family: var(--mono); font-size: 12px;
background: var(--surface-container);
border-radius: var(--r-table);
padding: 12px;
word-break: break-all;
user-select: all;
}
.node-modal-actions { display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
.server-card-info {
display: flex;
flex-direction: column;
@@ -656,6 +696,9 @@ tbody td:last-child { padding-right: 20px; }
border-color: var(--primary-container);
padding: 12px 14px;
}
.server-card--inactive { border-color: var(--outline-variant); }
.server-add { width: 100%; justify-content: center; }
.node-modal-actions .btn { flex: 1; }
.server-toggle { display: flex; }
.server-online-chip { display: none; }
+84 -19
View File
@@ -4,20 +4,27 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios';
import './App.css';
import { TOKEN_KEY, THEME_KEY, BRAND_FALLBACK, apiFetch, type ServerInfo, type Brand } from './lib/shared';
import {
TOKEN_KEY, THEME_KEY, SERVER_KEY, BRAND_FALLBACK, apiFetch, setServerId, getServerId,
type NodeInfo, type Brand,
} from './lib/shared';
import { IcoMenu, IcoLogout, IcoSun, IcoMoon, IcoRefresh } from './components/icons';
import ServerBar from './components/ServerBar';
import AddServerModal from './components/AddServerModal';
import { TABS } from './tabs';
// Оболочка приложения: логин, тема, drawer/сайдбар, общая сессия (token,
// serverInfo, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
// список серверов и выбранный сервер, снэкбар) и переключение вкладок. Контент каждой вкладки приходит
// из реестра TABS — App про конкретные вкладки ничего не знает.
export default function App() {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) ?? '');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [statusText, setStatusText] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [hubEnabled, setHubEnabled] = useState(false);
const [serverId, setServerIdState] = useState<number | null>(null);
const [modal, setModal] = useState<{ node?: NodeInfo } | null>(null);
const [msg, setMsg] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeTab, setActiveTab] = useState(TABS[0].id);
@@ -33,17 +40,34 @@ export default function App() {
setTimeout(() => setMsg(''), 3000);
}, []);
// Проверяет токен и подтягивает данные сервера (/health). Данные конкретных
// вкладок (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
const { data: h } = await axios.get('/health', { headers: { Authorization: `Bearer ${tok}` } });
setServerInfo({
name: h.server || 'VPN', ip: h.ip || '', peers: h.awg?.peers ?? 0, gen: h.gen || '2',
mod: h.awg?.module ?? '', tools: h.awg?.tools ?? '',
});
setStatusText('online:' + (h.server || 'ok') + ' · peers: ' + (h.awg?.peers ?? '?'));
// Выбрать сервер: id уходит заголовком X-Server-Id во все запросы вкладок
// (setServerId — до смены state, чтобы вкладка с новым key читала уже с ним).
const pickServer = useCallback((id: number | null) => {
setServerId(id);
setServerIdState(id);
try { if (id !== null) localStorage.setItem(SERVER_KEY, String(id)); } catch { /* не критично */ }
}, []);
// Список серверов (core + ноды) с их состоянием. Если выбранного больше нет —
// берём сохранённый, иначе первый.
const loadNodes = useCallback(async (tok: string) => {
const { data } = await axios.get('/ui/nodes', { headers: { Authorization: `Bearer ${tok}` } });
const list: NodeInfo[] = data.nodes ?? [];
setNodes(list);
setHubEnabled(!!data.hub);
const cur = getServerId();
if (cur !== null && list.some(n => n.id === cur)) return;
let saved: number | null = null;
try { const raw = localStorage.getItem(SERVER_KEY); saved = raw === null ? null : Number(raw); } catch { /* не критично */ }
pickServer(list.find(n => n.id === saved)?.id ?? list[0]?.id ?? null);
}, [pickServer]);
// Проверяет токен и подтягивает список серверов. Данные конкретных вкладок
// (юзеры, ключи) грузят сами вкладки.
const startSession = useCallback(async (tok: string) => {
await loadNodes(tok);
}, [loadNodes]);
const login = useCallback(async () => {
try {
const { data: auth } = await axios.post('/login', { user: loginUser, pass: loginPass });
@@ -69,10 +93,23 @@ export default function App() {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setStatusText('');
setServerInfo(null);
setNodes([]);
setServerId(null);
setServerIdState(null);
setDrawerOpen(false);
}, [token]);
const deleteNode = useCallback(async (n: NodeInfo) => {
if (!confirm(`Удалить сервер «${n.name}»? Нода отключится от панели.`)) return;
try {
await apiFetch('DELETE', `/ui/nodes/${n.id}`, token);
showMsg('Сервер удалён');
await loadNodes(token);
} catch (e) {
showMsg(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось удалить' : 'Не удалось удалить');
}
}, [token, showMsg, loadNodes]);
// Перезапуск AWG-интерфейса (awg-quick down/up + ресинк пиров в awg-ctrl).
// Соединения клиентов кратковременно прерываются — поэтому подтверждение.
const restartAwg = useCallback(async () => {
@@ -111,6 +148,13 @@ export default function App() {
}
}, [startSession]);
// Состояние серверов (онлайн, пиры) обновляем раз в 15 с, пока открыта панель.
useEffect(() => {
if (!token) return;
const t = setInterval(() => { loadNodes(token).catch(() => { /* следующий тик */ }); }, 15_000);
return () => clearInterval(t);
}, [token, loadNodes]);
// Имя продукта и версия — с сервера, до логина (роут без авторизации).
// Если не ответил, остаётся фолбэк из констант.
useEffect(() => {
@@ -143,8 +187,7 @@ export default function App() {
return () => document.removeEventListener('keydown', h);
}, [drawerOpen]);
const isOnline = statusText.startsWith('online:');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const statusLabel = statusText.replace(/^(online|offline):/, '');
const tab = TABS.find(t => t.id === activeTab) ?? TABS[0];
// Хром вкладки (сервер-бар + перезапуск). Любой флаг по умолчанию включён.
const chrome = tab.chrome ?? {};
@@ -161,7 +204,7 @@ export default function App() {
{theme === 'dark' ? <IcoSun /> : <IcoMoon />}
</button>
{token && showRestart && (
{token && showRestart && serverId !== null && (
<button
className={`awg-restart${restarting ? ' spinning' : ''}`}
aria-label="Перезапустить AWG"
@@ -257,19 +300,41 @@ export default function App() {
{/* Общий хром над вкладкой — App владеет им сам, вкладки про него не знают */}
{showServerBar && (
<ServerBar
serverInfo={serverInfo}
serverOnline={isOnline}
nodes={nodes}
serverId={serverId}
hubEnabled={hubEnabled}
onSelect={pickServer}
onAdd={() => setModal({})}
onJoin={n => setModal({ node: n })}
onDelete={deleteNode}
onRestartAwg={restartAwg}
restarting={restarting}
showRestart={showRestart}
/>
)}
<tab.Page token={token} showMsg={showMsg} />
{serverId === null && showServerBar ? (
<p className="login-sub">
Серверов пока нет. Добавьте ноду: она сама подключится к этой панели.
</p>
) : (
// key: при смене сервера вкладка монтируется заново и читает данные уже с ним
<tab.Page key={`${tab.id}:${serverId}`} token={token} showMsg={showMsg} />
)}
</main>
</div>
</>
)}
{modal && (
<AddServerModal
token={token}
node={modal.node}
onClose={() => setModal(null)}
onChanged={() => { loadNodes(token).catch(() => { /* следующий тик */ }); }}
showMsg={showMsg}
/>
)}
{msg && <div className="snack">{msg}</div>}
</div>
);
+102
View File
@@ -0,0 +1,102 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useEffect, useState } from 'react';
import axios from 'axios';
import { apiFetch, copyText, type NodeInfo } from '../lib/shared';
import { IcoCopy } from './icons';
interface Joined { id: number; name: string; join: string; expires_in: number }
// Добавление ноды: имя → одноразовая join-строка, которую выполняют на самой ноде
// (`awg-ctrl join …`). С `node` открывается сразу за новой строкой для уже
// созданной, но ещё не подключавшейся ноды.
export default function AddServerModal({ token, node, onClose, onChanged, showMsg }: {
token: string;
node?: NodeInfo;
onClose: () => void;
onChanged: () => void;
showMsg: (text: string) => void;
}) {
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<Joined | null>(null);
const [error, setError] = useState('');
const call = async (fn: () => Promise<Joined>) => {
setBusy(true); setError('');
try { setResult(await fn()); onChanged(); }
catch (e) { setError(axios.isAxiosError(e) ? e.response?.data?.error ?? 'Не удалось' : 'Не удалось'); }
finally { setBusy(false); }
};
const create = () => call(() =>
apiFetch('POST', '/ui/nodes', token, { name: name.trim(), host: window.location.hostname }));
useEffect(() => {
if (node) void call(() => apiFetch('POST', `/ui/nodes/${node.id}/join`, token, { host: window.location.hostname }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
const command = result ? `awg-ctrl join '${result.join}'` : '';
return (
<div className="node-scrim" onClick={onClose}>
<div className="node-modal" role="dialog" aria-modal="true" aria-label="Добавить сервер" onClick={e => e.stopPropagation()}>
<h3 className="node-modal-title">{node ? `Подключение: ${node.name}` : 'Добавить сервер'}</h3>
{!node && !result && (
<>
<p className="node-modal-hint">
Нода — обычный сервер с установленным Forgetting. Она сама подключится к этой панели,
открывать на ней порты не нужно.
</p>
<input
className="field"
placeholder="Название сервера"
value={name}
maxLength={40}
autoFocus
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && name.trim() && !busy && create()}
/>
</>
)}
{result && (
<>
<p className="node-modal-hint">
Выполните на сервере «{result.name}» от root. Ссылка одноразовая и действует
{' '}{Math.round(result.expires_in / 60)} мин.
</p>
<code className="node-modal-cmd">{command}</code>
<p className="node-modal-hint">
Канал шифруется TLS с проверкой отпечатка сертификата этой панели: подменить ноду
или панель по дороге не выйдет.
</p>
</>
)}
{error && <p className="login-error">{error}</p>}
<div className="node-modal-actions">
{result ? (
<button className="btn btn--tonal" onClick={() => copyText(command).then(() => showMsg('Команда скопирована'))}>
<IcoCopy /> Скопировать
</button>
) : !node && (
<button className="btn btn--primary" onClick={create} disabled={busy || !name.trim()}>
Создать
</button>
)}
<button className="btn btn--outline" onClick={onClose}>{result ? 'Готово' : 'Отмена'}</button>
</div>
</div>
</div>
);
}
+84 -36
View File
@@ -1,56 +1,104 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { type ServerInfo, genLabel } from '../lib/shared';
import { IcoRefresh } from './icons';
import { type NodeInfo, genLabel } from '../lib/shared';
import { IcoRefresh, IcoPlus, IcoTrash, IcoCopy } from './icons';
// Сервер-бар: карточка активного сервера + (на мобильной) перезапуск AWG под
// ней. Общий для вкладок — показывает сервер, к которому относится содержимое
// вкладки (пиры / абоненты).
// Сервер-бар: карточки серверов (core-локальный и ноды) + «Добавить сервер» и
// (на мобильной) перезапуск AWG под ними. Общий для вкладок — показывает
// серверы, между которыми переключается содержимое вкладки (пиры / абоненты).
export default function ServerBar({
serverInfo, serverOnline, onRestartAwg, restarting,
showRestart = true,
nodes, serverId, hubEnabled, onSelect, onAdd, onJoin, onDelete,
onRestartAwg, restarting, showRestart = true,
}: {
serverInfo: ServerInfo | null;
serverOnline: boolean;
nodes: NodeInfo[];
serverId: number | null;
hubEnabled: boolean;
onSelect: (id: number) => void;
onAdd: () => void;
onJoin: (n: NodeInfo) => void;
onDelete: (n: NodeInfo) => void;
onRestartAwg: () => void;
restarting: boolean;
showRestart?: boolean;
}) {
return (
<div className="server-bar">
{serverInfo && (
<div className="server-card server-card--active">
{/* toggle — виден на мобильном вместо чипа */}
{nodes.map(n => {
const h = n.health;
const active = n.id === serverId;
return (
<div
className={`server-toggle${serverOnline ? ' server-toggle--on' : ''}`}
aria-hidden="true"
key={n.id}
className={`server-card server-card--${active ? 'active' : 'inactive'}`}
role="button"
tabIndex={0}
aria-pressed={active}
onClick={() => onSelect(n.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(n.id); } }}
>
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${serverOnline ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info">
<span className="server-card-name">{serverInfo.name}</span>
<span className="server-card-ip">{serverInfo.ip}</span>
{(serverInfo.mod || serverInfo.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${serverInfo.mod || '?'} · tools ${serverInfo.tools || '?'}`}
{/* toggle — виден на мобильном вместо чипа */}
<div className={`server-toggle${n.online ? ' server-toggle--on' : ''}`} aria-hidden="true">
<div className="server-toggle-thumb" />
</div>
{/* чип онлайн/офлайн — виден на десктопе */}
<span className={`chip chip--${n.online ? 'online' : 'offline'} server-online-chip`}>
<span className="chip-dot" />
</span>
<div className="server-card-info">
<span className="server-card-name">{h?.server || n.name}</span>
{n.pending ? (
<span className="server-card-ip">ждёт подключения</span>
) : !n.online ? (
<span className="server-card-ip">нет связи</span>
) : h && (
<>
<span className="server-card-ip">{h.ip}</span>
{(h.module || h.tools) ? (
<span
className="tip-wrap server-gen-wrap"
data-tip={`модуль ${h.module || '?'} · tools ${h.tools || '?'}`}
>
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(h.gen)}</span>
)}
</>
)}
</div>
{h && n.online && <span className="server-card-peers">{h.peers} peers</span>}
{n.pending && (
<button
className="btn-icon btn-icon--neutral"
aria-label="Новая ссылка подключения"
onClick={e => { e.stopPropagation(); onJoin(n); }}
>
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
</span>
) : (
<span className="chip chip--offline server-gen-chip">{genLabel(serverInfo.gen)}</span>
<IcoCopy />
</button>
)}
{!n.local && (
<button
className="btn-icon btn-icon--danger"
aria-label={`Удалить сервер ${n.name}`}
onClick={e => { e.stopPropagation(); onDelete(n); }}
>
<IcoTrash />
</button>
)}
</div>
<span className="server-card-peers">{serverInfo.peers} peers</span>
</div>
)}
);
})}
<span
className="tip-wrap"
data-tip={hubEnabled ? '' : 'Приём нод выключен: задайте NODE_PORT'}
>
<button className="btn server-add" onClick={onAdd} disabled={!hubEnabled}>
<IcoPlus /> Добавить сервер
</button>
</span>
{/* Перезапуск AWG — только на мобильной (на десктопе кнопка в углу). */}
{showRestart && (
{showRestart && serverId !== null && (
<button
className={`btn btn--tonal awg-restart-mobile${restarting ? ' spinning' : ''}`}
onClick={onRestartAwg}
@@ -61,4 +109,4 @@ export default function ServerBar({
)}
</div>
);
}
}
+25 -3
View File
@@ -47,10 +47,32 @@ export interface PageProps {
export const BRAND_FALLBACK = 'Forgetting';
export interface Brand { brand: string; channel: string; version: string }
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const TOKEN_KEY = 'awg_token';
export const THEME_KEY = 'awg_theme';
export const SERVER_KEY = 'awg_server';
// Сервер (core или нода) в списке ServerBar: то, что отдаёт GET /ui/nodes.
export interface NodeInfo {
id: number;
name: string;
local: boolean; // id 0 — локальный awg-ctrl этой же машины
online: boolean;
pending: boolean; // нода создана, но ещё ни разу не подключалась
last_seen: number | null;
health: { server: string; ip: string; gen: AwgGen; peers: number; awg_up: boolean; module: string; tools: string } | null;
}
// Выбранный сервер. Вкладки зовут apiFetch без явного id — заголовок X-Server-Id
// подставляется здесь; App ставит значение до смены key у вкладки, поэтому та
// перечитывает данные уже с новым сервером.
let currentServerId: number | null = null;
export const setServerId = (id: number | null) => { currentServerId = id; };
export const getServerId = () => currentServerId;
export async function apiFetch(method: string, path: string, token: string, body?: object): Promise<any> {
const r = await axios({ method, url: path, headers: { Authorization: `Bearer ${token}` }, data: body });
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (currentServerId !== null) headers['X-Server-Id'] = String(currentServerId);
const r = await axios({ method, url: path, headers, data: body });
return r.data;
}
+2 -2
View File
@@ -2,7 +2,7 @@
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { useState, useEffect, useCallback } from 'react';
import { apiFetch, copyText, timeAgo, type ApiKey, type PageProps } from '../../lib/shared';
import { apiFetch, copyText, timeAgo, getServerId, type ApiKey, type PageProps } from '../../lib/shared';
import { IcoPlus, IcoTrash, IcoCopy } from '../../components/icons';
import './apikeys.css';
@@ -26,7 +26,7 @@ export default function ApiKeysPage({ token, showMsg }: PageProps) {
const createKey = useCallback(async () => {
if (!label.trim()) return;
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: 0 });
const r = await apiFetch('POST', '/ui/apikeys', token, { label: label.trim(), server_id: getServerId() ?? 0 });
if (r.error) { showMsg(r.error); return; }
setLabel('');
setCreated({ label: r.label, key: r.key });
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "awg-cli",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"dependencies": {
"tsx": "^4.7.0",
+104 -6
View File
@@ -1,7 +1,7 @@
// Copyright (c) 2026 Ivan Vasilev
// This source code is licensed under the MIT license found in the
// LICENSE file in the root directory of this source tree.
import { spawn } from "child_process";
import { spawn, spawnSync } from "child_process";
import { readFileSync, writeFileSync, existsSync,
mkdirSync, openSync, closeSync,
unlinkSync } from "fs";
@@ -100,14 +100,48 @@ const SERVICES = {
SUB_SIGN_KEY_FILE: process.env.SUB_SIGN_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_sign.key",
SUB_TLS_KEY_FILE: process.env.SUB_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/sub_tls.key",
SUB_TLS_CERT_FILE: process.env.SUB_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/sub_tls.crt",
// Приём нод (core): порт хаба WSS и его самоподписанный сертификат (pin зашит в
// join-строки, живёт как sub_tls.*). Без NODE_PORT хаб выключен. LOCAL_NODE=off —
// core без собственного VPN: awg-ctrl рядом не нужен.
NODE_PORT: process.env.NODE_PORT ?? "",
LOCAL_NODE: process.env.LOCAL_NODE ?? "on",
NODE_TLS_KEY_FILE: process.env.NODE_TLS_KEY_FILE ?? "/etc/amnezia/amneziawg/node_tls.key",
NODE_TLS_CERT_FILE: process.env.NODE_TLS_CERT_FILE ?? "/etc/amnezia/amneziawg/node_tls.crt",
},
pidFile: "/tmp/awg-ui.pid",
logFile: path.join(LOGS, "awg-ui.log"),
},
// Агент ноды: сам звонит в core (CORE_HOST/PORT/PIN из join-строки) и исполняет запросы
// на локальном awg-ctrl. Запускается вместо awg-ui — см. ROLE.
agent: {
label: "awg-agent",
cwd: path.join(ROOT, "awg-agent"),
entry: "index.ts",
env: {
CORE_HOST: process.env.CORE_HOST ?? "",
CORE_PORT: process.env.CORE_PORT ?? "",
CORE_PIN: process.env.CORE_PIN ?? "",
NODE_ID: process.env.NODE_ID ?? "",
NODE_KEY_FILE: process.env.NODE_KEY_FILE ?? "/etc/amnezia/amneziawg/node.key",
JOIN_SECRET: process.env.JOIN_SECRET ?? "",
CLI_ENV_FILE: envFile,
AWGCTRL_PORT: process.env.AWGCTRL_PORT ?? "3005",
INTERNAL_AUTH_KEY_FILE: process.env.INTERNAL_AUTH_KEY_FILE
?? "/etc/amnezia/amneziawg/internal_auth_private.key",
},
pidFile: "/tmp/awg-agent.pid",
logFile: path.join(LOGS, "awg-agent.log"),
},
} as const;
type SvcName = keyof typeof SERVICES;
const ALL: SvcName[] = ["awgctrl", "ui"];
// Роль установки. node — VPN-сервер без панели: awg-ctrl + агент, который сам
// подключается к core (есть CORE_HOST, его пишет `join`). core с LOCAL_NODE=off —
// панель без собственного VPN (только awg-ui). Иначе — обычная установка.
const IS_NODE = !!process.env.CORE_HOST;
const NO_LOCAL = (process.env.LOCAL_NODE ?? "on").toLowerCase() === "off";
const ALL: SvcName[] = IS_NODE ? ["awgctrl", "agent"] : NO_LOCAL ? ["ui"] : ["awgctrl", "ui"];
function readPid(name: SvcName): number | null {
const { pidFile } = SERVICES[name];
@@ -270,7 +304,7 @@ function probe(url: string, timeoutMs = 1000): Promise<boolean> {
// Ждём, пока awg-ctrl начнёт отвечать. Если процесс умер (например,
// ensureInterfaceUp() бросил исключение) — сразу выходим.
async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
async function waitForHealth(name: "awgctrl", attempts = 10): Promise<boolean> {
const url = `http://127.0.0.1:${SERVICES[name].env.PORT}/health`;
for (let i = 0; i < attempts; i++) {
if (!isRunning(name)) return false;
@@ -281,12 +315,16 @@ async function waitForHealth(name: SvcName, attempts = 10): Promise<boolean> {
}
async function startAll() {
// Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен.
if (!ALL.includes("awgctrl")) { for (const n of ALL) await start(n); return; }
await start("awgctrl");
const next: SvcName = IS_NODE ? "agent" : "ui";
if (!isRunning("awgctrl") || !(await waitForHealth("awgctrl"))) {
console.error(` ${grey("■")} awg-ctrl не отвечает — awg-ui не запущен (см. ${SERVICES.awgctrl.logFile})`);
console.error(` ${grey("■")} awg-ctrl не отвечает — ${SERVICES[next].label} не запущен (см. ${SERVICES.awgctrl.logFile})`);
return;
}
await start("ui");
await start(next);
}
async function stopAll() { for (const n of ALL) await stop(n); }
@@ -299,6 +337,64 @@ function updateEnvVar(content: string, key: string, value: string): string {
: content.trimEnd() + `\n${key}=${value}\n`;
}
function removeEnvVar(content: string, key: string): string {
return content.split("\n").filter(l => !l.startsWith(`${key}=`)).join("\n");
}
// Роль читается из окружения при старте процесса, поэтому после смены cli.env
// перезапуск делаем свежим процессом CLI, а не в этом же.
function restartFresh(): void {
spawnSync(process.execPath, [...process.execArgv, process.argv[1], "restart"], { stdio: "inherit" });
}
// `join <awgjoin://…>`: превратить эту установку в ноду. Панель (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") {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string) => new Promise<string>(r => rl.question(q, r));
@@ -393,7 +489,7 @@ async function main() {
return;
}
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]>";
const USAGE = "Usage: cli <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…>|unjoin>";
const [,,, sub] = process.argv;
@@ -407,6 +503,8 @@ async function main() {
else if (sub === "pass") await setCredentials("pass");
else await setCredentials();
break;
case "join": await joinCore(sub); break;
case "unjoin": await unjoinCore(); break;
default: console.log(USAGE); process.exit(1);
}
}
+886 -521
View File
File diff suppressed because it is too large Load Diff