Files
kandev/apps/web/e2e/helpers/ssh-bastion.ts
T
2026-08-11 19:33:50 +01:00

285 lines
9.3 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SSH_E2E_IMAGE_TAG } from "../fixtures/ssh-image";
import { E2E_DOCKER_SCOPE } from "../fixtures/docker-probe";
import type { SSHServerHandle } from "./ssh";
/**
* Two-container setup for ProxyJump end-to-end tests:
*
* kandev backend --ssh--> bastion (port published) --tcp--> target (no port published)
*
* `bastion` lives on a private Docker network and has a published port the
* host can dial. `target` is on the same private network only — there is no
* port-forward from the host to it, so any successful connection to target
* MUST have traversed the bastion. That makes the assertion honest: a green
* test proves we actually proxied, not that we side-stepped.
*
* Each test worker gets its own network and pair of containers so concurrent
* workers don't fight over container names or IP ranges.
*/
export type SSHBastionHandles = {
bastion: SSHServerHandle;
target: SSHServerHandle;
/** Docker network name; teardown removes it. */
networkName: string;
/**
* Hostname the kandev backend uses for the target. Resolves inside the
* private network only — supplying this as `host` to the SSH executor
* proves the connection went through the bastion.
*/
targetInternalHost: string;
};
const BASTION_BASE_PORT = 23000;
export function startBastionAndTarget(workerIndex: number, workDir: string): SSHBastionHandles {
fs.mkdirSync(workDir, { recursive: true });
const networkName = `kandev-e2e-ssh-net-${E2E_DOCKER_SCOPE}-${workerIndex}`;
ensureNetwork(networkName);
// The SSH executor's ProxyJump string doesn't carry a separate identity for
// the bastion (matching OpenSSH semantics — per-host identities come from
// ~/.ssh/config or ssh-agent). For these tests we share one keypair across
// bastion + target so a single -i flag authenticates both hops.
const sharedIdentityFile = path.join(workDir, "shared_id_ed25519");
generateKeypair(sharedIdentityFile);
const bastionPort = BASTION_BASE_PORT + workerIndex * 2;
const bastion = launchInNetwork({
name: `kandev-bastion-${E2E_DOCKER_SCOPE}-${workerIndex}`,
role: "bastion",
networkName,
hostPort: bastionPort,
workDir: path.join(workDir, "bastion"),
identityFile: sharedIdentityFile,
});
const target = launchInNetwork({
name: `kandev-target-${E2E_DOCKER_SCOPE}-${workerIndex}`,
role: "target",
networkName,
hostPort: null, // not reachable from host
workDir: path.join(workDir, "target"),
identityFile: sharedIdentityFile,
});
return {
bastion,
target,
networkName,
targetInternalHost: target.containerName,
};
}
export function stopBastionAndTarget(handles: SSHBastionHandles): void {
spawnSync("docker", ["rm", "-f", handles.bastion.containerName], { stdio: "ignore" });
spawnSync("docker", ["rm", "-f", handles.target.containerName], { stdio: "ignore" });
spawnSync("docker", ["network", "rm", handles.networkName], { stdio: "ignore" });
}
// --- internals ---
function ensureNetwork(name: string): void {
// `docker network create` errors if it exists; check first to be idempotent.
const inspect = spawnSync("docker", ["network", "inspect", name], { stdio: "ignore" });
if (inspect.status === 0) return;
execFileSync("docker", ["network", "create", "--driver", "bridge", name], {
stdio: process.env.E2E_DEBUG ? "inherit" : "ignore",
});
}
interface LaunchOpts {
name: string;
role: "bastion" | "target";
networkName: string;
hostPort: number | null;
workDir: string;
/** Pre-generated identity file to mount as authorized_keys. */
identityFile: string;
}
function launchInNetwork(opts: LaunchOpts): SSHServerHandle {
fs.mkdirSync(opts.workDir, { recursive: true });
const identityFile = opts.identityFile;
const publicKeyFile = `${identityFile}.pub`;
spawnSync("docker", ["rm", "-f", opts.name], { stdio: "ignore" });
const args = [
"run",
"-d",
"--rm",
"--name",
opts.name,
"--network",
opts.networkName,
"--label",
"kandev.managed=true",
"--label",
`kandev.e2e.run=${E2E_DOCKER_SCOPE}`,
"--label",
`kandev.e2e.role=ssh-${opts.role}`,
"--cap-add",
"NET_ADMIN",
"-v",
`${publicKeyFile}:/authorized_keys:ro`,
];
if (opts.hostPort !== null) {
args.push("-p", `127.0.0.1:${opts.hostPort}:22`);
}
args.push(SSH_E2E_IMAGE_TAG);
const containerId = execFileSync("docker", args, { encoding: "utf8" }).toString().trim();
// For the bastion, wait for its published port to open and grab the host
// fingerprint. For the target, we don't expose anything on the host, so
// grab the fingerprint from inside the network via a one-shot helper
// container on the same network.
let hostFingerprint = "";
if (opts.hostPort !== null) {
waitForTCPOpen("127.0.0.1", opts.hostPort);
hostFingerprint = scanFingerprintViaHost("127.0.0.1", opts.hostPort);
} else {
waitForTCPOpenInsideNetwork(opts.networkName, opts.name, 22);
hostFingerprint = scanFingerprintInsideNetwork(opts.networkName, opts.name);
}
return {
containerId,
containerName: opts.name,
host: opts.hostPort !== null ? "127.0.0.1" : opts.name,
port: opts.hostPort ?? 22,
user: "kandev",
identityFile,
publicKeyFile,
hostFingerprint,
workDir: opts.workDir,
};
}
function generateKeypair(identityFile: string): void {
if (fs.existsSync(identityFile)) {
fs.rmSync(identityFile, { force: true });
fs.rmSync(`${identityFile}.pub`, { force: true });
}
execFileSync(
"ssh-keygen",
["-t", "ed25519", "-f", identityFile, "-N", "", "-C", "kandev-e2e", "-q"],
{ stdio: process.env.E2E_DEBUG ? "inherit" : "ignore" },
);
fs.chmodSync(identityFile, 0o600);
}
function waitForTCPOpen(host: string, port: number, timeoutMs = 15_000): void {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (spawnSync("nc", ["-z", host, String(port)], { stdio: "ignore" }).status === 0) return;
spawnSync("sleep", ["0.15"]);
}
throw new Error(`${host}:${port} did not open within ${timeoutMs}ms`);
}
function waitForTCPOpenInsideNetwork(
networkName: string,
targetName: string,
targetPort: number,
timeoutMs = 15_000,
): void {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = spawnSync(
"docker",
[
"run",
"--rm",
"--network",
networkName,
"alpine:3.20",
"sh",
"-c",
`nc -z ${targetName} ${targetPort}`,
],
{ stdio: "ignore" },
);
if (res.status === 0) return;
spawnSync("sleep", ["0.2"]);
}
throw new Error(
`${targetName}:${targetPort} on ${networkName} did not open within ${timeoutMs}ms`,
);
}
function scanFingerprintViaHost(host: string, port: number): string {
// sshd often accepts TCP before it's finished negotiating host keys —
// ssh-keyscan can come back empty on the first try right after the
// container starts. Retry a few times before giving up so the bastion
// fixture isn't flaky on a cold daemon.
const deadline = Date.now() + 15_000;
let lastErr = "";
while (Date.now() < deadline) {
const keyOut = spawnSync("ssh-keyscan", ["-p", String(port), "-t", "ed25519", host], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (keyOut.stdout?.trim()) {
return parseFingerprint(keyOut.stdout);
}
lastErr = keyOut.stderr ?? "";
spawnSync("sleep", ["0.3"]);
}
throw new Error(`scanFingerprintViaHost: empty output (stderr=${lastErr})`);
}
function scanFingerprintInsideNetwork(networkName: string, targetName: string): string {
// openssh-client is the package that ships ssh-keyscan on Alpine. (The
// historical "openssh-keyscan" name doesn't exist in repos.) Pin to a
// known-good Alpine and surface install/keyscan stderr so failures are
// diagnosable instead of empty. Retry on empty output — sshd accepts TCP
// before completing host-key negotiation on a cold container, mirroring
// the same flake guard the host-side scanFingerprintViaHost helper uses.
const deadline = Date.now() + 15_000;
let lastErr = "";
while (Date.now() < deadline) {
const res = spawnSync(
"docker",
[
"run",
"--rm",
"--network",
networkName,
"alpine:3.20",
"sh",
"-c",
`apk add --no-cache openssh-client >/dev/null 2>&1 && ssh-keyscan -t ed25519 ${targetName}`,
],
{ encoding: "utf8" },
);
if (res.stdout?.trim()) {
return parseFingerprint(res.stdout);
}
lastErr = `status=${res.status}, stderr=${res.stderr ?? ""}`;
spawnSync("sleep", ["0.3"]);
}
throw new Error(`scanFingerprintInsideNetwork: empty output after retry (${lastErr})`);
}
function parseFingerprint(keyscanOutput: string): string {
if (!keyscanOutput.trim()) {
throw new Error("ssh-keyscan produced no output");
}
const tmp = path.join(os.tmpdir(), `kandev-e2e-keyscan-${process.pid}-${Date.now()}`);
fs.writeFileSync(tmp, keyscanOutput);
try {
const fp = spawnSync("ssh-keygen", ["-lf", tmp], { encoding: "utf8" });
const match = fp.stdout.match(/SHA256:\S+/);
if (!match) throw new Error(`could not parse SHA256 fingerprint from: ${fp.stdout}`);
return match[0];
} finally {
fs.rmSync(tmp, { force: true });
}
}