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" ;
26import type { Command } from "commander" ;
37import { apiClient } from "../apiClient" ;
48import { isLoggedIn } from "../helpers/config" ;
5- import { logAndQuit , logLoginMessageAndQuit } from "../helpers/errors" ;
9+ import {
10+ logAndQuit ,
11+ logLoginMessageAndQuit ,
12+ unreachable ,
13+ } from "../helpers/errors" ;
614import { 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+
882function isPubkey ( key : string ) : boolean {
983 const pubKeyPattern = / ^ s s h - / ;
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+
37279export 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