@@ -12,6 +12,7 @@ import (
12
12
"path/filepath"
13
13
"strconv"
14
14
"strings"
15
+ "syscall"
15
16
"time"
16
17
17
18
"github.com/pkg/browser"
@@ -21,18 +22,23 @@ import (
21
22
22
23
const codeServerPath = "~/.cache/sshcode/sshcode-server"
23
24
25
+ const (
26
+ sshDirectory = "~/.ssh"
27
+ sshDirectoryUnsafeModeMask = 0022
28
+ sshControlPath = sshDirectory + "/control-%h-%p-%r"
29
+ )
30
+
24
31
type options struct {
25
- skipSync bool
26
- syncBack bool
27
- noOpen bool
28
- bindAddr string
29
- remotePort string
30
- sshFlags string
32
+ skipSync bool
33
+ syncBack bool
34
+ noOpen bool
35
+ reuseConnection bool
36
+ bindAddr string
37
+ remotePort string
38
+ sshFlags string
31
39
}
32
40
33
41
func sshCode (host , dir string , o options ) error {
34
- flog .Info ("ensuring code-server is updated..." )
35
-
36
42
host , extraSSHFlags , err := parseHost (host )
37
43
if err != nil {
38
44
return xerrors .Errorf ("failed to parse host IP: %w" , err )
@@ -53,6 +59,24 @@ func sshCode(host, dir string, o options) error {
53
59
return xerrors .Errorf ("failed to find available remote port: %w" , err )
54
60
}
55
61
62
+ // Check the SSH directory's permissions and warn the user if it is not safe.
63
+ o .reuseConnection = checkSSHDirectory (sshDirectory , o .reuseConnection )
64
+
65
+ // Start SSH master connection socket. This prevents multiple password prompts from appearing as authentication
66
+ // only happens on the initial connection.
67
+ if o .reuseConnection {
68
+ flog .Info ("starting SSH master connection..." )
69
+ newSSHFlags , cancel , err := startSSHMaster (o .sshFlags , sshControlPath , host )
70
+ defer cancel ()
71
+ if err != nil {
72
+ flog .Error ("failed to start SSH master connection: %v" , err )
73
+ o .reuseConnection = false
74
+ } else {
75
+ o .sshFlags = newSSHFlags
76
+ }
77
+ }
78
+
79
+ flog .Info ("ensuring code-server is updated..." )
56
80
dlScript := downloadScript (codeServerPath )
57
81
58
82
// Downloads the latest code-server and allows it to be executed.
@@ -147,8 +171,8 @@ func sshCode(host, dir string, o options) error {
147
171
case <- c :
148
172
}
149
173
174
+ flog .Info ("shutting down" )
150
175
if ! o .syncBack || o .skipSync {
151
- flog .Info ("shutting down" )
152
176
return nil
153
177
}
154
178
@@ -167,6 +191,24 @@ func sshCode(host, dir string, o options) error {
167
191
return nil
168
192
}
169
193
194
+ // expandPath returns an expanded version of path.
195
+ func expandPath (path string ) string {
196
+ path = filepath .Clean (os .ExpandEnv (path ))
197
+
198
+ // Replace tilde notation in path with the home directory. You can't replace the first instance of `~` in the
199
+ // string with the homedir as having a tilde in the middle of a filename is valid.
200
+ homedir := os .Getenv ("HOME" )
201
+ if homedir != "" {
202
+ if path == "~" {
203
+ path = homedir
204
+ } else if strings .HasPrefix (path , "~/" ) {
205
+ path = filepath .Join (homedir , path [2 :])
206
+ }
207
+ }
208
+
209
+ return filepath .Clean (path )
210
+ }
211
+
170
212
func parseBindAddr (bindAddr string ) (string , error ) {
171
213
if ! strings .Contains (bindAddr , ":" ) {
172
214
bindAddr += ":"
@@ -263,6 +305,100 @@ func randomPort() (string, error) {
263
305
return "" , xerrors .Errorf ("max number of tries exceeded: %d" , maxTries )
264
306
}
265
307
308
+ // checkSSHDirectory performs sanity and safety checks on sshDirectory, and
309
+ // returns a new value for o.reuseConnection depending on the checks.
310
+ func checkSSHDirectory (sshDirectory string , reuseConnection bool ) bool {
311
+ sshDirectoryMode , err := os .Lstat (expandPath (sshDirectory ))
312
+ if err != nil {
313
+ if reuseConnection {
314
+ flog .Info ("failed to stat %v directory, disabling connection reuse feature: %v" , sshDirectory , err )
315
+ }
316
+ reuseConnection = false
317
+ } else {
318
+ if ! sshDirectoryMode .IsDir () {
319
+ if reuseConnection {
320
+ flog .Info ("%v is not a directory, disabling connection reuse feature" , sshDirectory )
321
+ } else {
322
+ flog .Info ("warning: %v is not a directory" , sshDirectory )
323
+ }
324
+ reuseConnection = false
325
+ }
326
+ if sshDirectoryMode .Mode ().Perm ()& sshDirectoryUnsafeModeMask != 0 {
327
+ flog .Info ("warning: the %v directory has unsafe permissions, they should only be writable by " +
328
+ "the owner (and files inside should be set to 0600)" , sshDirectory )
329
+ }
330
+ }
331
+ return reuseConnection
332
+ }
333
+
334
+ // startSSHMaster starts an SSH master connection and waits for it to be ready.
335
+ // It returns a new set of SSH flags for child SSH processes to use.
336
+ func startSSHMaster (sshFlags string , sshControlPath string , host string ) (string , func (), error ) {
337
+ ctx , cancel := context .WithCancel (context .Background ())
338
+
339
+ newSSHFlags := fmt .Sprintf (`%v -o "ControlPath=%v"` , sshFlags , sshControlPath )
340
+
341
+ // -MN means "start a master socket and don't open a session, just connect".
342
+ sshCmdStr := fmt .Sprintf (`exec ssh %v -MNq %v` , newSSHFlags , host )
343
+ sshMasterCmd := exec .CommandContext (ctx , "sh" , "-c" , sshCmdStr )
344
+ sshMasterCmd .Stdin = os .Stdin
345
+ sshMasterCmd .Stderr = os .Stderr
346
+
347
+ // Gracefully stop the SSH master.
348
+ stopSSHMaster := func () {
349
+ if sshMasterCmd .Process != nil {
350
+ if sshMasterCmd .ProcessState != nil && sshMasterCmd .ProcessState .Exited () {
351
+ return
352
+ }
353
+ err := sshMasterCmd .Process .Signal (syscall .SIGTERM )
354
+ if err != nil {
355
+ flog .Error ("failed to send SIGTERM to SSH master process: %v" , err )
356
+ }
357
+ }
358
+ cancel ()
359
+ }
360
+
361
+ // Start ssh master and wait. Waiting prevents the process from becoming a zombie process if it dies before
362
+ // sshcode does, and allows sshMasterCmd.ProcessState to be populated.
363
+ err := sshMasterCmd .Start ()
364
+ go sshMasterCmd .Wait ()
365
+ if err != nil {
366
+ return "" , stopSSHMaster , err
367
+ }
368
+ err = checkSSHMaster (sshMasterCmd , newSSHFlags , host )
369
+ if err != nil {
370
+ stopSSHMaster ()
371
+ return "" , stopSSHMaster , xerrors .Errorf ("SSH master wasn't ready on time: %w" , err )
372
+ }
373
+ return newSSHFlags , stopSSHMaster , nil
374
+ }
375
+
376
+ // checkSSHMaster polls every second for 30 seconds to check if the SSH master
377
+ // is ready.
378
+ func checkSSHMaster (sshMasterCmd * exec.Cmd , sshFlags string , host string ) error {
379
+ var (
380
+ maxTries = 30
381
+ sleepDur = time .Second
382
+ err error
383
+ )
384
+ for i := 0 ; i < maxTries ; i ++ {
385
+ // Check if the master is running.
386
+ if sshMasterCmd .Process == nil || (sshMasterCmd .ProcessState != nil && sshMasterCmd .ProcessState .Exited ()) {
387
+ return xerrors .Errorf ("SSH master process is not running" )
388
+ }
389
+
390
+ // Check if it's ready.
391
+ sshCmdStr := fmt .Sprintf (`ssh %v -O check %v` , sshFlags , host )
392
+ sshCmd := exec .Command ("sh" , "-c" , sshCmdStr )
393
+ err = sshCmd .Run ()
394
+ if err == nil {
395
+ return nil
396
+ }
397
+ time .Sleep (sleepDur )
398
+ }
399
+ return xerrors .Errorf ("max number of tries exceeded: %d" , maxTries )
400
+ }
401
+
266
402
func syncUserSettings (sshFlags string , host string , back bool ) error {
267
403
localConfDir , err := configDir ()
268
404
if err != nil {
0 commit comments