@@ -3,6 +3,7 @@ package cli
3
3
import (
4
4
"bytes"
5
5
"context"
6
+ "encoding/json"
6
7
"errors"
7
8
"fmt"
8
9
"io"
@@ -13,6 +14,7 @@ import (
13
14
"os/exec"
14
15
"path/filepath"
15
16
"slices"
17
+ "strconv"
16
18
"strings"
17
19
"sync"
18
20
"time"
@@ -21,11 +23,14 @@ import (
21
23
"github.com/gofrs/flock"
22
24
"github.com/google/uuid"
23
25
"github.com/mattn/go-isatty"
26
+ "github.com/spf13/afero"
24
27
gossh"golang.org/x/crypto/ssh"
25
28
gosshagent"golang.org/x/crypto/ssh/agent"
26
29
"golang.org/x/term"
27
30
"golang.org/x/xerrors"
28
31
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
32
+ "tailscale.com/tailcfg"
33
+ "tailscale.com/types/netlogtype"
29
34
30
35
"cdr.dev/slog"
31
36
"cdr.dev/slog/sloggers/sloghuman"
@@ -55,19 +60,21 @@ var (
55
60
56
61
func (r * RootCmd )ssh ()* serpent.Command {
57
62
var (
58
- stdio bool
59
- forwardAgent bool
60
- forwardGPG bool
61
- identityAgent string
62
- wsPollInterval time.Duration
63
- waitEnum string
64
- noWait bool
65
- logDirPath string
66
- remoteForwards []string
67
- env []string
68
- usageApp string
69
- disableAutostart bool
70
- appearanceConfig codersdk.AppearanceConfig
63
+ stdio bool
64
+ forwardAgent bool
65
+ forwardGPG bool
66
+ identityAgent string
67
+ wsPollInterval time.Duration
68
+ waitEnum string
69
+ noWait bool
70
+ logDirPath string
71
+ remoteForwards []string
72
+ env []string
73
+ usageApp string
74
+ disableAutostart bool
75
+ appearanceConfig codersdk.AppearanceConfig
76
+ networkInfoDir string
77
+ networkInfoInterval time.Duration
71
78
)
72
79
client := new (codersdk.Client )
73
80
cmd := & serpent.Command {
@@ -274,6 +281,11 @@ func (r *RootCmd) ssh() *serpent.Command {
274
281
defer closeUsage ()
275
282
}
276
283
284
+ fs ,ok := inv .Context ().Value ("fs" ).(afero.Fs )
285
+ if ! ok {
286
+ fs = afero .NewOsFs ()
287
+ }
288
+
277
289
if stdio {
278
290
rawSSH ,err := conn .SSH (ctx )
279
291
if err != nil {
@@ -284,13 +296,21 @@ func (r *RootCmd) ssh() *serpent.Command {
284
296
return err
285
297
}
286
298
299
+ var errCh <- chan error
300
+ if networkInfoDir != "" {
301
+ errCh ,err = setStatsCallback (ctx ,conn ,fs ,logger ,networkInfoDir ,networkInfoInterval )
302
+ if err != nil {
303
+ return err
304
+ }
305
+ }
306
+
287
307
wg .Add (1 )
288
308
go func () {
289
309
defer wg .Done ()
290
310
watchAndClose (ctx ,func ()error {
291
311
stack .close (xerrors .New ("watchAndClose" ))
292
312
return nil
293
- },logger ,client ,workspace )
313
+ },logger ,client ,workspace , errCh )
294
314
}()
295
315
copier .copy (& wg )
296
316
return nil
@@ -312,6 +332,14 @@ func (r *RootCmd) ssh() *serpent.Command {
312
332
return err
313
333
}
314
334
335
+ var errCh <- chan error
336
+ if networkInfoDir != "" {
337
+ errCh ,err = setStatsCallback (ctx ,conn ,fs ,logger ,networkInfoDir ,networkInfoInterval )
338
+ if err != nil {
339
+ return err
340
+ }
341
+ }
342
+
315
343
wg .Add (1 )
316
344
go func () {
317
345
defer wg .Done ()
@@ -324,6 +352,7 @@ func (r *RootCmd) ssh() *serpent.Command {
324
352
logger ,
325
353
client ,
326
354
workspace ,
355
+ errCh ,
327
356
)
328
357
}()
329
358
@@ -540,6 +569,17 @@ func (r *RootCmd) ssh() *serpent.Command {
540
569
Value :serpent .StringOf (& usageApp ),
541
570
Hidden :true ,
542
571
},
572
+ {
573
+ Flag :"network-info-dir" ,
574
+ Description :"Specifies a directory to write network information periodically." ,
575
+ Value :serpent .StringOf (& networkInfoDir ),
576
+ },
577
+ {
578
+ Flag :"network-info-interval" ,
579
+ Description :"Specifies the interval to update network information." ,
580
+ Default :"5s" ,
581
+ Value :serpent .DurationOf (& networkInfoInterval ),
582
+ },
543
583
sshDisableAutostartOption (serpent .BoolOf (& disableAutostart )),
544
584
}
545
585
return cmd
@@ -555,7 +595,7 @@ func (r *RootCmd) ssh() *serpent.Command {
555
595
// will usually not propagate.
556
596
//
557
597
// See: https://github.com/coder/coder/issues/6180
558
- func watchAndClose (ctx context.Context ,closer func ()error ,logger slog.Logger ,client * codersdk.Client ,workspace codersdk.Workspace ) {
598
+ func watchAndClose (ctx context.Context ,closer func ()error ,logger slog.Logger ,client * codersdk.Client ,workspace codersdk.Workspace , errCh <- chan error ) {
559
599
// Ensure session is ended on both context cancellation
560
600
// and workspace stop.
561
601
defer func () {
@@ -606,6 +646,9 @@ startWatchLoop:
606
646
logger .Info (ctx ,"workspace stopped" )
607
647
return
608
648
}
649
+ case err := <- errCh :
650
+ logger .Error (ctx ,"%s" ,err )
651
+ return
609
652
}
610
653
}
611
654
}
@@ -1144,3 +1187,160 @@ func getUsageAppName(usageApp string) codersdk.UsageAppName {
1144
1187
1145
1188
return codersdk .UsageAppNameSSH
1146
1189
}
1190
+
1191
+ func setStatsCallback (
1192
+ ctx context.Context ,
1193
+ agentConn * workspacesdk.AgentConn ,
1194
+ fs afero.Fs ,
1195
+ logger slog.Logger ,
1196
+ networkInfoDir string ,
1197
+ networkInfoInterval time.Duration ,
1198
+ ) (<- chan error ,error ) {
1199
+ fs ,ok := ctx .Value ("fs" ).(afero.Fs )
1200
+ if ! ok {
1201
+ fs = afero .NewOsFs ()
1202
+ }
1203
+ if err := fs .MkdirAll (networkInfoDir ,0o700 );err != nil {
1204
+ return nil ,xerrors .Errorf ("mkdir: %w" ,err )
1205
+ }
1206
+
1207
+ // The VS Code extension obtains the PID of the SSH process to
1208
+ // read files to display logs and network info.
1209
+ //
1210
+ // We get the parent PID because it's assumed `ssh` is calling this
1211
+ // command via the ProxyCommand SSH option.
1212
+ pid := os .Getppid ()
1213
+
1214
+ // The VS Code extension obtains the PID of the SSH process to
1215
+ // read the file below which contains network information to display.
1216
+ //
1217
+ // We get the parent PID because it's assumed `ssh` is calling this
1218
+ // command via the ProxyCommand SSH option.
1219
+ networkInfoFilePath := filepath .Join (networkInfoDir ,fmt .Sprintf ("%d.json" ,pid ))
1220
+
1221
+ var (
1222
+ firstErrTime time.Time
1223
+ errCh = make (chan error ,1 )
1224
+ )
1225
+ cb := func (start ,end time.Time ,virtual ,_ map [netlogtype.Connection ]netlogtype.Counts ) {
1226
+ sendErr := func (tolerate bool ,err error ) {
1227
+ logger .Error (ctx ,"collect network stats" ,slog .Error (err ))
1228
+ // Tolerate up to 1 minute of errors.
1229
+ if tolerate {
1230
+ if firstErrTime .IsZero () {
1231
+ logger .Info (ctx ,"tolerating network stats errors for up to 1 minute" )
1232
+ firstErrTime = time .Now ()
1233
+ }
1234
+ if time .Since (firstErrTime )< time .Minute {
1235
+ return
1236
+ }
1237
+ }
1238
+
1239
+ select {
1240
+ case errCh <- err :
1241
+ default :
1242
+ }
1243
+ }
1244
+
1245
+ stats ,err := collectNetworkStats (ctx ,agentConn ,start ,end ,virtual )
1246
+ if err != nil {
1247
+ sendErr (true ,err )
1248
+ return
1249
+ }
1250
+
1251
+ rawStats ,err := json .Marshal (stats )
1252
+ if err != nil {
1253
+ sendErr (false ,err )
1254
+ return
1255
+ }
1256
+ err = afero .WriteFile (fs ,networkInfoFilePath ,rawStats ,0o600 )
1257
+ if err != nil {
1258
+ sendErr (false ,err )
1259
+ return
1260
+ }
1261
+
1262
+ firstErrTime = time.Time {}
1263
+ }
1264
+
1265
+ now := time .Now ()
1266
+ cb (now ,now .Add (time .Nanosecond ),map [netlogtype.Connection ]netlogtype.Counts {},map [netlogtype.Connection ]netlogtype.Counts {})
1267
+ agentConn .SetConnStatsCallback (networkInfoInterval ,2048 ,cb )
1268
+ return errCh ,nil
1269
+ }
1270
+
1271
+ type sshNetworkStats struct {
1272
+ P2P bool `json:"p2p"`
1273
+ Latency float64 `json:"latency"`
1274
+ PreferredDERP string `json:"preferred_derp"`
1275
+ DERPLatency map [string ]float64 `json:"derp_latency"`
1276
+ UploadBytesSec int64 `json:"upload_bytes_sec"`
1277
+ DownloadBytesSec int64 `json:"download_bytes_sec"`
1278
+ }
1279
+
1280
+ func collectNetworkStats (ctx context.Context ,agentConn * workspacesdk.AgentConn ,start ,end time.Time ,counts map [netlogtype.Connection ]netlogtype.Counts ) (* sshNetworkStats ,error ) {
1281
+ latency ,p2p ,pingResult ,err := agentConn .Ping (ctx )
1282
+ if err != nil {
1283
+ return nil ,err
1284
+ }
1285
+ node := agentConn .Node ()
1286
+ derpMap := agentConn .DERPMap ()
1287
+ derpLatency := map [string ]float64 {}
1288
+
1289
+ // Convert DERP region IDs to friendly names for display in the UI.
1290
+ for rawRegion ,latency := range node .DERPLatency {
1291
+ regionParts := strings .SplitN (rawRegion ,"-" ,2 )
1292
+ regionID ,err := strconv .Atoi (regionParts [0 ])
1293
+ if err != nil {
1294
+ continue
1295
+ }
1296
+ region ,found := derpMap .Regions [regionID ]
1297
+ if ! found {
1298
+ // It's possible that a workspace agent is using an old DERPMap
1299
+ // and reports regions that do not exist. If that's the case,
1300
+ // report the region as unknown!
1301
+ region = & tailcfg.DERPRegion {
1302
+ RegionID :regionID ,
1303
+ RegionName :fmt .Sprintf ("Unnamed %d" ,regionID ),
1304
+ }
1305
+ }
1306
+ // Convert the microseconds to milliseconds.
1307
+ derpLatency [region .RegionName ]= latency * 1000
1308
+ }
1309
+
1310
+ totalRx := uint64 (0 )
1311
+ totalTx := uint64 (0 )
1312
+ for _ ,stat := range counts {
1313
+ totalRx += stat .RxBytes
1314
+ totalTx += stat .TxBytes
1315
+ }
1316
+ // Tracking the time since last request is required because
1317
+ // ExtractTrafficStats() resets its counters after each call.
1318
+ dur := end .Sub (start )
1319
+ uploadSecs := float64 (totalTx )/ dur .Seconds ()
1320
+ downloadSecs := float64 (totalRx )/ dur .Seconds ()
1321
+
1322
+ // Sometimes the preferred DERP doesn't match the one we're actually
1323
+ // connected with. Perhaps because the agent prefers a different DERP and
1324
+ // we're using that server instead.
1325
+ preferredDerpID := node .PreferredDERP
1326
+ if pingResult .DERPRegionID != 0 {
1327
+ preferredDerpID = pingResult .DERPRegionID
1328
+ }
1329
+ preferredDerp ,ok := derpMap .Regions [preferredDerpID ]
1330
+ preferredDerpName := fmt .Sprintf ("Unnamed %d" ,preferredDerpID )
1331
+ if ok {
1332
+ preferredDerpName = preferredDerp .RegionName
1333
+ }
1334
+ if _ ,ok := derpLatency [preferredDerpName ];! ok {
1335
+ derpLatency [preferredDerpName ]= 0
1336
+ }
1337
+
1338
+ return & sshNetworkStats {
1339
+ P2P :p2p ,
1340
+ Latency :float64 (latency .Microseconds ())/ 1000 ,
1341
+ PreferredDERP :preferredDerpName ,
1342
+ DERPLatency :derpLatency ,
1343
+ UploadBytesSec :int64 (uploadSecs ),
1344
+ DownloadBytesSec :int64 (downloadSecs ),
1345
+ },nil
1346
+ }