199 lines
5.7 KiB
TypeScript
199 lines
5.7 KiB
TypeScript
import { spawn, execFileSync } from "node:child_process";
|
|
import { createWriteStream, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
export type ServerInfo = {
|
|
httpBase: string;
|
|
adminHttpBase: string;
|
|
mqttWs: string;
|
|
adminPassword: string;
|
|
stop: () => Promise<void>;
|
|
};
|
|
|
|
let cachedBin: string | undefined;
|
|
|
|
function findRepoRoot(): string {
|
|
let dir = __dirname;
|
|
for (;;) {
|
|
const mod = join(dir, "go.mod");
|
|
const cmd = join(dir, "cmd", "nixmsg");
|
|
if (existsSync(mod) && existsSync(cmd)) {
|
|
const text = readFileSync(mod, "utf8");
|
|
if (text.includes("module git.asio.asia/nixevol/NixMsg")) return dir;
|
|
}
|
|
const parent = dirname(dir);
|
|
if (parent === dir) throw new Error("repo root not found");
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function nixmsgBin(root: string): string {
|
|
if (cachedBin && existsSync(cachedBin)) return cachedBin;
|
|
const outDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-bin-"));
|
|
const name = process.platform === "win32" ? "nixmsg.exe" : "nixmsg";
|
|
const out = join(outDir, name);
|
|
execFileSync("go", ["build", "-o", out, "./cmd/nixmsg"], {
|
|
cwd: root,
|
|
env: { ...process.env, CGO_ENABLED: "0" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
cachedBin = out;
|
|
return out;
|
|
}
|
|
|
|
function parseAdminPassword(text: string): string {
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const s = line.trim();
|
|
const lower = s.toLowerCase();
|
|
if (lower.startsWith("admin password:")) return s.slice("admin password:".length).trim();
|
|
if (lower.startsWith("password:")) return s.slice("password:".length).trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function waitAddr(path: string, ms: number): Promise<string> {
|
|
const deadline = Date.now() + ms;
|
|
let last = "";
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const addr = readFileSync(path, "utf8").trim();
|
|
if (addr) return addr;
|
|
last = "empty";
|
|
} catch (e) {
|
|
last = String(e);
|
|
}
|
|
await sleep(20);
|
|
}
|
|
throw new Error(`wait listen.addr: ${last}`);
|
|
}
|
|
|
|
export async function startNixmsg(): Promise<ServerInfo> {
|
|
const root = findRepoRoot();
|
|
const bin = nixmsgBin(root);
|
|
const dataDir = mkdtempSync(join(tmpdir(), "nixmsg-sdk-js-itest-"));
|
|
const cfgPath = join(dataDir, "config.yaml");
|
|
const slash = dataDir.replace(/\\/g, "/");
|
|
writeFileSync(cfgPath, `listen: "127.0.0.1:0"\ndata_dir: "${slash}"\n`);
|
|
|
|
const init = execFileSync(bin, ["admin", "init"], {
|
|
env: { ...process.env, NIXMSG_CONFIG: cfgPath },
|
|
encoding: "utf8",
|
|
});
|
|
const adminPassword = parseAdminPassword(init);
|
|
if (!adminPassword) throw new Error(`admin init no password:\n${init}`);
|
|
|
|
const child = spawn(bin, ["serve"], {
|
|
env: { ...process.env, NIXMSG_CONFIG: cfgPath },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const log = createWriteStream(join(dataDir, "serve.log"));
|
|
child.stdout?.pipe(log);
|
|
child.stderr?.pipe(log);
|
|
|
|
let stopped = false;
|
|
const stop = async () => {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
if (child.pid) {
|
|
try {
|
|
child.kill();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
await new Promise<void>((r) => child.once("exit", () => r()));
|
|
}
|
|
try {
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
try {
|
|
const addr = await waitAddr(join(dataDir, "listen.addr"), 20000);
|
|
const httpBase = `http://${addr}`;
|
|
return {
|
|
httpBase,
|
|
adminHttpBase: httpBase,
|
|
mqttWs: `ws://${addr}/mqtt`,
|
|
adminPassword,
|
|
stop,
|
|
};
|
|
} catch (e) {
|
|
await stop();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export class AdminClient {
|
|
private cookie = "";
|
|
constructor(
|
|
private base: string,
|
|
private password: string,
|
|
) {}
|
|
|
|
async login(): Promise<void> {
|
|
const res = await fetch(`${this.base}/api/admin/login`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ username: "admin", password: this.password }),
|
|
});
|
|
const raw = await res.text();
|
|
if (!res.ok) throw new Error(`admin login ${res.status} ${raw}`);
|
|
const set = res.headers.getSetCookie?.() ?? [];
|
|
for (const c of set) {
|
|
const m = /^nixmsg_admin=([^;]+)/.exec(c);
|
|
if (m) this.cookie = m[1];
|
|
}
|
|
if (!this.cookie) {
|
|
// Node fetch may expose set-cookie differently
|
|
const sc = res.headers.get("set-cookie");
|
|
if (sc) {
|
|
const m = /nixmsg_admin=([^;]+)/.exec(sc);
|
|
if (m) this.cookie = m[1];
|
|
}
|
|
}
|
|
if (!this.cookie) throw new Error("admin cookie missing");
|
|
}
|
|
|
|
private headers(json = true): Record<string, string> {
|
|
const h: Record<string, string> = {
|
|
cookie: `nixmsg_admin=${this.cookie}`,
|
|
"X-Nixmsg-Request": "1",
|
|
};
|
|
if (json) h["content-type"] = "application/json";
|
|
return h;
|
|
}
|
|
|
|
async putRegistration(enabled: boolean, code?: string): Promise<void> {
|
|
const body: Record<string, unknown> = { enabled };
|
|
if (code) body.code = code;
|
|
const res = await fetch(`${this.base}/api/admin/registration`, {
|
|
method: "PUT",
|
|
headers: this.headers(),
|
|
body: JSON.stringify(body),
|
|
});
|
|
const raw = await res.text();
|
|
if (!res.ok) throw new Error(`registration put ${res.status} ${raw}`);
|
|
}
|
|
|
|
async kick(id: string): Promise<void> {
|
|
const res = await fetch(`${this.base}/api/admin/endpoints/${id}/kick`, {
|
|
method: "POST",
|
|
headers: this.headers(),
|
|
body: "{}",
|
|
});
|
|
const raw = await res.text();
|
|
if (!res.ok) throw new Error(`kick ${id} ${res.status} ${raw}`);
|
|
}
|
|
}
|
|
|
|
export function delay0(): number {
|
|
return 0;
|
|
}
|