// Helpers for the transports' loopback checks (atlibs-iroh's and atlibs-webrtc's tests/loopback.test.mjs): // long-lived helpers, free ports, a throwaway sshd. A test file calls // after(cleanup) once; what these start is stopped then. import { execFile, spawn } from "node:child_process"; import { readFile, rm, writeFile } from "node:fs/promises"; import { createServer, connect } from "node:net"; import { join } from "node:path"; import { promisify } from "node:util"; export const ROOT = new URL("../../", import.meta.url).pathname; export const exec = promisify(execFile); export const which = (name) => exec("sh", ["-c", `command -v ${name}`]).then(({ stdout }) => stdout.trim(), () => null); export const missing = async (...names) => (await Promise.all(names.map(async (n) => ((await which(n)) ? null : n)))).filter(Boolean); // What to stop at the end: child processes, temp dirs, servers. export const children = []; export const temps = []; export const servers = []; export async function cleanup() { for (const child of children) child.kill(); for (const server of servers) server.close(); await Promise.all(temps.map((dir) => rm(dir, { recursive: true, force: true }))); } // Spawns a long-lived helper; resolves with the first match of `pattern` on its stdout or stderr. export function spawnUntil(cmd, args, pattern, options = {}, ms = 15000) { const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...options }); children.push(child); return new Promise((resolve, reject) => { let seen = ""; const timer = setTimeout(() => reject(new Error(`${cmd}: no ${pattern} in ${ms} ms: ${seen}`)), ms); const on = (chunk) => { seen += chunk; const match = seen.match(pattern); if (match) clearTimeout(timer), resolve({ child, match }); }; child.stdout.on("data", on); child.stderr.on("data", on); child.on("exit", (code) => (clearTimeout(timer), reject(new Error(`${cmd} exited ${code}: ${seen}`)))); }); } export const freePort = () => new Promise((resolve) => { const s = createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); export const listening = async (port, ms = 5000) => { for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 50))) { const ok = await new Promise((resolve) => connect(port, "127.0.0.1").on("connect", function () { this.end(); resolve(true); }).on("error", () => resolve(false))); if (ok) return; } throw new Error(`nothing listening on 127.0.0.1:${port}`); }; // A throwaway sshd on a random 127.0.0.1 port: a temp host key of `type`, authorized_keys in dir (written // by the caller, read per login). An unprivileged sshd logs in only the user running it (it cannot setuid), // so StrictModes is off for the temp files and the clients log in as this user, not production's root. export async function sshd(dir, type) { await exec("ssh-keygen", ["-q", "-t", type, "-N", "", "-C", "loopback-host", "-f", join(dir, "host_key")]); const port = await freePort(); await writeFile(join(dir, "sshd_config"), [`Port ${port}`, "ListenAddress 127.0.0.1", `HostKey ${dir}/host_key`, `AuthorizedKeysFile ${dir}/authorized_keys`, "StrictModes no", "PasswordAuthentication no", "KbdInteractiveAuthentication no", "UsePAM no", `PidFile ${dir}/sshd.pid`, ""].join("\n")); children.push(spawn(await which("sshd"), ["-D", "-e", "-f", join(dir, "sshd_config")], { stdio: "ignore" })); await listening(port); return { port, hostKey: await readFile(join(dir, "host_key.pub"), "utf8") }; } // rust/host/target/loopback//release/ (`cargo loopback`, its own target dir): `name` is atlibs // (built without iroh). ATLIBS_LOOPBACK_DIR overrides the directory. export const loopbackBin = (hostBin, name) => join(process.env.ATLIBS_LOOPBACK_DIR ?? hostBin.replace("/rust/host/target/", "/rust/host/target/loopback/").replace(/\/(debug|release)\/atlibs$/, "/release"), name);