From b78e6a9085343ec0f308c3d377da72528b4cf7ee Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 5 Oct 2026 13:06:08 +0200 Subject: [PATCH] feat(env): SSH bootstrap and daemons in the npm package - connectSsh detects the remote system, deploys the packaged daemon verified by SHA-256 before it runs, and connects over ssh with strict host keys under a fixed alias; scanHostKey and acceptHostKey - Windows detection and deployment through PowerShell - release builds the daemon for every supported remote system and ships them in @earendil-works/pi-env's bin/ - end-to-end tests against a disposable sshd --- .github/workflows/build-binaries.yml | 31 ++- .github/workflows/env.yml | 4 + packages/env/CHANGELOG.md | 1 + packages/env/README.md | 20 +- packages/env/src/index.ts | 15 ++ packages/env/src/ssh.ts | 322 +++++++++++++++++++++++++++ packages/env/test/ssh.test.ts | 162 ++++++++++++++ 7 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 packages/env/src/ssh.ts create mode 100644 packages/env/test/ssh.test.ts diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 90db9aed7..5bafe00ef 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -301,9 +301,17 @@ jobs: exit 1 fi + # The pi-env daemon for every remote system @earendil-works/pi-env supports; the npm package ships them all. + env-daemons: + uses: ./.github/workflows/env-daemons.yml + permissions: + contents: read + with: + ref: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} + publish-npm: runs-on: ubuntu-latest - needs: stage-github-release + needs: [stage-github-release, env-daemons] environment: npm-publish permissions: contents: read @@ -334,12 +342,33 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Download pi-env daemons + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: pi-env-* + path: packages/env/bin + + - name: Check pi-env daemons + run: | + set -euo pipefail + for name in linux-x64 linux-arm64 android-arm64 android-x64 darwin-arm64 darwin-x64; do + test -s "packages/env/bin/pi-env-${name}/pi-env" + done + for name in windows-x64 windows-arm64; do + test -s "packages/env/bin/pi-env-${name}/pi-env.exe" + done + chmod +x packages/env/bin/*/pi-env* + ls -lR packages/env/bin + - name: Build run: npm run build - name: Check run: npm run check + - name: Build the pi-env daemon for tests + run: npm run build:daemon --workspace packages/env + - name: Test run: npm test diff --git a/.github/workflows/env.yml b/.github/workflows/env.yml index 1d7cce797..d54068950 100644 --- a/.github/workflows/env.yml +++ b/.github/workflows/env.yml @@ -65,6 +65,10 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts + - name: Install an SSH server for the bootstrap tests + if: runner.os == 'Linux' + run: command -v sshd || test -x /usr/sbin/sshd || (sudo apt-get update && sudo apt-get install -y openssh-server) + - name: Daemon format, lint and unit tests working-directory: packages/env/daemon run: | diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index bd87f7833..5534c6681 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -5,3 +5,4 @@ ### Added - Initial release of `@earendil-works/pi-env`: `RemoteExecutionEnv`, a Durable `ExecutionEnv` on another machine, and `Connection` to the `pi-env` daemon that runs there. Results match `NodeExecutionEnv` on that machine on Linux, macOS, Android and Windows. +- SSH bootstrap: `connectSsh` detects the remote system, deploys the daemon this package ships for it (verified by SHA-256 before it runs), and connects; `scanHostKey` and `acceptHostKey` manage trusted host keys. diff --git a/packages/env/README.md b/packages/env/README.md index 7f202fd4f..2368e1568 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -10,12 +10,23 @@ while the Durable worker, its storage and credentials stay local. ([docs/semantics.md](docs/semantics.md)). ```ts -import { Connection, RemoteExecutionEnv } from "@earendil-works/pi-env"; +import { acceptHostKey, connectSsh, HostKeyUnknownError, RemoteExecutionEnv, scanHostKey } from "@earendil-works/pi-env"; -const connection = new Connection({ command: ["ssh", "-T", "--", "gpu-box", "~/.pi/mobile/tools/pi-env"] }); -const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu-box", cwd: "/home/me/project" }); +const target = { host: "gpu-box", knownHostsFile: "/data/ssh/known_hosts", hostKeyAlias: "pi-env-gpu" }; +// Once: show the host's key fingerprint to the owner, who compares it out of band and accepts it. +const { lines, fingerprints } = await scanHostKey(target); +await acceptHostKey(target, lines); + +// Detects the remote system, deploys this version's daemon if missing (verified by SHA-256 before it runs), and +// returns a connection that starts it over ssh. +const { connection } = await connectSsh(target); +const env = new RemoteExecutionEnv({ connection, id: "pi-env:gpu", cwd: "/home/me/project" }); ``` +The package ships the daemon for every supported remote system in `bin/`. `ssh` runs with `BatchMode`, strict host-key +checking against the application's own known-hosts file under a fixed alias, no forwarding, and without forwarding the +local locale. On Windows, detection and deployment go through PowerShell. + Supported remote systems: Linux, macOS, Android (Termux) and Windows, on x86-64 and arm64. On Windows, string commands run through Git Bash as `NodeExecutionEnv` runs them there; argv commands run directly. @@ -25,5 +36,4 @@ run through Git Bash as `NodeExecutionEnv` runs them there; argv commands run di run Durable's env conformance suite against `RemoteExecutionEnv` and compare random operation sequences and Durable's tools against `NodeExecutionEnv` on the same machine. -Not yet: SSH bootstrap and binary deployment, output windowing (all output is transferred), daemon-side file watching -(the client polls). +Not yet: output windowing (all output is transferred) and daemon-side file watching (the client polls). diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index e6e78f0c6..814666cb9 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -1,3 +1,18 @@ export { Connection, type ConnectionOptions, RemoteError, type RemoteInfo, type RequestOptions } from "./connection.ts"; export { PollingWatcher } from "./polling-watch.ts"; export { RemoteExecutionEnv, type RemoteExecutionEnvOptions } from "./remote-env.ts"; +export { + acceptHostKey, + connectSsh, + deployDaemon, + detectPlatform, + HostKeyChangedError, + HostKeyUnknownError, + packagedDaemon, + type RemotePlatform, + type SshConnectOptions, + SshError, + type SshTarget, + scanHostKey, + sshArguments, +} from "./ssh.ts"; diff --git a/packages/env/src/ssh.ts b/packages/env/src/ssh.ts new file mode 100644 index 000000000..15a775204 --- /dev/null +++ b/packages/env/src/ssh.ts @@ -0,0 +1,322 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { appendFile, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Connection, type ConnectionOptions } from "./connection.ts"; + +/** A remote system the package ships a daemon for. */ +export type RemotePlatform = { + platform: "linux" | "android" | "darwin" | "windows"; + arch: "x64" | "arm64"; + /** The remote home directory, in the remote system's own spelling. */ + home: string; +}; + +/** How to reach a machine with the system `ssh`. */ +export interface SshTarget { + /** Host name or `~/.ssh/config` alias. */ + host: string; + user?: string; + port?: number; + identityFile?: string; + /** Host keys this application trusts; `acceptHostKey` adds to it. */ + knownHostsFile: string; + /** The name keys are stored under, independent of aliases, ports and jump hosts, e.g. `pi-env-`. */ + hostKeyAlias: string; + /** The `ssh` program; default `ssh`. */ + ssh?: string; + /** An `ssh` configuration file instead of `~/.ssh/config` (`-F`). */ + configFile?: string; +} + +/** The remote host's key is not in `knownHostsFile`; `scanHostKey` shows it so the owner can accept it. */ +export class HostKeyUnknownError extends Error {} + +/** The remote host's key differs from the one in `knownHostsFile`; it is never accepted automatically. */ +export class HostKeyChangedError extends Error {} + +/** An `ssh` invocation that failed for another reason, with its exit code and diagnostics. */ +export class SshError extends Error { + readonly exitCode: number | null; + readonly stderr: string; + + constructor(message: string, exitCode: number | null, stderr: string) { + super(message); + this.exitCode = exitCode; + this.stderr = stderr; + } +} + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const VERSION = (JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { version: string }).version; + +/** No leading `-` (it would be read as an `ssh` option) and no whitespace or control characters. */ +function checkField(name: string, value: string): void { + if (value === "" || value.startsWith("-") || /[\s\x00-\x1f\x7f]/.test(value)) { + throw new Error(`Invalid ${name}: ${JSON.stringify(value)}`); + } +} + +/** + * Arguments for `ssh` up to the host: no prompts, no forwarding, no locale forwarding (the remote uses its own), and + * host keys checked strictly against the application's own file under a fixed alias. + */ +export function sshArguments( + target: SshTarget, + strictHostKeys = true, + knownHostsFile = target.knownHostsFile, +): string[] { + checkField("host", target.host); + checkField("host key alias", target.hostKeyAlias); + if (target.user !== undefined) checkField("user", target.user); + if (target.port !== undefined && (!Number.isInteger(target.port) || target.port < 1 || target.port > 65535)) { + throw new Error(`Invalid port: ${target.port}`); + } + return [ + ...(target.configFile === undefined ? [] : ["-F", target.configFile]), + "-T", + "-o", + "BatchMode=yes", + "-o", + "ClearAllForwardings=yes", + "-o", + "SendEnv=-*", + "-o", + "ServerAliveInterval=15", + "-o", + `StrictHostKeyChecking=${strictHostKeys ? "yes" : "accept-new"}`, + "-o", + `UserKnownHostsFile=${knownHostsFile}`, + "-o", + `HostKeyAlias=${target.hostKeyAlias}`, + ...(target.user === undefined ? [] : ["-l", target.user]), + ...(target.port === undefined ? [] : ["-p", String(target.port)]), + ...(target.identityFile === undefined ? [] : ["-i", target.identityFile, "-o", "IdentitiesOnly=yes"]), + "--", + target.host, + ]; +} + +/** Run one remote command over `ssh`, optionally feeding stdin; resolves with stdout, rejects on failure. */ +function runSsh( + target: SshTarget, + command: string, + options: { stdin?: Uint8Array; args?: string[] } = {}, +): Promise { + return new Promise((resolve, reject) => { + const args = options.args ?? sshArguments(target); + const child = spawn(target.ssh ?? "ssh", [...args, command], { stdio: ["pipe", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.stdin.on("error", () => {}); + child.on("error", (error) => reject(new SshError(error.message, null, ""))); + child.on("close", (code) => { + const output = Buffer.concat(stdout).toString("utf8"); + const diagnostics = Buffer.concat(stderr).toString("utf8"); + if (code === 0) return resolve(output); + if (/REMOTE HOST IDENTIFICATION HAS CHANGED/.test(diagnostics)) { + return reject( + new HostKeyChangedError(`The host key of ${target.host} changed; remove the old key to continue`), + ); + } + if (/Host key verification failed|No .* host key is known/.test(diagnostics)) { + return reject(new HostKeyUnknownError(`The host key of ${target.host} is not trusted yet`)); + } + reject( + new SshError(`ssh ${target.host} failed with exit code ${code}: ${diagnostics.trim()}`, code, diagnostics), + ); + }); + child.stdin.end(options.stdin ?? new Uint8Array(0)); + }); +} + +/** + * Connect once with `accept-new` against a temporary known-hosts file to capture the key the host presents, through + * the same route (`~/.ssh/config`, jump hosts) as real connections. Returns its known-hosts lines and fingerprints. + * Showing a fingerprint is not authentication: compare it with one obtained out of band before accepting it. + */ +export async function scanHostKey(target: SshTarget): Promise<{ lines: string[]; fingerprints: string[] }> { + const directory = await mkdtemp(join(tmpdir(), "pi-env-hostkey-")); + try { + const scanned = join(directory, "known_hosts"); + // Authentication may fail without the key; the key is recorded before authentication. + await runSsh(target, "exit 0", { args: sshArguments(target, false, scanned) }).catch((error: unknown) => { + if (!(error instanceof SshError) && !(error instanceof HostKeyUnknownError)) throw error; + }); + const lines = existsSync(scanned) + ? (await readFile(scanned, "utf8")).split("\n").filter((line) => line.trim() !== "") + : []; + if (lines.length === 0) throw new Error(`No host key received from ${target.host}`); + const fingerprints = await new Promise((resolve, reject) => { + const child = spawn("ssh-keygen", ["-lf", scanned], { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.on("error", reject); + child.on("close", () => resolve(output.split("\n").filter((line) => line.trim() !== ""))); + }); + return { lines, fingerprints }; + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +/** Trust host-key lines from `scanHostKey` by adding them to the target's known-hosts file. */ +export async function acceptHostKey(target: SshTarget, lines: readonly string[]): Promise { + await mkdir(dirname(target.knownHostsFile), { recursive: true, mode: 0o700 }); + const existing = existsSync(target.knownHostsFile) ? await readFile(target.knownHostsFile, "utf8") : ""; + const known = new Set(existing.split("\n")); + const added = lines.filter((line) => !known.has(line)); + if (added.length > 0) { + const prefix = existing === "" || existing.endsWith("\n") ? "" : "\n"; + await appendFile(target.knownHostsFile, `${prefix}${added.join("\n")}\n`, { mode: 0o600 }); + } +} + +/** POSIX detection, run by the remote login shell; a Windows host answers through PowerShell instead. */ +const POSIX_PROBE = `sh -c 'echo PI-ENV-PROBE; uname -s; uname -m; uname -o 2>/dev/null || echo -; printf "%s\\n" "$HOME"'`; + +function powershell(script: string): string { + return `powershell -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`; +} + +const WINDOWS_PROBE = powershell( + "'PI-ENV-PROBE'; 'Windows'; [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); '-'; $HOME", +); + +function normalizeArch(machine: string): RemotePlatform["arch"] { + const lower = machine.toLowerCase(); + if (lower === "x86_64" || lower === "amd64" || lower === "x64") return "x64"; + if (lower === "aarch64" || lower === "arm64") return "arm64"; + throw new Error(`Unsupported remote architecture: ${machine}`); +} + +function parseProbe(target: SshTarget, output: string): { system: string; machine: string; os: string; home: string } { + // Login shells may print a banner first. + const lines = output.split(/\r?\n/); + const start = lines.indexOf("PI-ENV-PROBE"); + if (start === -1) throw new Error(`Unexpected answer from ${target.host}: ${output.trim()}`); + const [system = "", machine = "", os = "", home = ""] = lines.slice(start + 1); + return { system, machine, os, home }; +} + +/** Which system the target runs: `uname` through the login shell, or PowerShell on Windows. */ +export async function detectPlatform(target: SshTarget): Promise { + let output: string; + try { + output = await runSsh(target, POSIX_PROBE); + } catch (error) { + // cmd.exe or PowerShell as the remote shell: no `sh`. + if (!(error instanceof SshError)) throw error; + output = await runSsh(target, WINDOWS_PROBE); + } + let answer = parseProbe(target, output); + // Git Bash as Windows' default SSH shell: ask PowerShell for Windows' own architecture and home spelling. + if (/^(MINGW|MSYS|CYGWIN)/.test(answer.system)) answer = parseProbe(target, await runSsh(target, WINDOWS_PROBE)); + const { system, os, home } = answer; + const arch = normalizeArch(answer.machine); + if (system === "Windows") return { platform: "windows", arch, home }; + if (system === "Darwin") return { platform: "darwin", arch, home }; + if (system === "Linux") return { platform: os === "Android" ? "android" : "linux", arch, home }; + throw new Error(`Unsupported remote system: ${system}`); +} + +/** The daemon binary this package ships for a remote system. */ +export function packagedDaemon(remote: Pick): string { + const name = `pi-env-${remote.platform}-${remote.arch}`; + return join(packageRoot, "bin", name, remote.platform === "windows" ? "pi-env.exe" : "pi-env"); +} + +function quotePosix(text: string): string { + return `'${text.replaceAll("'", "'\\''")}'`; +} + +/** + * Make sure the daemon of this package's version is on the remote machine, verified by its SHA-256 before it ever + * runs: an existing file with another hash is replaced. Returns the remote path of the binary. + */ +export async function deployDaemon( + target: SshTarget, + remote: RemotePlatform, + binary = packagedDaemon(remote), +): Promise { + if (!existsSync(binary)) throw new Error(`No pi-env daemon for ${remote.platform}-${remote.arch} at ${binary}`); + const bytes = await readFile(binary); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + if (remote.platform === "windows") { + const file = `${remote.home}\\.pi\\mobile\\tools\\pi-env-${VERSION}.exe`; + const literal = `'${file.replaceAll("'", "''")}'`; + const check = powershell( + `$f = ${literal}; if ((Test-Path -LiteralPath $f) -and ((Get-FileHash -Algorithm SHA256 -LiteralPath $f).Hash.ToLower() -eq '${sha256}')) { 'present' } else { 'missing' }`, + ); + if ((await runSsh(target, check)).trim().endsWith("present")) return file; + const upload = powershell( + [ + "$ErrorActionPreference = 'Stop'", + `$f = ${literal}`, + "$d = Split-Path -Parent $f", + "New-Item -ItemType Directory -Force -Path $d | Out-Null", + "$t = Join-Path $d ('.pi-env-' + [guid]::NewGuid().ToString() + '.tmp')", + "$in = [Console]::OpenStandardInput(); $out = [IO.File]::Create($t); $in.CopyTo($out); $out.Close()", + `if ((Get-FileHash -Algorithm SHA256 -LiteralPath $t).Hash.ToLower() -ne '${sha256}') { Remove-Item -LiteralPath $t; throw 'pi-env upload is corrupt' }`, + // A running daemon or a virus scanner can hold the old file for a moment. + "for ($i = 0; ; $i++) { try { Move-Item -Force -LiteralPath $t -Destination $f; break } catch { if ($i -ge 20) { throw }; Start-Sleep -Milliseconds 250 } }", + "'deployed'", + ].join("; "), + ); + await runSsh(target, upload, { stdin: bytes }); + return file; + } + const file = `${remote.home}/.pi/mobile/tools/pi-env-${VERSION}`; + const hash = `hash() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1; elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | sed 's/.*= //'; else echo none; fi; }`; + const check = `${hash}; f=${quotePosix(file)}; if [ -f "$f" ] && [ "$(hash "$f")" = ${sha256} ]; then echo present; elif [ "$(hash /dev/null)" = none ]; then echo nohash; else echo missing; fi`; + const state = (await runSsh(target, `sh -c ${quotePosix(check)}`)).trim().split("\n").at(-1); + if (state === "present") return file; + if (state === "nohash") throw new Error(`${target.host} has no sha256sum, shasum or openssl to verify pi-env`); + const upload = [ + "set -e", + hash, + `f=${quotePosix(file)}`, + 'd=$(dirname "$f")', + 'mkdir -p "$d"', + 'chmod 700 "$d"', + 't=$(mktemp "$d/.pi-env.XXXXXX")', + 'cat > "$t"', + `if [ "$(hash "$t")" != ${sha256} ]; then rm -f "$t"; echo "pi-env upload is corrupt" >&2; exit 1; fi`, + 'chmod 700 "$t"', + 'mv -f "$t" "$f"', + "echo deployed", + ].join("\n"); + await runSsh(target, `sh -c ${quotePosix(upload)}`, { stdin: bytes }); + return file; +} + +/** Options for `connectSsh`: the target, plus the binary to deploy (default: the one this package ships). */ +export interface SshConnectOptions extends SshTarget { + binary?: string; + onLog?: ConnectionOptions["onLog"]; +} + +/** + * Detect the remote system, deploy the daemon if needed, and return a `Connection` that starts it over `ssh`. Host + * keys must already be trusted (`scanHostKey`, `acceptHostKey`); otherwise this rejects with `HostKeyUnknownError`. + */ +export async function connectSsh( + options: SshConnectOptions, +): Promise<{ connection: Connection; remote: RemotePlatform }> { + const remote = await detectPlatform(options); + const file = await deployDaemon(options, remote, options.binary ?? packagedDaemon(remote)); + // cmd.exe, Windows' default SSH shell, needs double quotes; POSIX shells get single quotes. + const program = remote.platform === "windows" ? (file.includes(" ") ? `"${file}"` : file) : quotePosix(file); + const connection = new Connection({ + command: [options.ssh ?? "ssh", ...sshArguments(options), program], + ...(options.onLog === undefined ? {} : { onLog: options.onLog }), + }); + return { connection, remote }; +} diff --git a/packages/env/test/ssh.test.ts b/packages/env/test/ssh.test.ts new file mode 100644 index 000000000..f40eb9f02 --- /dev/null +++ b/packages/env/test/ssh.test.ts @@ -0,0 +1,162 @@ +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createServer, Socket } from "node:net"; +import { tmpdir, userInfo } from "node:os"; +import { join, resolve } from "node:path"; +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { getOrThrow } from "@earendil-works/pi-durable/env"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { RemoteExecutionEnv } from "../src/remote-env.ts"; +import { + acceptHostKey, + connectSsh, + HostKeyChangedError, + HostKeyUnknownError, + type SshTarget, + scanHostKey, +} from "../src/ssh.ts"; + +const daemon = resolve(import.meta.dirname, "../daemon/target/debug/pi-env"); +const version = ( + JSON.parse(readFileSync(resolve(import.meta.dirname, "../package.json"), "utf8")) as { version: string } +).version; +const context = BACKGROUND_CONTEXT; + +function which(program: string): string | undefined { + try { + return execFileSync("sh", ["-c", `command -v ${program}`], { encoding: "utf8" }).trim() || undefined; + } catch { + return undefined; + } +} + +const sshd = + process.platform === "win32" + ? undefined + : (which("sshd") ?? (existsSync("/usr/sbin/sshd") ? "/usr/sbin/sshd" : undefined)); + +async function freePort(): Promise { + return new Promise((resolve) => { + const server = createServer().listen(0, "127.0.0.1", () => { + const { port } = server.address() as { port: number }; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForPort(port: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + const open = await new Promise((resolve) => { + const socket = new Socket(); + socket.once("connect", () => resolve(true)).once("error", () => resolve(false)); + socket.connect(port, "127.0.0.1"); + }).finally(() => undefined); + if (open) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("sshd did not start"); +} + +// A disposable sshd on localhost with its own host key, client key and home directory. +describe.skipIf(sshd === undefined)("SSH bootstrap", () => { + const root = mkdtempSync(join(tmpdir(), "pi-env-ssh-")); + const home = join(root, "home"); + let server: ChildProcess | undefined; + let target: SshTarget; + + beforeAll(async () => { + execFileSync("mkdir", ["-p", home]); + execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "host_key")]); + execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "client_key")]); + writeFileSync(join(root, "authorized_keys"), readFileSync(join(root, "client_key.pub"))); + const port = await freePort(); + writeFileSync( + join(root, "sshd_config"), + [ + `Port ${port}`, + "ListenAddress 127.0.0.1", + `HostKey ${join(root, "host_key")}`, + `AuthorizedKeysFile ${join(root, "authorized_keys")}`, + `PidFile ${join(root, "sshd.pid")}`, + "PasswordAuthentication no", + "KbdInteractiveAuthentication no", + "StrictModes no", + `SetEnv HOME=${home}`, + "", + ].join("\n"), + ); + writeFileSync(join(root, "ssh_config"), ""); + server = spawn(sshd!, ["-D", "-e", "-f", join(root, "sshd_config")], { stdio: "ignore" }); + await waitForPort(port); + target = { + host: "127.0.0.1", + port, + user: userInfo().username, + identityFile: join(root, "client_key"), + knownHostsFile: join(root, "known_hosts"), + hostKeyAlias: "pi-env-test", + configFile: join(root, "ssh_config"), + }; + }); + + afterAll(() => { + server?.kill(); + rmSync(root, { recursive: true, force: true }); + }); + + it("refuses an untrusted host, then deploys and runs the daemon once its key is accepted", async () => { + await expect(connectSsh({ ...target, binary: daemon })).rejects.toBeInstanceOf(HostKeyUnknownError); + + const scanned = await scanHostKey(target); + const expected = execFileSync("ssh-keygen", ["-lf", join(root, "host_key.pub")], { encoding: "utf8" }).split( + " ", + )[1]; + expect(scanned.fingerprints.join("\n")).toContain(expected); + await acceptHostKey(target, scanned.lines); + + const { connection, remote } = await connectSsh({ ...target, binary: daemon }); + try { + expect(remote.home).toBe(home); + const deployed = readFileSync(join(home, ".pi/mobile/tools", `pi-env-${version}`)); + expect(deployed.equals(readFileSync(daemon))).toBe(true); + const env = new RemoteExecutionEnv({ connection, id: "pi-env:test", cwd: home }); + getOrThrow(await env.writeFile("over-ssh.txt", "hello", context)); + expect(getOrThrow(await env.readTextFile("over-ssh.txt", context))).toBe("hello"); + const output: string[] = []; + const result = getOrThrow( + await env.exec(["sh", "-c", 'printf "%s" "$HOME"'], { onOutput: (text) => output.push(text) }, context), + ); + expect(result.exitCode).toBe(0); + expect(output.join("")).toBe(home); + } finally { + connection.close(); + } + }, 60_000); + + it("reuses a verified daemon and replaces a tampered one", async () => { + const file = join(home, ".pi/mobile/tools", `pi-env-${version}`); + const before = statSync(file).mtimeMs; + (await connectSsh({ ...target, binary: daemon })).connection.close(); + expect(statSync(file).mtimeMs).toBe(before); + + writeFileSync(file, "tampered"); + const { connection } = await connectSsh({ ...target, binary: daemon }); + try { + expect(readFileSync(file).equals(readFileSync(daemon))).toBe(true); + expect((await connection.info()).home).toBe(home); + } finally { + connection.close(); + } + }, 60_000); + + it("refuses a changed host key", async () => { + const other = join(root, "other_key"); + execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", other]); + const key = readFileSync(`${other}.pub`, "utf8").split(" ").slice(0, 2).join(" "); + const knownHosts = join(root, "changed_known_hosts"); + writeFileSync(knownHosts, `pi-env-test ${key}\n`); + await expect(connectSsh({ ...target, knownHostsFile: knownHosts, binary: daemon })).rejects.toBeInstanceOf( + HostKeyChangedError, + ); + }, 60_000); +});