Port awg-ui to Go: awg-control ui

internal/ui reimplements the awg-ui backend with the same HTTP surface; the
React frontend is unchanged and served from awg-ui/dist.

- web.go: Express-like router (express.json semantics, mounts, :params,
  HTML 400/404/413 pages, SPA fallback)
- server.go: JWT login with rate limit, API keys and their server sets,
  awg-ctrl client and proxy, /api/v1/users, delete hooks
- nodes.go: WSS hub for agents (pin, challenge-response, join strings),
  RPC with timeouts, health, /ui/nodes
- sub.go, audit.go, sub_admin.go: sen:// master keys and devices,
  per-server reconcile with tombstones and /api/peers/sync, signed /sub/v1
  listener, audit with two-pass confirmation and cleaning, panel and
  /api/v1/masterkeys routers
- metrics.go, monitor.go: /metrics exposition with tokens, /ui/monitor
- sen.go: link codec and protocol signatures, tested byte for byte against
  fixtures produced by awg-ui/sen.ts (tools/golden/sen.ts)

Differential bench (tools/diffbench/local-ui.sh, scenario-ui.ts,
compare-ui.mjs): 158 steps over every route group, including signed device
requests and a Go agent joining as a node, plus the resulting ui.db and
awg-ctrl state. TS and Go are identical after masking random values, across
repeated runs and with the race detector on.
This commit is contained in:
2026-10-14 13:27:24 +00:00
parent c2e9636f31
commit 70bb587216
18 changed files with 7017 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
// Состояние после сценария awg-ui: строки ui.db и пиры/пользователи awg-ctrl.
import fs from "fs";
import { execSync } from "child_process";
const out = process.argv[2];
const t = JSON.parse(fs.readFileSync(out, "utf8"));
const q = (db, sql) => JSON.parse(execSync(`sqlite3 -json ${db} "${sql}"`).toString() || "[]");
const UI = "/run/bench/ui.db", CT = "/etc/amnezia/amneziawg/users.db";
const tables = ["api_keys", "api_key_servers", "nodes", "master_keys", "master_servers", "devices", "device_peers",
"peer_tombstones", "server_profiles", "sub_meta", "metrics_token"];
t.state = {
ui: Object.fromEntries(tables.map(x => [x, q(UI, `select * from ${x} order by rowid`)])),
uiSchema: execSync(`sqlite3 ${UI} .schema`).toString(),
ctrlUsers: q(CT, "select name, ip, key_gen from users order by rowid"),
ctrlPeers: q(CT, "select pub_key, ip, owner from peers order by rowid"),
};
fs.writeFileSync(out, JSON.stringify(t, null, 1));
console.log("collect-ui: done");
+61
View File
@@ -0,0 +1,61 @@
// compare-ui.mjs <ts.json> <go.json>: расхождения двух прогонов scenario-ui. Случайное
// маскируется (токены, ключи, uuid, ссылки, подписи, время, id панели); подписанные ответы
// /sub/v1 раскрываются: сравнивается body, а подпись — только что она есть.
import fs from "fs";
const [a, b] = process.argv.slice(2).map(f => JSON.parse(fs.readFileSync(f, "utf8")));
const panelOf = r => r.state.ui.sub_meta.find(x => x.key === "panel_id")?.value ?? "";
const TIME_KEYS = /^(created_at|last_used|last_seen|at|ts|updated_at|join_expires|revoked_at|expires_in_at)$/;
function mask(s, panel) {
if (panel) s = s.split(panel).join("<panel>");
return s
.replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "<jwt>")
.replace(/awg[km]_[A-Za-z0-9_-]{8}(…|[A-Za-z0-9_-]{24})/g, "<token>")
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
.replace(/sen:\/\/[A-Za-z0-9_-]+/g, "<sen>")
.replace(/awgjoin:\/\/[A-Za-z0-9_-]+/g, "<join>")
.replace(/awg_scrape_duration_seconds [0-9.e-]+/g, "awg_scrape_duration_seconds <d>")
.replace(/(awg_host_[a-z_]+\{[^}]*\}) [0-9.e+-]+/g, "$1 <n>");
}
function norm(v, panel, key = "") {
if (typeof v === "string") {
if (key === "sig") return "<sig>";
if (key === "body" && v.startsWith("{")) { try { return norm(JSON.parse(v), panel); } catch {} }
return mask(v, panel);
}
if (typeof v === "number" && TIME_KEYS.test(key)) return "<t>";
if (Array.isArray(v)) return v.map(x => norm(x, panel));
if (v && typeof v === "object") {
const o = {};
for (const [k, x] of Object.entries(v)) {
if (["key_hash", "secret", "join_hash", "token_hash", "pub", "prefix"].includes(k) && x !== null) { o[k] = "<rnd>"; continue; }
if (k === "value" && typeof x === "string" && /^[[{]/.test(x)) { o[k] = norm(JSON.parse(x), panel); continue; }
o[k] = norm(x, panel, k);
}
return o;
}
return v;
}
const pa = panelOf(a), pb = panelOf(b);
let diffs = 0;
const show = (label, x, y) => {
const X = JSON.stringify(norm(x, pa)), Y = JSON.stringify(norm(y, pb));
if (X === Y) return;
diffs++;
let i = 0; while (X[i] === Y[i]) i++;
const from = Math.max(0, i - 120);
console.log(`### ${label} @${i}\n TS: …${X.slice(from, i + 300)}\n GO: …${Y.slice(from, i + 300)}`);
};
if (a.error || b.error) console.log("scenario error:", a.error, "|", b.error);
const n = Math.max(a.steps.length, b.steps.length);
for (let i = 0; i < n; i++) {
const s = a.steps[i] ?? {}, t = b.steps[i] ?? {};
// text не сравниваем: порядок ключей виден и в разобранном body (Object.entries его хранит).
// Стек Express в dev-режиме (NODE_ENV не задан) у Go не воспроизводится — сравниваем тип ошибки.
const html = x => typeof x === "string" ? x.replace(/<pre>([A-Za-z]+Error)[^<]*(<br>[\s\S]*)?<\/pre>/, "<pre>$1</pre>") : x;
show(`${i} ${s.name ?? t.name} ${s.method} ${s.path}`, { st: s.status, ct: s.ctype, body: html(s.body) }, { st: t.status, ct: t.ctype, body: html(t.body) });
}
for (const k of Object.keys(a.state.ui)) show("ui." + k, a.state.ui[k], b.state.ui[k]);
show("uiSchema", a.state.uiSchema.replace(/\s+/g, " "), b.state.uiSchema.replace(/\s+/g, " "));
show("ctrlUsers", a.state.ctrlUsers, b.state.ctrlUsers);
show("ctrlPeers", a.state.ctrlPeers, b.state.ctrlPeers);
console.log(diffs ? `${diffs} differences` : "IDENTICAL");
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# local-ui.sh <ts|go> <имя-прогона> — дифференциальный прогон awg-ui без Docker: awg-ctrl (Go,
# на заглушках, сверен с TS отдельно) + панель нужной реализации + нода из Go-агента на тот же
# awg-ctrl. Как и local.sh — только для одноразовой машины: пересоздаёт /etc/amnezia.
# Нужны node 20+, sqlite3, openssl, go и `npm install` в awg-ui/ (и собранный awg-ui/dist).
set -e
IMPL="$1"; NAME="$2"
B=$(cd "$(dirname "$0")" && pwd); ROOT=$(cd "$B/../.." && pwd)
BENCH=/var/lib/uibench
mkdir -p "$B/bin/stubs" "$B/out" "$BENCH"
for c in awg awg-quick ip tc nft modinfo; do
printf '#!/bin/sh\nexec node %s %s "$@"\n' "$B/stubs/stub.mjs" "$c" > "$B/bin/stubs/$c"; chmod +x "$B/bin/stubs/$c"
done
export PATH="$B/bin/stubs:$PATH"
(cd "$ROOT" && go build ${RACE:+-race} -o "$B/bin/awg-control" ./cmd/awg-control) # RACE=1 — с детектором гонок
# Ключи стенда — одни на все прогоны: подпись sen://, сертификат хаба нод.
[ -f "$BENCH/sub_sign.key" ] || openssl genpkey -algorithm ed25519 -out "$BENCH/sub_sign.key" 2>/dev/null
[ -f "$BENCH/node_tls.key" ] || openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout "$BENCH/node_tls.key" -out "$BENCH/node_tls.crt" -days 3650 -subj /CN=awg-node 2>/dev/null
sh "$B/setup.sh"
PORT=3005 SERVER_IP=203.0.113.7 SERVER_PORT=47619 SERVER_NAME=VPN \
INTERNAL_AUTH_PUB_FILE=/etc/amnezia/amneziawg/internal_auth_public.key SHAPE_SAMPLE_S=3600 AWG_CLI=/nonexistent/awg-ctrl \
sh -c 'cd /tmp && exec "$0" ctrl' "$B/bin/awg-control" > /run/bench/ctrl.log 2>&1 &
CTRL=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:3005/health; do i=$((i+1)); [ $i -gt 150 ] && { cat /run/bench/ctrl.log; kill $CTRL; exit 1; }; sleep 0.1; done
export PORT=8080 UI_USER=admin UI_PASS=benchpass JWT_SECRET=benchsecret UI_DB_FILE=/run/bench/ui.db AWGCTRL_PORT=3005 \
INTERNAL_AUTH_KEY_FILE=/etc/amnezia/amneziawg/internal_auth_private.key \
SUB_PORT=8443 SUB_TLS=off SUB_SIGN_KEY_FILE="$BENCH/sub_sign.key" SUB_HOST=sub.example.com \
NODE_PORT=8444 NODE_TLS_KEY_FILE="$BENCH/node_tls.key" NODE_TLS_CERT_FILE="$BENCH/node_tls.crt" \
BRAND=Bench CHANNEL=Beta VERSION=0.0.0-bench MONITOR_HASH_FILE="$BENCH/none" PROM_URL=http://127.0.0.1:9 \
METRICS_PEERS=on AUDIT=on LOCAL_NODE=on
cd "$ROOT/awg-ui"
if [ "$IMPL" = "ts" ]; then node node_modules/.bin/tsx server.ts > /run/bench/ui.log 2>&1 &
else "$B/bin/awg-control" ui > /run/bench/ui.log 2>&1 &
fi
UIPID=$!
i=0; until curl -s -o /dev/null http://127.0.0.1:8080/ui/brand; do i=$((i+1)); [ $i -gt 200 ] && { cat /run/bench/ui.log; kill $UIPID $CTRL; exit 1; }; sleep 0.1; done
sleep 1
AGENT_BIN="$B/bin/awg-control" BENCH_DIR="$BENCH" "$ROOT/awg-ui/node_modules/.bin/tsx" "$B/scenario-ui.ts" "$B/out/$NAME.json" || true
sleep 2
kill $UIPID $CTRL 2>/dev/null || true; wait $UIPID $CTRL 2>/dev/null || true
node "$B/collect-ui.mjs" "$B/out/$NAME.json"
cp /run/bench/ui.log "$B/out/$NAME.ui.log"; cp /run/bench/ctrl.log "$B/out/$NAME.ctrl.log"
+301
View File
@@ -0,0 +1,301 @@
// Сценарий для awg-ui: одинаковая последовательность запросов к TS- и Go-версии панели.
// Запуск: awg-ui/node_modules/.bin/tsx tools/diffbench/scenario-ui.ts <out.json>
// Окружение: UI (http://127.0.0.1:8080), SUB (http://127.0.0.1:8443), AGENT_BIN — бинарник
// awg-control для ноды (join по строке из панели), BENCH_DIR — каталог с ключами стенда.
import crypto from "crypto";
import fs from "fs";
import { spawn, execSync, type ChildProcess } from "child_process";
import { decodeSenLink, signRequest } from "../../awg-ui/sen";
const UI = process.env.UI ?? "http://127.0.0.1:8080";
const SUB = process.env.SUB ?? "http://127.0.0.1:8443";
const out = process.argv[2];
const BENCH = process.env.BENCH_DIR ?? "/var/lib/uibench";
type Step = { name: string; method: string; path: string; status: number; ctype: string; body: unknown; text?: string };
const steps: Step[] = [];
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function call(name: string, method: string, path: string, body?: unknown,
o: { token?: string | null; key?: string; raw?: string; headers?: Record<string, string>; base?: string } = {}) {
const h: Record<string, string> = { ...(o.headers ?? {}) };
if (o.token) h.authorization = `Bearer ${o.token}`;
if (o.key) h["x-api-key"] = o.key;
let data: string | undefined;
if (o.raw !== undefined) { h["content-type"] = "application/json"; data = o.raw; }
else if (body !== undefined) { h["content-type"] = "application/json"; data = JSON.stringify(body); }
const r = await fetch((o.base ?? UI) + path, { method, headers: h, body: data });
const ctype = (r.headers.get("content-type") ?? "").split(";")[0];
const text = await r.text();
let parsed: unknown = text;
if (ctype === "application/json") { try { parsed = JSON.parse(text); } catch { /* как есть */ } }
if (ctype === "text/html" && text.length > 2000) parsed = text.slice(0, 200); // index.html
steps.push({ name, method, path, status: r.status, ctype, body: parsed, text: ctype === "application/json" ? text : undefined });
return parsed as any;
}
// ── Подписанные запросы устройства (/sub/v1) ────────────────────────────────
const seedKey = (s: string) => crypto.createPrivateKey({
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), crypto.createHash("sha256").update(s).digest()]),
format: "der", type: "pkcs8",
});
const rawPub = (k: crypto.KeyObject) => (crypto.createPublicKey(k).export({ type: "spki", format: "der" }) as Buffer).subarray(12);
const wgPub = (s: string) => crypto.createHash("sha256").update("wg:" + s).digest("base64");
let tsBump = 0;
async function subCall(name: string, method: string, path: string, body: unknown, auth: crypto.KeyObject | null, device?: number, extra: Record<string, string> = {}) {
const raw = body === undefined ? "" : JSON.stringify(body);
const ts = Math.floor(Date.now() / 1000) + (tsBump++ % 2); // разные подписи для одинаковых запросов
const h: Record<string, string> = { ...extra };
if (auth) { h["x-sen-ts"] = String(ts); h["x-sen-sig"] = signRequest(method, path, ts, raw, auth); }
if (device !== undefined) h["x-sen-device"] = String(device);
return call(name, method, path, undefined, { raw: body === undefined ? undefined : raw, headers: h, base: SUB });
}
// ── Сценарий ────────────────────────────────────────────────────────────────
async function main() {
let agent: ChildProcess | null = null;
try {
await call("brand", "GET", "/ui/brand");
await call("login bad", "POST", "/login", { user: "admin", pass: "nope" });
await call("login no body", "POST", "/login");
await call("login broken json", "POST", "/login", undefined, { raw: "{" });
const { token } = await call("login", "POST", "/login", { user: "admin", pass: "benchpass" });
const T = { token };
await call("apikeys no auth", "GET", "/ui/apikeys");
await call("apikeys bad token", "GET", "/ui/apikeys", undefined, { token: "a.b.c" });
await call("nodes no auth", "GET", "/ui/nodes");
await call("proxy no auth", "GET", "/api/users");
// прокси к awg-ctrl
await call("proxy health", "GET", "/health", undefined, T);
await call("proxy users", "GET", "/api/users", undefined, T);
await call("proxy add alice", "POST", "/api/users", { name: "alice" }, T);
await call("proxy add bad", "POST", "/api/users", { name: "a b" }, T);
await call("proxy unknown", "GET", "/api/nope?x=1", undefined, T);
await call("proxy server 5", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": "5" } });
await call("proxy server junk", "GET", "/api/users/stats", undefined, { ...T, headers: { "x-server-id": "junk" } });
// ключи API
await call("apikeys empty", "GET", "/ui/apikeys", undefined, T);
await call("apikey bad label", "POST", "/ui/apikeys", { label: "bad!" }, T);
await call("apikey no label", "POST", "/ui/apikeys", {}, T);
await call("apikey bad servers", "POST", "/ui/apikeys", { label: "k", servers: [9] }, T);
await call("apikey empty servers", "POST", "/ui/apikeys", { label: "k", servers: [] }, T);
const k1 = await call("apikey create", "POST", "/ui/apikeys", { label: "main key" }, T);
const k2 = await call("apikey create sid", "POST", "/ui/apikeys", { label: "second", server_id: 0, servers: [0, 0] }, T);
await call("apikeys list", "GET", "/ui/apikeys", undefined, T);
await call("apikey patch bad", "PATCH", "/ui/apikeys/" + k2.id, { servers: [] }, T);
await call("apikey patch missing", "PATCH", "/ui/apikeys/999", { servers: [0] }, T);
await call("apikey patch", "PATCH", "/ui/apikeys/" + k2.id, { servers: [0] }, T);
await call("apikey delete bad", "DELETE", "/ui/apikeys/x", undefined, T);
await call("apikey delete", "DELETE", "/ui/apikeys/" + k2.id, undefined, T);
// внешний API: пользователи
const K = { key: k1.key as string };
await call("v1 no key", "GET", "/api/v1/users");
await call("v1 bad key", "GET", "/api/v1/users", undefined, { key: "awgk_nope" });
await call("v1 users", "GET", "/api/v1/users", undefined, K);
await call("v1 add bob", "POST", "/api/v1/users", { name: "bob" }, K);
await call("v1 add bob again", "POST", "/api/v1/users", { name: "bob" }, K);
await call("v1 add no name", "POST", "/api/v1/users", {}, K);
await call("v1 get bob", "GET", "/api/v1/users/bob", undefined, K);
await call("v1 get nobody", "GET", "/api/v1/users/nobody", undefined, K);
await call("v1 get escaped", "GET", "/api/v1/users/b%20b", undefined, K);
await call("v1 server outside", "GET", "/api/v1/users", undefined, { ...K, headers: { "x-server-id": "3" } });
await call("v1 unknown with key", "GET", "/api/v1/zzz", undefined, K);
await call("v1 delete bob", "DELETE", "/api/v1/users/bob", undefined, K);
// ноды: заводим и подключаем агента к тому же awg-ctrl
await call("nodes list", "GET", "/ui/nodes", undefined, T);
await call("node bad name", "POST", "/ui/nodes", { name: "" }, T);
const n1 = await call("node create", "POST", "/ui/nodes", { name: "edge-1", host: "127.0.0.1" }, T);
await call("node rename bad", "PATCH", "/ui/nodes/" + n1.id, { name: "a<b" }, T);
await call("node rename", "PATCH", "/ui/nodes/" + n1.id, { name: "edge-one" }, T);
await call("node rejoin", "POST", `/ui/nodes/${n1.id}/join`, { host: "127.0.0.1" }, T).then(async r => {
const j = JSON.parse(Buffer.from(String(r.join).slice("awgjoin://".length), "base64url").toString());
fs.rmSync(`${BENCH}/agent-node.key`, { force: true });
agent = spawn(process.env.AGENT_BIN!, ["agent"], {
env: { ...process.env, CORE_HOST: j.h, CORE_PORT: String(j.p), CORE_PIN: j.pin, NODE_ID: String(j.n), JOIN_SECRET: j.s,
AWGCTRL_PORT: "3005", NODE_KEY_FILE: `${BENCH}/agent-node.key`, VERSION: "0.9.9",
INTERNAL_AUTH_KEY_FILE: "/etc/amnezia/amneziawg/internal_auth_private.key", CLI_ENV_FILE: "" },
stdio: "ignore",
});
});
for (let i = 0; i < 100; i++) {
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
if (r.nodes.some((n: any) => n.id === n1.id && n.online && n.health)) break;
await sleep(100);
}
await sleep(500);
await call("nodes online", "GET", "/ui/nodes", undefined, T);
await call("node join bound", "POST", `/ui/nodes/${n1.id}/join`, {}, T);
await call("proxy via node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
await call("proxy via node awg", "GET", "/awg/status", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
// мастер-ключи
await call("mk list empty", "GET", "/ui/masterkeys", undefined, T);
await call("mk bad label", "POST", "/ui/masterkeys", { label: "a\nb" }, T);
await call("mk bad limit", "POST", "/ui/masterkeys", { label: "x", device_limit: 0 }, T);
await call("mk bad servers", "POST", "/ui/masterkeys", { label: "x", servers: [42] }, T);
const m1 = await call("mk create", "POST", "/ui/masterkeys", { label: "Семья", device_limit: 2 }, T);
const m2 = await call("mk create multi", "POST", "/ui/masterkeys", { label: "Multi", servers: [n1.id, 0] }, T);
await call("mk list", "GET", "/ui/masterkeys", undefined, T);
const link = await call("mk link", "GET", `/ui/masterkeys/${m1.id}/link`, undefined, T);
await call("mk link by uuid", "GET", `/ui/masterkeys/${m1.uuid.toUpperCase()}/link`, undefined, T);
await call("mk link missing", "GET", "/ui/masterkeys/999/link", undefined, T);
const sen = decodeSenLink(link.link);
const secret = sen.secret.toString("base64url");
// /sub/v1: регистрация и опрос устройства
const d1 = seedKey("dev1"), d2 = seedKey("dev2"), d3 = seedKey("dev3");
const reg = (dev: string, k: crypto.KeyObject, sec: string, extra: object = {}) => ({
sub: sec, device_id: dev, device_name: "Phone " + dev, platform: "android", pub_key: wgPub(dev),
auth_pub: rawPub(k).toString("base64url"), version: "1.2.3", ...extra,
});
await subCall("sub unknown path", "GET", "/sub/v1/nope", undefined, null);
await subCall("sub broken json", "POST", "/sub/v1/peek", undefined, null).catch(() => {});
await call("sub broken json raw", "POST", "/sub/v1/peek", undefined, { raw: "{", base: SUB });
await subCall("sub peek bad", "POST", "/sub/v1/peek", { sub: "x" }, null);
await subCall("sub peek missing", "POST", "/sub/v1/peek", { sub: "A".repeat(22) }, null);
await subCall("sub peek", "POST", "/sub/v1/peek", { sub: secret }, null);
await subCall("sub register bad", "POST", "/sub/v1/register", { sub: secret }, d1);
await subCall("sub register unsigned", "POST", "/sub/v1/register", reg("d1", d1, secret), null);
const r1 = await subCall("sub register d1", "POST", "/sub/v1/register", reg("d1", d1, secret), d1, { "x-sen-device": "77" });
const dev1 = JSON.parse(r1.body).device;
const r2 = await subCall("sub register d2", "POST", "/sub/v1/register", reg("d2", d2, secret), d2);
const dev2 = JSON.parse(r2.body).device;
await subCall("sub register over limit", "POST", "/sub/v1/register", reg("d3", d3, secret), d3);
await subCall("sub config", "GET", "/sub/v1/config", undefined, d1, dev1, { "x-sen-version": "1.2.4" });
await subCall("sub config wrong key", "GET", "/sub/v1/config", undefined, d2, dev1);
await subCall("sub config no sig", "GET", "/sub/v1/config", undefined, null, dev1);
await subCall("sub config gone", "GET", "/sub/v1/config", undefined, d1, 9999);
await subCall("sub devices", "GET", "/sub/v1/devices", undefined, d2, dev2);
await subCall("sub rekey bad", "POST", "/sub/v1/rekey", { pub_key: "x" }, d1, dev1);
await subCall("sub rekey same", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1") }, d1, dev1);
await subCall("sub rekey taken", "POST", "/sub/v1/rekey", { pub_key: wgPub("d2") }, d1, dev1);
await subCall("sub rekey", "POST", "/sub/v1/rekey", { pub_key: wgPub("d1-new") }, d1, dev1);
// повтор той же подписи — replay
{
const ts = Math.floor(Date.now() / 1000);
const sig = signRequest("GET", "/sub/v1/config", ts, "", d2);
const h = { "x-sen-ts": String(ts), "x-sen-sig": sig, "x-sen-device": String(dev2) };
await call("sub config sig once", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
await call("sub config replay", "GET", "/sub/v1/config", undefined, { headers: h, base: SUB });
}
// устройство на ключе с двумя серверами (нода — тот же awg-ctrl)
const link2 = await call("mk2 link", "GET", `/ui/masterkeys/${m2.id}/link`, undefined, T);
const secret2 = decodeSenLink(link2.link).secret.toString("base64url");
const r3 = await subCall("sub register multi", "POST", "/sub/v1/register", reg("d3", d3, secret2), d3);
const dev3 = JSON.parse(r3.body).device;
await subCall("sub config multi", "GET", "/sub/v1/config", undefined, d3, dev3);
// панель: устройства и мастер-ключи
await call("mk devices", "GET", `/ui/masterkeys/${m1.id}/devices`, undefined, T);
await call("mk2 devices", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
await call("dev rekey", "POST", `/ui/devices/${dev2}/rekey`, undefined, T);
await call("dev psk", "POST", `/ui/devices/${dev2}/psk`, undefined, T);
await call("dev missing", "POST", "/ui/devices/999/psk", undefined, T);
await call("mk rekey", "POST", `/ui/masterkeys/${m1.id}/rekey`, undefined, T);
await subCall("sub config after rekey flag", "GET", "/sub/v1/config", undefined, d2, dev2);
await call("mk patch bad", "PATCH", `/ui/masterkeys/${m1.id}`, { device_limit: 101 }, T);
await call("mk patch limit", "PATCH", `/ui/masterkeys/${m1.id}`, { label: "Семья 2", device_limit: 1 }, T);
await subCall("sub config unbound", "GET", "/sub/v1/config", undefined, d2, dev2);
await call("mk2 patch servers", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0] }, T);
await call("mk rotate", "POST", `/ui/masterkeys/${m1.id}/rotate`, undefined, T);
await subCall("sub peek old secret", "POST", "/sub/v1/peek", { sub: secret }, null);
await call("dev delete", "DELETE", `/ui/devices/${dev1}`, undefined, T);
await subCall("sub config deleted", "GET", "/sub/v1/config", undefined, d1, dev1);
await subCall("sub delete self", "DELETE", "/sub/v1/device", undefined, d3, dev3);
// внешний API: мастер-ключи
await call("v1 mk list", "GET", "/api/v1/masterkeys", undefined, K);
await call("v1 mk create outside", "POST", "/api/v1/masterkeys", { label: "ext", servers: [n1.id] }, K);
const em = await call("v1 mk create", "POST", "/api/v1/masterkeys", { label: "ext" }, K);
await call("v1 mk get", "GET", `/api/v1/masterkeys/${em.id}`, undefined, K);
await call("v1 mk get by uuid", "GET", `/api/v1/masterkeys/${em.uuid}`, undefined, K);
await call("v1 mk get invisible", "GET", `/api/v1/masterkeys/${m2.id}`, undefined, K);
await call("v1 mk patch", "PATCH", `/api/v1/masterkeys/${em.id}`, { device_limit: 5 }, K);
await call("v1 mk rotate", "POST", `/api/v1/masterkeys/${em.id}/rotate`, undefined, K);
await call("v1 mk rekey", "POST", `/api/v1/masterkeys/${em.id}/rekey`, undefined, K);
await call("v1 mk devices", "GET", `/api/v1/masterkeys/${em.id}/devices`, undefined, K);
await call("v1 mk device missing", "DELETE", `/api/v1/masterkeys/${em.id}/devices/${dev2}`, undefined, K);
await call("v1 mk delete", "DELETE", `/api/v1/masterkeys/${em.id}`, undefined, K);
// аудит
await call("audit get", "GET", "/ui/masterkeys/audit", undefined, T);
await call("audit settings bad", "PUT", "/ui/masterkeys/audit/settings", { every_h: 0 }, T);
await call("audit settings", "PUT", "/ui/masterkeys/audit/settings", { clean: false, every_h: 2, max: 5 }, T);
await call("audit run", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean", "POST", "/ui/masterkeys/audit/clean", undefined, T);
// метрики
await call("metrics no token", "GET", "/metrics");
await call("metrics token none", "GET", "/ui/metrics-token", undefined, T);
const mt = await call("metrics token create", "POST", "/ui/metrics-token", undefined, T);
await call("metrics", "GET", "/metrics", undefined, { token: mt.token });
await call("metrics token view", "GET", "/ui/metrics-token", undefined, T);
await call("metrics token delete", "DELETE", "/ui/metrics-token", undefined, T);
await call("metrics after delete", "GET", "/metrics", undefined, { token: mt.token });
// монитор (Prometheus не запущен)
await call("monitor", "GET", "/ui/monitor", undefined, T);
await call("monitor overview bad", "GET", "/ui/monitor/overview?range=2h", undefined, T);
await call("monitor overview", "GET", "/ui/monitor/overview?range=6h", undefined, T);
// аудит с «мёртвыми»: лишний пир панели у awg-ctrl и пир в ядре без записи
{
const panel = fs.readFileSync(__dirname + "/../../awg-ui/panel.id", "utf8").trim();
await call("orphan add", "POST", "/api/peers", { pub_key: wgPub("orphan"), owner: `${panel}/m1/d77` }, T);
await call("foreign add", "POST", "/api/peers", { pub_key: wgPub("foreign"), owner: "ffffffffffff/m1/d1" }, T);
execSync(`awg set awg1 peer ${wgPub("ghost")} allowed-ips 10.9.9.9/32`);
await call("audit run 1", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean unconfirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
await call("audit run 2", "POST", "/ui/masterkeys/audit", undefined, T);
await call("audit clean confirmed", "POST", "/ui/masterkeys/audit/clean", undefined, T);
await call("audit after clean", "GET", "/ui/masterkeys/audit", undefined, T);
}
// ключ API на ноде мешает её удалить; потом нода уходит офлайн
const k3 = await call("apikey on node", "POST", "/ui/apikeys", { label: "node only", servers: [n1.id] }, T);
await call("node delete key veto", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("apikey to both", "PATCH", "/ui/apikeys/" + k3.id, { servers: [0, n1.id] }, T);
await call("v1 users on node", "GET", "/api/v1/users", undefined, { key: k3.key, headers: { "x-server-id": String(n1.id) } });
if (agent) { (agent as ChildProcess).kill(); agent = null; }
for (let i = 0; i < 50; i++) {
const r = await fetch(UI + "/ui/nodes", { headers: { authorization: `Bearer ${token}` } }).then(x => x.json()) as any;
if (r.nodes.some((n: any) => n.id === n1.id && !n.online)) break;
await sleep(100);
}
await call("nodes offline", "GET", "/ui/nodes", undefined, T);
await call("proxy via offline node", "GET", "/api/users", undefined, { ...T, headers: { "x-server-id": String(n1.id) } });
await call("mk2 back to node", "PATCH", `/ui/masterkeys/${m2.id}`, { servers: [0, n1.id] }, T);
await call("mk2 devices offline", "GET", `/ui/masterkeys/${m2.id}/devices`, undefined, T);
// удаление ноды
await call("node delete in use", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("mk2 delete", "DELETE", `/ui/masterkeys/${m2.id}`, undefined, T);
await call("node delete pending", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("node delete", "DELETE", `/ui/nodes/${n1.id}?force=1`, undefined, T);
await call("apikeys after node delete", "GET", "/ui/apikeys", undefined, T);
await call("node delete again", "DELETE", `/ui/nodes/${n1.id}`, undefined, T);
await call("mk list final", "GET", "/ui/masterkeys", undefined, T);
// статика и общие ответы
await call("static index", "GET", "/");
await call("static spa", "GET", "/some/route");
await call("static ui fallback", "GET", "/ui/unknown", undefined, T);
await call("post unknown", "POST", "/nope");
await call("wrong method", "PUT", "/ui/apikeys", undefined, T);
await call("logout", "POST", "/logout", undefined, T);
await call("after logout", "GET", "/ui/apikeys", undefined, T);
} finally {
if (agent) (agent as ChildProcess).kill();
}
fs.writeFileSync(out, JSON.stringify({ steps }, null, 1));
console.log(`scenario-ui: ${steps.length} steps → ${out}`);
}
main().catch(e => { console.error(e); fs.writeFileSync(out, JSON.stringify({ steps, error: String(e) }, null, 1)); process.exit(1); });
+63
View File
@@ -0,0 +1,63 @@
// Эталон для internal/ui (sen.go): ссылки sen:// и подписи протокола, как их делает awg-ui/sen.ts.
// Запуск: awg-ui/node_modules/.bin/tsx tools/golden/sen.ts > testdata/golden/sen.json
import crypto from "crypto";
import {
encodeSenLink, decodeSenLink, crc32, requestSigString, signResponse, signRequest, rawPublicKey,
type SenLink,
} from "../../awg-ui/sen";
const seed = (s: string) => crypto.createHash("sha256").update(s).digest();
// Ed25519 из фиксированного seed: PKCS#8 = префикс + 32 байта seed.
const keyFromSeed = (s: Buffer) => crypto.createPrivateKey({
key: Buffer.concat([Buffer.from("302e020100300506032b657004220420", "hex"), s]), format: "der", type: "pkcs8",
});
const signKey = keyFromSeed(seed("sign"));
const signPub = rawPublicKey(crypto.createPublicKey(signKey));
const hex = (b?: Buffer) => b?.toString("hex");
const links: SenLink[] = [
{ tls: false, addrs: [{ host: "203.0.113.7", port: 8443 }], secret: seed("s1").subarray(0, 16), signPub, name: "Семья" },
{ tls: true, addrs: [{ host: "vpn.example.com", port: 443 }], secret: seed("s2").subarray(0, 16), signPub, tlsPin: seed("pin"), name: "" },
{ tls: false, addrs: [{ host: "2001:db8::1", port: 1 }, { host: "::", port: 65535 }, { host: "fe80:0:0:1:0:0:0:1", port: 80 }],
secret: seed("s3").subarray(0, 16), signPub, name: "x".repeat(64) },
{ tls: false, addrs: [{ host: "1:2:3:4:5:6:7:8", port: 2 }], secret: seed("s4").subarray(0, 16), signPub, name: "emoji 😀 & <>" },
];
const badLinks = [
{ tls: false, addrs: [], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "256.1.1.1", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "1::2::3", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "bad_host", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "a.b", port: 0 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
{ tls: false, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "я".repeat(33) },
{ tls: true, addrs: [{ host: "a.b", port: 1 }], secret: seed("a").subarray(0, 16), signPub, name: "" },
];
const good = links.map(encodeSenLink);
const decodeCases = [
...good, " " + good[0].toUpperCase().slice(0, 6) + good[0].slice(6) + " \n", good[1].slice(0, 20) + "\n" + good[1].slice(20),
"http://x", "sen://", "sen://AAAA", "sen://a+b", good[0].slice(0, -2), good[0].slice(0, -1) + (good[0].endsWith("A") ? "B" : "A"),
];
const err = (f: () => unknown) => { try { return { ok: f() }; } catch (e) { return { error: (e as Error).message }; } };
process.stdout.write(JSON.stringify({
sign_seed: hex(seed("sign")),
sign_pub: hex(signPub),
crc32: ["", "a", "hello", "кириллица"].map(s => ({ in: s, out: crc32(Buffer.from(s)) })),
encode: links.map((l, i) => ({
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), tlsPin: hex(l.tlsPin), name: l.name, out: good[i],
})),
encode_errors: badLinks.map(l => ({
tls: l.tls, addrs: l.addrs, secret: hex(l.secret), name: l.name, error: err(() => encodeSenLink(l as SenLink)).error,
})),
decode: decodeCases.map(s => {
const r = err(() => decodeSenLink(s));
if ("error" in r) return { in: s, error: r.error };
const d = r.ok as SenLink;
return { in: s, tls: d.tls, addrs: d.addrs, secret: hex(d.secret), signPub: hex(d.signPub), tlsPin: hex(d.tlsPin) ?? null, name: d.name };
}),
sig_string: [
{ method: "post", path: "/sub/v1/register", ts: 1700000000, body: '{"a":1}' },
{ method: "GET", path: "/sub/v1/config", ts: 1, body: "" },
].map(c => ({ ...c, out: requestSigString(c.method, c.path, c.ts, c.body),
sig: signRequest(c.method, c.path, c.ts, c.body, signKey) })),
sign_response: [{ config: { rev: "ab", servers: [] }, device: 3, ts: 5 }, { error: "revoked" }].map(p => ({ in: p, out: signResponse(p, signKey) })),
}, null, 1));