8
8
"fmt"
9
9
"io"
10
10
"log"
11
+ "net"
11
12
"net/http"
12
13
"net/url"
13
14
"os"
@@ -66,6 +67,7 @@ func (r *RootCmd) ssh() *serpent.Command {
66
67
stdio bool
67
68
hostPrefix string
68
69
hostnameSuffix string
70
+ forceTunnel bool
69
71
forwardAgent bool
70
72
forwardGPG bool
71
73
identityAgent string
@@ -85,6 +87,7 @@ func (r *RootCmd) ssh() *serpent.Command {
85
87
containerUser string
86
88
)
87
89
client := new (codersdk.Client )
90
+ wsClient := workspacesdk .New (client )
88
91
cmd := & serpent.Command {
89
92
Annotations :workspaceCommand ,
90
93
Use :"ssh <workspace>" ,
@@ -203,14 +206,14 @@ func (r *RootCmd) ssh() *serpent.Command {
203
206
parsedEnv = append (parsedEnv , [2 ]string {k ,v })
204
207
}
205
208
206
- deploymentSSHConfig := codersdk.SSHConfigResponse {
209
+ cliConfig := codersdk.SSHConfigResponse {
207
210
HostnamePrefix :hostPrefix ,
208
211
HostnameSuffix :hostnameSuffix ,
209
212
}
210
213
211
214
workspace ,workspaceAgent ,err := findWorkspaceAndAgentByHostname (
212
215
ctx ,inv ,client ,
213
- inv .Args [0 ],deploymentSSHConfig ,disableAutostart )
216
+ inv .Args [0 ],cliConfig ,disableAutostart )
214
217
if err != nil {
215
218
return err
216
219
}
@@ -275,10 +278,34 @@ func (r *RootCmd) ssh() *serpent.Command {
275
278
return err
276
279
}
277
280
281
+ // See if we can use the Coder Connect tunnel
282
+ if ! forceTunnel {
283
+ connInfo ,err := wsClient .AgentConnectionInfoGeneric (ctx )
284
+ if err != nil {
285
+ return xerrors .Errorf ("get agent connection info: %w" ,err )
286
+ }
287
+
288
+ coderConnectHost := fmt .Sprintf ("%s.%s.%s.%s" ,
289
+ workspaceAgent .Name ,workspace .Name ,workspace .OwnerName ,connInfo .HostnameSuffix )
290
+ exists ,_ := workspacesdk .ExistsViaCoderConnect (ctx ,coderConnectHost )
291
+ if exists {
292
+ _ ,_ = fmt .Fprintln (inv .Stderr ,"Connecting to workspace via Coder Connect..." )
293
+ defer cancel ()
294
+ addr := fmt .Sprintf ("%s:22" ,coderConnectHost )
295
+ if stdio {
296
+ if err := writeCoderConnectNetInfo (ctx ,networkInfoDir );err != nil {
297
+ logger .Error (ctx ,"failed to write coder connect net info file" ,slog .Error (err ))
298
+ }
299
+ return runCoderConnectStdio (ctx ,addr ,stdioReader ,stdioWriter ,stack )
300
+ }
301
+ return runCoderConnectPTY (ctx ,addr ,inv .Stdin ,inv .Stdout ,inv .Stderr ,stack )
302
+ }
303
+ }
304
+
278
305
if r .disableDirect {
279
306
_ ,_ = fmt .Fprintln (inv .Stderr ,"Direct connections disabled." )
280
307
}
281
- conn ,err := workspacesdk . New ( client ) .
308
+ conn ,err := wsClient .
282
309
DialAgent (ctx ,workspaceAgent .ID ,& workspacesdk.DialAgentOptions {
283
310
Logger :logger ,
284
311
BlockEndpoints :r .disableDirect ,
@@ -454,36 +481,11 @@ func (r *RootCmd) ssh() *serpent.Command {
454
481
stdinFile ,validIn := inv .Stdin .(* os.File )
455
482
stdoutFile ,validOut := inv .Stdout .(* os.File )
456
483
if validIn && validOut && isatty .IsTerminal (stdinFile .Fd ())&& isatty .IsTerminal (stdoutFile .Fd ()) {
457
- inState ,err := pty .MakeInputRaw (stdinFile .Fd ())
458
- if err != nil {
459
- return err
460
- }
461
- defer func () {
462
- _ = pty .RestoreTerminal (stdinFile .Fd (),inState )
463
- }()
464
- outState ,err := pty .MakeOutputRaw (stdoutFile .Fd ())
484
+ restorePtyFn ,err := configurePTY (ctx ,stdinFile ,stdoutFile ,sshSession )
485
+ defer restorePtyFn ()
465
486
if err != nil {
466
- return err
487
+ return xerrors . Errorf ( "configure pty: %w" , err )
467
488
}
468
- defer func () {
469
- _ = pty .RestoreTerminal (stdoutFile .Fd (),outState )
470
- }()
471
-
472
- windowChange := listenWindowSize (ctx )
473
- go func () {
474
- for {
475
- select {
476
- case <- ctx .Done ():
477
- return
478
- case <- windowChange :
479
- }
480
- width ,height ,err := term .GetSize (int (stdoutFile .Fd ()))
481
- if err != nil {
482
- continue
483
- }
484
- _ = sshSession .WindowChange (height ,width )
485
- }
486
- }()
487
489
}
488
490
489
491
for _ ,kv := range parsedEnv {
@@ -662,11 +664,51 @@ func (r *RootCmd) ssh() *serpent.Command {
662
664
Value :serpent .StringOf (& containerUser ),
663
665
Hidden :true ,// Hidden until this features is at least in beta.
664
666
},
667
+ {
668
+ Flag :"force-tunnel" ,
669
+ Description :"Force the use of a new tunnel to the workspace, even if the Coder Connect tunnel is available." ,
670
+ Value :serpent .BoolOf (& forceTunnel ),
671
+ },
665
672
sshDisableAutostartOption (serpent .BoolOf (& disableAutostart )),
666
673
}
667
674
return cmd
668
675
}
669
676
677
+ func configurePTY (ctx context.Context ,stdinFile * os.File ,stdoutFile * os.File ,sshSession * gossh.Session ) (restoreFn func (),err error ) {
678
+ inState ,err := pty .MakeInputRaw (stdinFile .Fd ())
679
+ if err != nil {
680
+ return restoreFn ,err
681
+ }
682
+ restoreFn = func () {
683
+ _ = pty .RestoreTerminal (stdinFile .Fd (),inState )
684
+ }
685
+ outState ,err := pty .MakeOutputRaw (stdoutFile .Fd ())
686
+ if err != nil {
687
+ return restoreFn ,err
688
+ }
689
+ restoreFn = func () {
690
+ _ = pty .RestoreTerminal (stdinFile .Fd (),inState )
691
+ _ = pty .RestoreTerminal (stdoutFile .Fd (),outState )
692
+ }
693
+
694
+ windowChange := listenWindowSize (ctx )
695
+ go func () {
696
+ for {
697
+ select {
698
+ case <- ctx .Done ():
699
+ return
700
+ case <- windowChange :
701
+ }
702
+ width ,height ,err := term .GetSize (int (stdoutFile .Fd ()))
703
+ if err != nil {
704
+ continue
705
+ }
706
+ _ = sshSession .WindowChange (height ,width )
707
+ }
708
+ }()
709
+ return restoreFn ,nil
710
+ }
711
+
670
712
// findWorkspaceAndAgentByHostname parses the hostname from the commandline and finds the workspace and agent it
671
713
// corresponds to, taking into account any name prefixes or suffixes configured (e.g. myworkspace.coder, or
672
714
// vscode-coder--myusername--myworkspace).
@@ -1374,12 +1416,13 @@ func setStatsCallback(
1374
1416
}
1375
1417
1376
1418
type sshNetworkStats struct {
1377
- P2P bool `json:"p2p"`
1378
- Latency float64 `json:"latency"`
1379
- PreferredDERP string `json:"preferred_derp"`
1380
- DERPLatency map [string ]float64 `json:"derp_latency"`
1381
- UploadBytesSec int64 `json:"upload_bytes_sec"`
1382
- DownloadBytesSec int64 `json:"download_bytes_sec"`
1419
+ P2P bool `json:"p2p"`
1420
+ Latency float64 `json:"latency"`
1421
+ PreferredDERP string `json:"preferred_derp"`
1422
+ DERPLatency map [string ]float64 `json:"derp_latency"`
1423
+ UploadBytesSec int64 `json:"upload_bytes_sec"`
1424
+ DownloadBytesSec int64 `json:"download_bytes_sec"`
1425
+ UsingCoderConnect bool `json:"using_coder_connect"`
1383
1426
}
1384
1427
1385
1428
func collectNetworkStats (ctx context.Context ,agentConn * workspacesdk.AgentConn ,start ,end time.Time ,counts map [netlogtype.Connection ]netlogtype.Counts ) (* sshNetworkStats ,error ) {
@@ -1450,6 +1493,121 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn,
1450
1493
},nil
1451
1494
}
1452
1495
1496
+ func runCoderConnectStdio (ctx context.Context ,addr string ,stdin io.Reader ,stdout io.Writer ,stack * closerStack )error {
1497
+ conn ,err := net .Dial ("tcp" ,addr )
1498
+ if err != nil {
1499
+ return xerrors .Errorf ("dial coder connect host: %w" ,err )
1500
+ }
1501
+ if err := stack .push ("tcp conn" ,conn );err != nil {
1502
+ return err
1503
+ }
1504
+
1505
+ agentssh .Bicopy (ctx ,conn ,& cliutil.StdioConn {
1506
+ Reader :stdin ,
1507
+ Writer :stdout ,
1508
+ })
1509
+
1510
+ return nil
1511
+ }
1512
+
1513
+ func runCoderConnectPTY (ctx context.Context ,addr string ,stdin io.Reader ,stdout io.Writer ,stderr io.Writer ,stack * closerStack )error {
1514
+ client ,err := gossh .Dial ("tcp" ,addr ,& gossh.ClientConfig {
1515
+ // We've already checked the agent's address
1516
+ // is within the Coder service prefix.
1517
+ // #nosec
1518
+ HostKeyCallback :gossh .InsecureIgnoreHostKey (),
1519
+ })
1520
+ if err != nil {
1521
+ return xerrors .Errorf ("dial coder connect host: %w" ,err )
1522
+ }
1523
+ if err := stack .push ("ssh client" ,client );err != nil {
1524
+ return err
1525
+ }
1526
+
1527
+ session ,err := client .NewSession ()
1528
+ if err != nil {
1529
+ return xerrors .Errorf ("create ssh session: %w" ,err )
1530
+ }
1531
+ if err := stack .push ("ssh session" ,session );err != nil {
1532
+ return err
1533
+ }
1534
+
1535
+ stdinFile ,validIn := stdin .(* os.File )
1536
+ stdoutFile ,validOut := stdout .(* os.File )
1537
+ if validIn && validOut && isatty .IsTerminal (stdinFile .Fd ())&& isatty .IsTerminal (stdoutFile .Fd ()) {
1538
+ restorePtyFn ,err := configurePTY (ctx ,stdinFile ,stdoutFile ,session )
1539
+ defer restorePtyFn ()
1540
+ if err != nil {
1541
+ return xerrors .Errorf ("configure pty: %w" ,err )
1542
+ }
1543
+ }
1544
+
1545
+ session .Stdin = stdin
1546
+ session .Stdout = stdout
1547
+ session .Stderr = stderr
1548
+
1549
+ err = session .RequestPty ("xterm-256color" ,80 ,24 , gossh.TerminalModes {})
1550
+ if err != nil {
1551
+ return xerrors .Errorf ("request pty: %w" ,err )
1552
+ }
1553
+
1554
+ err = session .Shell ()
1555
+ if err != nil {
1556
+ return xerrors .Errorf ("start shell: %w" ,err )
1557
+ }
1558
+
1559
+ if validOut {
1560
+ // Set initial window size.
1561
+ width ,height ,err := term .GetSize (int (stdoutFile .Fd ()))
1562
+ if err == nil {
1563
+ _ = session .WindowChange (height ,width )
1564
+ }
1565
+ }
1566
+
1567
+ err = session .Wait ()
1568
+ if err != nil {
1569
+ if exitErr := (& gossh.ExitError {});errors .As (err ,& exitErr ) {
1570
+ // Clear the error since it's not useful beyond
1571
+ // reporting status.
1572
+ return ExitError (exitErr .ExitStatus (),nil )
1573
+ }
1574
+ // If the connection drops unexpectedly, we get an
1575
+ // ExitMissingError but no other error details, so try to at
1576
+ // least give the user a better message
1577
+ if errors .Is (err ,& gossh.ExitMissingError {}) {
1578
+ return ExitError (255 ,xerrors .New ("SSH connection ended unexpectedly" ))
1579
+ }
1580
+ return xerrors .Errorf ("session ended: %w" ,err )
1581
+ }
1582
+
1583
+ return nil
1584
+ }
1585
+
1586
+ func writeCoderConnectNetInfo (ctx context.Context ,networkInfoDir string )error {
1587
+ fs ,ok := ctx .Value ("fs" ).(afero.Fs )
1588
+ if ! ok {
1589
+ fs = afero .NewOsFs ()
1590
+ }
1591
+ // The VS Code extension obtains the PID of the SSH process to
1592
+ // find the log file associated with a SSH session.
1593
+ //
1594
+ // We get the parent PID because it's assumed `ssh` is calling this
1595
+ // command via the ProxyCommand SSH option.
1596
+ networkInfoFilePath := filepath .Join (networkInfoDir ,fmt .Sprintf ("%d.json" ,os .Getppid ()))
1597
+ stats := & sshNetworkStats {
1598
+ UsingCoderConnect :true ,
1599
+ }
1600
+ rawStats ,err := json .Marshal (stats )
1601
+ if err != nil {
1602
+ return xerrors .Errorf ("marshal network stats: %w" ,err )
1603
+ }
1604
+ err = afero .WriteFile (fs ,networkInfoFilePath ,rawStats ,0o600 )
1605
+ if err != nil {
1606
+ return xerrors .Errorf ("write network stats: %w" ,err )
1607
+ }
1608
+ return nil
1609
+ }
1610
+
1453
1611
// Converts workspace name input to owner/workspace.agent format
1454
1612
// Possible valid input formats:
1455
1613
// workspace