Skip to content

Commit 7a24154

Browse files
ssh add --init flag (#26)
1 parent 3915338 commit 7a24154

2 files changed

Lines changed: 286 additions & 13 deletions

File tree

src/helpers/errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@ export function failedToConnect(): never {
2323
"Failed to connect to the server. Please check your internet connection and try again.",
2424
);
2525
}
26+
27+
export function unreachable(): never {
28+
throw new Error("unreachable code");
29+
}

src/lib/ssh.ts

Lines changed: 282 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,84 @@
1-
import { $ } from "bun";
1+
import { expect } from "bun:test";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import util from "node:util";
5+
import type { SpawnOptions, Subprocess } from "bun";
26
import type { Command } from "commander";
37
import { apiClient } from "../apiClient";
48
import { isLoggedIn } from "../helpers/config";
5-
import { logAndQuit, logLoginMessageAndQuit } from "../helpers/errors";
9+
import {
10+
logAndQuit,
11+
logLoginMessageAndQuit,
12+
unreachable,
13+
} from "../helpers/errors";
614
import { getInstances } from "./instances";
715

16+
// openssh-client doesn't check $HOME while homedir() does. This function is to
17+
// make it easy to fix if it causes issues.
18+
function sshHomedir(): string {
19+
return os.homedir();
20+
}
21+
22+
// Bun 1.1.29 does not handle empty arguments properly, for now use an `sh -c`
23+
// wrapper. Due to using `sh` as a wrapper it won't throw an error for unfound
24+
// executables, but will instead have an exitCode of 127 (as per usual shell
25+
// handling).
26+
function spawnWrapper<Opts extends SpawnOptions.OptionsObject>(
27+
cmds: string[],
28+
options?: Opts,
29+
): SpawnOptions.OptionsToSubprocess<Opts> {
30+
let shCmd = "";
31+
for (const cmd of cmds) {
32+
if (shCmd.length > 0) {
33+
shCmd += " ";
34+
}
35+
shCmd += '"';
36+
37+
// utf-16 code points are fine as we will ignore surrogates, and we don't
38+
// care about anything other than characters that don't require surrogates.
39+
for (const c of cmd) {
40+
switch (c) {
41+
case "$":
42+
case "\\":
43+
case "`":
44+
// @ts-ignore
45+
// biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional fallthrough
46+
case '"': {
47+
shCmd += "\\";
48+
// fallthrough
49+
}
50+
default: {
51+
shCmd += c;
52+
break;
53+
}
54+
}
55+
}
56+
shCmd += '"';
57+
}
58+
return Bun.spawn(["sh", "-c", shCmd], options);
59+
}
60+
61+
// Returns an absolute path (symbolic links, ".", and ".." are left
62+
// unnormalized).
63+
function normalizeSshConfigPath(sshPath: string): string {
64+
if (sshPath.length === 0) {
65+
throw new Error('invalid ssh config path ""');
66+
} else if (sshPath[0] === "/") {
67+
return sshPath;
68+
} else if (sshPath[0] === "~") {
69+
if (sshPath.length === 1 || sshPath[1] === "/") {
70+
return path.join(sshHomedir(), sshPath.slice(1));
71+
} else {
72+
// i.e. try `~root/foo` in your terminal and see how it handles it (same
73+
// behavior as ssh client).
74+
throw new Error("unimplemented");
75+
}
76+
} else {
77+
// Are they relative to ~/.ssh or to the cwd for things listed in ssh -G ?
78+
throw new Error("unimplemented");
79+
}
80+
}
81+
882
function isPubkey(key: string): boolean {
983
const pubKeyPattern = /^ssh-/;
1084
return pubKeyPattern.test(key);
@@ -34,6 +108,174 @@ async function readFileOrKey(keyOrFile: string): Promise<string> {
34108
}
35109
}
36110

