Skip to content

Commit 85577a2

Browse files
add sf vm ssh (#107)
1 parent 41de614 commit 85577a2

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

deno.lock

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"test": "deno test --allow-all"
99
},
1010
"dependencies": {
11+
"@alphahydrae/exec": "^1.1.0",
1112
"@commander-js/extra-typings": "^12.1.0",
1213
"@inkjs/ui": "^2.0.0",
1314
"@inquirer/prompts": "^5.1.2",
@@ -36,6 +37,7 @@
3637
"prettier": "^3.5.3",
3738
"react": "^18.3.1",
3839
"semver": "^7.6.3",
40+
"shescape": "^2.1.1",
3941
"tiny-invariant": "^1.3.3",
4042
"tweetnacl": "^1.0.3",
4143
"tweetnacl-util": "^0.15.1",

src/helpers/urls.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@ const apiPaths: Record<string, Path<IdParams | never>> = {
4545

4646
vms_instances_list: "/v0/vms/instances",
4747
vms_logs_list: "/v0/vms/logs",
48+
vms_replace: "/v0/vms/replace",
4849
vms_script_post: "/v0/vms/script",
4950
vms_script_get: "/v0/vms/script",
50-
vms_replace: "/v0/vms/replace",
51+
vms_ssh_get: "/v0/vms/ssh",
5152
};
5253

5354
export async function getWebAppUrl<T extends TokenParams | never>(

src/lib/ssh.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { Command } from "@commander-js/extra-typings";
2+
import console from "node:console";
3+
import process from "node:process";
4+
import { Shescape } from "shescape";
5+
import { execvp } from "@alphahydrae/exec";
6+
import {
7+
logAndQuit,
8+
logSessionTokenExpiredAndQuit,
9+
} from "../helpers/errors.ts";
10+
import { getApiUrl } from "../helpers/urls.ts";
11+
import { getAuthToken } from "../helpers/config.ts";
12+
13+
type SshHostKey = {
14+
keyType: string;
15+
base64EncodedKey: string;
16+
};
17+
18+
export function registerSsh(program: Command) {
19+
program
20+
.command("ssh")
21+
.option("-q, --quiet", "Quiet mode", false)
22+
.option(
23+
"--use-host-keys [value]",
24+
"Use API provided SSH server host keys if known",
25+
true,
26+
)
27+
.argument(
28+
"<destination>",
29+
"USERNAME@VM_ID The (optional) username, and VM id to SSH into.",
30+
)
31+
.allowExcessArguments(false)
32+
.action(async (destination, options) => {
33+
const splitDestination = destination.split("@");
34+
let vmId: string;
35+
let sshUsername: string | undefined;
36+
if (splitDestination.length == 1) {
37+
sshUsername = undefined;
38+
vmId = splitDestination[0];
39+
} else if (splitDestination.length == 2) {
40+
sshUsername = splitDestination[0];
41+
vmId = splitDestination[1];
42+
} else {
43+
logAndQuit(`Invalid SSH destination string: ${destination}`);
44+
}
45+
46+
const baseUrl = await getApiUrl("vms_ssh_get");
47+
const params = new URLSearchParams();
48+
params.append("vm_id", vmId);
49+
const url = `${baseUrl}?${params.toString()}`;
50+
const response = await fetch(url, {
51+
method: "GET",
52+
headers: {
53+
Authorization: `Bearer ${await getAuthToken()}`,
54+
},
55+
});
56+
57+
if (!response.ok) {
58+
if (response.status === 401) {
59+
logSessionTokenExpiredAndQuit();
60+
}
61+
62+
logAndQuit(
63+
`Failed to retrieve ssh information: ${response.statusText}`,
64+
);
65+
}
66+
67+
const data = (await response.json()) as {
68+
ssh_hostname: string;
69+
ssh_port: number;
70+
ssh_host_keys: SshHostKey[] | undefined;
71+
};
72+
const sshHostname = data.ssh_hostname;
73+
const sshPort = data.ssh_port;
74+
const sshHostKeys = data.ssh_host_keys || [];
75+
76+
let sshDestination = sshHostname;
77+
if (sshUsername !== undefined) {
78+
sshDestination = `${sshUsername}@${sshDestination}`;
79+
}
80+
if (sshPort !== undefined) {
81+
sshDestination = `${sshDestination}:${sshPort}`;
82+
}
83+
sshDestination = `ssh://${sshDestination}`;
84+
85+
let cmd = ["ssh"];
86+
87+
if (sshHostKeys.length > 0 && options.useHostKeys) {
88+
let knownHostsCommand = ["/usr/bin/env", "printf", "%s %s %s\\n"];
89+
for (const sshHostKey of sshHostKeys) {
90+
knownHostsCommand = knownHostsCommand.concat([
91+
sshHostname,
92+
sshHostKey.keyType,
93+
sshHostKey.base64EncodedKey,
94+
]);
95+
}
96+
// Escape all characters for proper pass through
97+
for (const i in knownHostsCommand) {
98+
knownHostsCommand[i] = knownHostsCommand[i].replaceAll("%", "%%");
99+
knownHostsCommand[i] = knownHostsCommand[i].replaceAll('"', '\\"');
100+
knownHostsCommand[i] = '"' + knownHostsCommand[i] + '"';
101+
}
102+
const knownHostsCommand_str = knownHostsCommand.join(" ");
103+
cmd = cmd.concat(["-o", `KnownHostsCommand=${knownHostsCommand_str}`]);
104+
}
105+
106+
cmd = cmd.concat([sshDestination]);
107+
108+
let shescape: undefined | Shescape = undefined;
109+
let shell: undefined | string = undefined;
110+
if (process.env.SHELL !== undefined) {
111+
try {
112+
shescape = new Shescape({
113+
flagProtection: false,
114+
shell: process.env.SHELL,
115+
});
116+
shell = process.env.SHELL;
117+
} catch {
118+
// shescape will stay undefined
119+
}
120+
}
121+
if (shescape === undefined) {
122+
shescape = new Shescape({
123+
flagProtection: false,
124+
shell: "/bin/sh",
125+
});
126+
shell = "/bin/sh";
127+
}
128+
const shell_cmd = shescape.quoteAll(cmd).join(" ");
129+
if (!options.quiet) {
130+
console.log(`Executing (${shell} style output): ${shell_cmd}`);
131+
}
132+
133+
execvp(cmd[0], cmd.slice(1));
134+
});
135+
}

src/lib/vm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../helpers/errors.ts";
1515
import { getApiUrl } from "../helpers/urls.ts";
1616
import { isFeatureEnabled } from "./posthog.ts";
17+
import { registerSsh } from "./ssh.ts";
1718

1819
type VMInstance = {
1920
id: string;
@@ -34,6 +35,8 @@ export async function registerVM(program: Command) {
3435
.aliases(["v", "vms"])
3536
.description("Manage virtual machines");
3637

38+
registerSsh(vm);
39+
3740
vm.command("list")
3841
.description("List all virtual machines")
3942
.action(async () => {

0 commit comments

Comments
 (0)