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:
@@ -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");
|
||||
@@ -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");
|
||||
Executable
+46
@@ -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"
|
||||
@@ -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); });
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user