111+
// This attempts to find the user's default ssh public key (or generate one),
112+
// and returns its value. Errors out and prints a message to the user if unable
113+
// to find, or generate one.
114+
async function findDefaultKey(): Promise<string> {
115+
// 1. Attempt to find the first identityfile within `ssh -G "" | grep
116+
// identityfile` that exists.
117+
// 2. If step 1 found no entries for `ssh -G` (and `ssh -V` succeeds) then use
118+
// the hardcoded list of identity files while printing a warning.
119+
// 3. If no key was found in step 1, and if applicable step 2 then generate a
120+
// key for the user using `ssh-keygen`.
121+
// 4. Now that we have a key and a public key, return the public key.
122+
123+
// The default keys for openssh client version "OpenSSH_9.2p1
124+
// Debian-2+deb12u3, OpenSSL 3.0.14 4 Jun 2024".
125+
const hardcodedPrivKeys: string[] = [
126+
"~/.ssh/id_rsa",
127+
"~/.ssh/id_ecdsa",
128+
"~/.ssh/id_ecdsa_sk",
129+
"~/.ssh/id_ed25519",
130+
"~/.ssh/id_ed25519_sk",
131+
"~/.ssh/id_xmss",
132+
"~/.ssh/id_dsa",
133+
];
134+
135+
{
136+
let proc: Subprocess<null, null, null>;
137+
try {
138+
proc = Bun.spawn(["ssh", "-V"], {
139+
stdin: null,
140+
stdout: null,
141+
stderr: null,
142+
});
143+
} catch (e) {
144+
if (e instanceof TypeError) {
145+
logAndQuit(
146+
"The ssh command is not installed, please install it before trying again.",
147+
);
148+
} else {
149+
throw e;
150+
}
151+
}
152+
await proc.exited;
153+
if (proc.exitCode !== 0) {
154+
logAndQuit("The ssh command is not functioning as expected.");
155+
}
156+
}
157+
158+
let identityFile: string | null = null;
159+
// If we found at least 1 identityfile (if not assume that our gross parsing
160+
// failed and log a warning message).
161+
let sshGParsedSuccess = false;
162+
163+
// If we believe key types to be supported by the ssh client.
164+
let keySupportedEd25519 = false;
165+
let keySupportedRsa = false;
166+
167+
const proc = spawnWrapper(["ssh", "-G", ""], {
168+
stdin: null,
169+
stdout: "pipe",
170+
stderr: null,
171+
});
172+
const stdout = await Bun.readableStreamToArrayBuffer(proc.stdout);
173+
await proc.exited;
174+
if (proc.exitCode === 0) {
175+
const decoder = new TextDecoder("utf-8", { fatal: true });
176+
let stdoutStr: string | null;
177+
try {
178+
stdoutStr = decoder.decode(stdout);
179+
} catch (e) {
180+
logAndQuit("The ssh command returned invalid utf-8");
181+
}
182+
183+
for (const line of stdoutStr.split("\n")) {
184+
const prefix = "identityfile ";
185+
if (line.startsWith(prefix)) {
186+
const lineSuffix = line.slice(prefix.length);
187+
if (
188+
lineSuffix === "~/.ssh/id_ed25519" ||
189+
lineSuffix === path.join(sshHomedir(), ".ssh/id_ed25519")
190+
) {
191+
keySupportedEd25519 = true;
192+
}
193+
if (
194+
lineSuffix === "~/.ssh/id_rsa" ||
195+
lineSuffix === path.join(sshHomedir(), ".ssh/id_rsa")
196+
) {
197+
keySupportedRsa = true;
198+
}
199+
const potentialIdentityFile = normalizeSshConfigPath(
200+
lineSuffix + ".pub",
201+
);
202+
sshGParsedSuccess = true;
203+
if (await Bun.file(potentialIdentityFile).exists()) {
204+
identityFile = potentialIdentityFile;
205+
break;
206+
}
207+
}
208+
}
209+
}
210+
211+
if (!sshGParsedSuccess) {
212+
expect(identityFile === null);
213+
214+
console.log(
215+
"Warning: failed finding default ssh keys (checking hardcoded list)",
216+
);
217+
keySupportedEd25519 = true;
218+
keySupportedRsa = true;
219+
for (const hardcodedPrivKey of hardcodedPrivKeys) {
220+
const potentialIdentityFile = normalizeSshConfigPath(
221+
hardcodedPrivKey + ".pub",
222+
);
223+
if (await Bun.file(potentialIdentityFile).exists()) {
224+
identityFile = potentialIdentityFile;
225+
break;
226+
}
227+
}
228+
}
229+
230+
if (identityFile === null) {
231+
console.log("Unable to find SSH key (generating new key)");
232+
233+
const sshDir: string = path.join(sshHomedir(), ".ssh");
234+
let privSshKeyPath: string;
235+
let extraSshOptions: string[];
236+
if (keySupportedEd25519) {
237+
extraSshOptions = ["-t", "ed25519"];
238+
privSshKeyPath = path.join(sshDir, "id_ed25519");
239+
} else if (keySupportedRsa) {
240+
extraSshOptions = ["-t", "rsa", "-b", "4096"];
241+
privSshKeyPath = path.join(sshDir, "id_rsa");
242+
} else {
243+
logAndQuit(
244+
"Unable to generate SSH key (neither rsa, nor ed25519 appear supported)",
245+
);
246+
}
247+
248+
const proc = spawnWrapper(
249+
["ssh-keygen", "-N", "", "-q", "-f", privSshKeyPath].concat(
250+
extraSshOptions,
251+
),
252+
{
253+
stdin: null,
254+
stdout: null,
255+
stderr: null,
256+
},
257+
);
258+
await proc.exited;
259+
if (proc.exitCode === 0) {
260+
// Success
261+
} else if (proc.exitCode === 127) {
262+
// Gross as technically ssh-keyen could also exit with 127. Remove once no
263+
// longer using spawnWrapper.
264+
logAndQuit(
265+
"The ssh-keygen command is not installed, please install it before trying again.",
266+
);
267+
} else {
268+
logAndQuit("The ssh-keygen command did not execute successfully.");
269+
}
270+
console.log(util.format("Generated key %s", privSshKeyPath));
271+
identityFile = privSshKeyPath + ".pub";
272+
}
273+
274+
console.log(util.format("Using ssh key %s", identityFile));
275+
const file = Bun.file(identityFile);
276+
return (await file.text()).trim();
277+
}
278+
37279
export function registerSSH(program: Command) {
38280
const cmd = program
39281
.command("ssh")
@@ -44,6 +286,7 @@ export function registerSSH(program: Command) {
44286
"Specify the username associated with the pubkey",
45287
"ubuntu",
46288
)
289+
.option("--init", "Attempt to automatically add the first default ssh key")
47290
.argument("[name]", "The name of the node to SSH into");
48291

49292
cmd.action(async (name, options) => {
@@ -57,38 +300,64 @@ export function registerSSH(program: Command) {
57300
return;
58301
}
59302

60-
if (options.add && name) {
303+
if (options.init && options.add) {
304+
logAndQuit("--init is not compatible with --add");
305+
}
306+
307+
if ((options.add || options.init) && name) {
61308
logAndQuit("You can only add a key to all nodes at once");
62309
}
63310

64311
if (name) {
312+
let proc: Subprocess<"inherit", "inherit", "inherit">;
65313
const instances = await getInstances({ clusterId: undefined });
66314
const instance = instances.find((instance) => instance.id === name);
67315
if (!instance) {
68316
logAndQuit(`Instance ${name} not found`);
69317
}
70318
if (instance.ip.split(":").length === 2) {
71319
const [ip, port] = instance.ip.split(":");
72-
await $`ssh -p ${port} ${options.user}@${ip}`;
320+
proc = Bun.spawn(
321+
["ssh", "-p", port, util.format("%s@%s", options.user, ip)],
322+
{
323+
stdin: "inherit",
324+
stdout: "inherit",
325+
stderr: "inherit",
326+
},
327+
);
73328
} else {
74-
await $`ssh ${options.user}@${instance.ip}`;
329+
proc = Bun.spawn(
330+
["ssh", util.format("%s@%s", options.user, instance.ip)],
331+
{
332+
stdin: "inherit",
333+
stdout: "inherit",
334+
stderr: "inherit",
335+
},
336+
);
337+
}
338+
await proc;
339+
if (proc.exitCode === 255) {
340+
console.log(
341+
"The ssh command appears to possibly have failed. To set up ssh keys please run `sf ssh --init`.",
342+
);
75343
}
76344
process.exit(0);
77345
}
78346

79-
if (options.add) {
80-
if (!options.user) {
81-
logAndQuit(
82-
"Username is required when adding an SSH key (add it with --user <username>)",
83-
);
347+
if (options.init || options.add) {
348+
let pubkey: string;
349+
if (options.init) {
350+
pubkey = await findDefaultKey();
351+
} else if (options.add) {
352+
pubkey = await readFileOrKey(options.add);
353+
} else {
354+
unreachable();
84355
}
85356

86-
const key = await readFileOrKey(options.add);
87-
88357
const api = await apiClient();
89358
await api.POST("/v0/credentials", {
90359
body: {
91-
pubkey: key,
360+
pubkey,
92361
username: options.user,
93362
},
94363
});

0 commit comments

Comments
 (0)