mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
This commit is contained in:
parent
1cbc7a0e8e
commit
b78e6a9085
7 changed files with 549 additions and 6 deletions
31
.github/workflows/build-binaries.yml
vendored
31
.github/workflows/build-binaries.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
4
.github/workflows/env.yml
vendored
4
.github/workflows/env.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
1
packages/env/CHANGELOG.md
vendored
1
packages/env/CHANGELOG.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
20
packages/env/README.md
vendored
20
packages/env/README.md
vendored
|
|
@ -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).
|
||||
|
|
|
|||
15
packages/env/src/index.ts
vendored
15
packages/env/src/index.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
322
packages/env/src/ssh.ts
vendored
Normal file
322
packages/env/src/ssh.ts
vendored
Normal file
|
|
@ -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-<env name>`. */
|
||||
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<string> {
|
||||
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<string[]>((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<void> {
|
||||
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<RemotePlatform> {
|
||||
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<RemotePlatform, "platform" | "arch">): 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<string> {
|
||||
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 };
|
||||
}
|
||||
162
packages/env/test/ssh.test.ts
vendored
Normal file
162
packages/env/test/ssh.test.ts
vendored
Normal file
|
|
@ -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<number> {
|
||||
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<void> {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
const open = await new Promise<boolean>((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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue