Skip to content
Merged
15 changes: 11 additions & 4 deletions packages/cli/src/commands/sandbox/ssh-exec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmod, mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { sandboxKnownHostsPath } from "@bunny.net/sandbox/known-hosts";
import type { SandboxRecord } from "../../config/schema.ts";

export const WORKPLACE = "/workplace";
Expand Down Expand Up @@ -36,10 +37,14 @@ export function sshArgs(
...(options.tty ? ["-t"] : []),
"-p",
portStr,
// Trust the host key on first contact, in the dedicated file the SDK also uses.
"-o",
"StrictHostKeyChecking=no",
"StrictHostKeyChecking=accept-new",
"-o",
"UserKnownHostsFile=/dev/null",
`UserKnownHostsFile=${sandboxKnownHostsPath()}`,
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
// Plaintext entries so the SDK's parser can read the shared file.
"-o",
"HashKnownHosts=no",
"-o",
"LogLevel=ERROR",
`root@${host}`,
Expand All @@ -59,6 +64,8 @@ export async function withSshEnv<T>(
record: SandboxRecord,
fn: (env: Record<string, string>) => Promise<T>,
): Promise<T> {
// ssh writes the known-hosts file but won't create its parent directory.
await mkdir(dirname(sandboxKnownHostsPath()), { recursive: true });
const dir = await mkdtemp(join(tmpdir(), "bunny-ssh-"));
const scriptPath = join(dir, "askpass");
await Bun.write(scriptPath, `#!/bin/sh\nprintf '%s' "$BUNNY_SSH_TOKEN"\n`);
Expand Down
4 changes: 4 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./known-hosts": {
"types": "./dist/known-hosts.d.ts",
"import": "./dist/known-hosts.js"
}
},
"files": [
Expand Down
68 changes: 68 additions & 0 deletions packages/sandbox/src/known-hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";

/**
* The known-hosts file shared by the CLI and libssh2.
*/
export function sandboxKnownHostsPath(): string {
const base = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
return join(base, "bunny", "sandbox_known_hosts");
}

/** OpenSSH known_hosts label; non-default ports are bracketed. */
function hostLabel(host: string, port: number): string {
return port === 22 ? host : `[${host}]:${port}`;
}

/** Algorithm name from the front of an SSH public-key blob, or null if malformed. */
function keyType(key: Buffer): string | null {
if (key.length < 4) return null;
const len = key.readUInt32BE(0);
if (4 + len > key.length) return null;
return key.toString("ascii", 4, 4 + len);
}

/**
* Trust-on-first-use check for a server host key (`key` is the raw public-key
* blob).
* The first key seen for a host and type is recorded and trusted; a later
* connection presenting a different key for that host is rejected, catching an
* impostor before the token is sent as the password.
*/
export function verifyKnownHost(
host: string,
port: number,
key: Buffer,
path: string = sandboxKnownHostsPath(),
): boolean {
const type = keyType(key);
if (!type) return false;
const label = hostLabel(host, port);
const encoded = key.toString("base64");

let text = "";
try {
text = readFileSync(path, "utf8");
} catch {
// No file yet — first contact.
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
}
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [hosts, lineType, lineKey] = trimmed.split(/\s+/);
if (!hosts || !lineType || !lineKey) continue;
// known_hosts allows several comma-separated hosts per line.
if (lineType !== type || !hosts.split(",").includes(label)) continue;
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
return lineKey === encoded;
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
}

try {
mkdirSync(dirname(path), { recursive: true });
// Append so concurrent connects don't clobber each other.
appendFileSync(path, `${label} ${type} ${encoded}\n`, { mode: 0o600 });
} catch {
// Can't persist (e.g. read-only home): trust it anyway, like OpenSSH accept-new — no cross-run pin.
}
return true;
}
3 changes: 3 additions & 0 deletions packages/sandbox/src/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type SFTPWrapper,
} from "ssh2";
import { SandboxError } from "./errors.ts";
import { verifyKnownHost } from "./known-hosts.ts";

export interface TransportConfig {
host: string;
Expand Down Expand Up @@ -147,6 +148,8 @@ export class SshTransport {
port: this.config.port,
username: this.config.username ?? "root",
password: this.config.password,
hostVerifier: (key: Buffer) =>
verifyKnownHost(this.config.host, this.config.port, key),
Comment thread
jedisct1 marked this conversation as resolved.
Outdated
readyTimeout: 15_000,
};

Expand Down
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
"@bunny.net/openapi-client/generated/*": [
"./packages/openapi-client/src/generated/*"
],
"@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"]
"@bunny.net/sandbox": ["./packages/sandbox/src/index.ts"],
"@bunny.net/sandbox/known-hosts": [
"./packages/sandbox/src/known-hosts.ts"
]
},

// Best practices
Expand Down