• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mendersoftware / mender-cli / 1779499080

22 Apr 2025 10:11AM UTC coverage: 1.737% (-30.1%) from 31.802%
1779499080

push

gitlab-ci

web-flow
Merge pull request #277 from alfrunes/MEN-7794

MEN-7794: Add support for pagination when listing devices

28 of 82 new or added lines in 4 files covered. (34.15%)

770 existing lines in 17 files now uncovered.

45 of 2590 relevant lines covered (1.74%)

0.04 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/cmd/terminal.go
1
// Copyright 2023 Northern.tech AS
2
//
3
//        Licensed under the Apache License, Version 2.0 (the "License");
4
//        you may not use this file except in compliance with the License.
5
//        You may obtain a copy of the License at
6
//
7
//            http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//        Unless required by applicable law or agreed to in writing, software
10
//        distributed under the License is distributed on an "AS IS" BASIS,
11
//        WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//        See the License for the specific language governing permissions and
13
//        limitations under the License.
14
package cmd
15

16
import (
17
        "bufio"
18
        "compress/gzip"
19
        "context"
20
        "encoding/binary"
21
        "encoding/gob"
22
        "fmt"
23
        "io"
24
        "os"
25
        "os/signal"
26
        "syscall"
27
        "time"
28

29
        "github.com/pkg/errors"
30
        "github.com/spf13/cobra"
31
        "github.com/spf13/viper"
32
        "golang.org/x/sys/unix"
33
        "golang.org/x/term"
34

35
        "github.com/mendersoftware/go-lib-micro/ws"
36
        wsshell "github.com/mendersoftware/go-lib-micro/ws/shell"
37

38
        "github.com/mendersoftware/mender-cli/client/deviceconnect"
39
        "github.com/mendersoftware/mender-cli/log"
40
)
41

42
const (
43
        // default terminal size
44
        defaultTermWidth  = 80
45
        defaultTermHeight = 40
46

47
        // dummy delay for playback
48
        playbackSleep = time.Millisecond * 32
49

50
        // cli args
51
        argRecord   = "record"
52
        argPlayback = "playback"
53
)
54

55
var terminalCmd = &cobra.Command{
56
        Use:   "terminal [DEVICE_ID]",
57
        Short: "Remotely access a terminal on a device",
58
        Long: "Remotely access a terminal on a device\n" +
59
                "Basic usage is terminal DEVICE_ID, which starts a new terminal " +
60
                "session with the remote device. The session can be saved locally " +
61
                "using --record flag. When using --playback flag, no DEVICE_ID is " +
62
                "required and no connection will be established.",
63
        Args: cobra.RangeArgs(0, 1),
64
        Run: func(c *cobra.Command, args []string) {
×
65
                cmd, err := NewTerminalCmd(c, args)
×
66
                CheckErr(err)
×
67
                CheckErr(cmd.Run())
×
68
        },
×
69
}
70

UNCOV
71
func init() {
×
UNCOV
72
        terminalCmd.Flags().StringP(argRecord, "", "", "recording file path to save the session to")
×
UNCOV
73
        terminalCmd.Flags().
×
UNCOV
74
                StringP(argPlayback, "", "", "recording file path to playback the session from")
×
UNCOV
75
}
×
76

77
// TerminalCmd handles the terminal command
78
type TerminalCmd struct {
79
        server             string
80
        token              string
81
        skipVerify         bool
82
        deviceID           string
83
        sessionID          string
84
        running            bool
85
        healthcheck        chan int
86
        stop               chan struct{}
87
        err                error
88
        recordFile         string
89
        recording          bool
90
        stopRecording      chan bool
91
        playbackFile       string
92
        terminalOutputChan chan []byte
93
}
94

95
const (
96
        deviceIDMaxLength     = 64
97
        terminalTypeMaxLength = 32
98
        terminalTypeDefault   = "xterm-256color"
99
)
100

101
type TerminalRecordingHeader struct {
102
        Version        uint8
103
        DeviceID       [deviceIDMaxLength]byte
104
        TerminalType   [terminalTypeMaxLength]byte
105
        TerminalWidth  int16
106
        TerminalHeight int16
107
        Timestamp      int64
108
}
109

110
const (
111
        terminalRecordingVersion = 1
112
)
113

114
type TerminalRecordingType int8
115

116
type TerminalRecordingData struct {
117
        Type TerminalRecordingType
118
        Data []byte
119
}
120

121
const (
122
        terminalRecordingOutput TerminalRecordingType = iota
123
)
124

125
// NewTerminalCmd returns a new TerminalCmd
126
func NewTerminalCmd(cmd *cobra.Command, args []string) (*TerminalCmd, error) {
×
127
        server := viper.GetString(argRootServer)
×
128
        if server == "" {
×
129
                return nil, errors.New("No server")
×
130
        }
×
131

132
        skipVerify, err := cmd.Flags().GetBool(argRootSkipVerify)
×
133
        if err != nil {
×
134
                return nil, err
×
135
        }
×
136

137
        recordFile, err := cmd.Flags().GetString(argRecord)
×
138
        if err != nil {
×
139
                return nil, err
×
140
        }
×
141

142
        playbackFile, err := cmd.Flags().GetString(argPlayback)
×
143
        if err != nil {
×
144
                return nil, err
×
145
        }
×
146

147
        token, err := getAuthToken(cmd)
×
148
        if err != nil {
×
149
                return nil, err
×
150
        }
×
151

152
        deviceID := ""
×
153
        if len(args) == 1 {
×
154
                deviceID = args[0]
×
155
        }
×
156

157
        if playbackFile == "" && deviceID == "" {
×
158
                return nil, errors.New("No device specified")
×
159
        }
×
160

161
        return &TerminalCmd{
×
162
                server:             server,
×
163
                token:              token,
×
164
                skipVerify:         skipVerify,
×
165
                deviceID:           deviceID,
×
166
                healthcheck:        make(chan int),
×
167
                stop:               make(chan struct{}),
×
168
                recordFile:         recordFile,
×
169
                stopRecording:      make(chan bool),
×
170
                terminalOutputChan: make(chan []byte),
×
171
                playbackFile:       playbackFile,
×
172
        }, nil
×
173
}
174

175
// send the shell start message
176
func (c *TerminalCmd) startShell(client *deviceconnect.Client, termWidth, termHeight int) error {
×
177
        m := &ws.ProtoMsg{
×
178
                Header: ws.ProtoHdr{
×
179
                        Proto:   ws.ProtoTypeShell,
×
180
                        MsgType: wsshell.MessageTypeSpawnShell,
×
181
                        Properties: map[string]interface{}{
×
182
                                "terminal_width":  termWidth,
×
183
                                "terminal_height": termHeight,
×
184
                        },
×
185
                },
×
186
        }
×
187
        if err := client.WriteMessage(m); err != nil {
×
188
                return err
×
189
        }
×
190
        return nil
×
191
}
192

193
// send the stop shell message
194
func (c *TerminalCmd) stopShell(client *deviceconnect.Client) error {
×
195
        m := &ws.ProtoMsg{
×
196
                Header: ws.ProtoHdr{
×
197
                        Proto:     ws.ProtoTypeShell,
×
198
                        MsgType:   wsshell.MessageTypeStopShell,
×
199
                        SessionID: c.sessionID,
×
200
                },
×
201
        }
×
202
        if err := client.WriteMessage(m); err != nil {
×
203
                return err
×
204
        }
×
205
        return nil
×
206
}
207

208
func (c *TerminalCmd) record() {
×
209
        f, err := os.Create(c.recordFile)
×
210
        if err != nil {
×
211
                log.Err(fmt.Sprintf("Can't create recording file: %s: %s", c.recordFile, err.Error()))
×
212
        }
×
213
        defer f.Close()
×
214

×
215
        fz := gzip.NewWriter(f)
×
216
        defer fz.Close()
×
217

×
218
        data := TerminalRecordingHeader{
×
219
                Version:        terminalRecordingVersion,
×
220
                Timestamp:      time.Now().Unix(),
×
221
                TerminalWidth:  defaultTermWidth,
×
222
                TerminalHeight: defaultTermHeight,
×
223
        }
×
224
        copy(data.DeviceID[:], []byte(c.deviceID))
×
225
        copy(data.TerminalType[:], []byte(terminalTypeDefault))
×
226
        err = binary.Write(fz, binary.LittleEndian, data)
×
227
        if err != nil {
×
228
                log.Err(fmt.Sprintf("Header write failed: %s", err.Error()))
×
229
        }
×
230
        err = fz.Flush()
×
231
        if err != nil {
×
232
                log.Err(fmt.Sprintf("Header flush failed: %s", err.Error()))
×
233
        }
×
234

235
        log.Info(fmt.Sprintf("Recording to file: %s", c.recordFile))
×
236

×
237
        e := gob.NewEncoder(fz)
×
238
        for {
×
239
                select {
×
240
                case <-c.stopRecording:
×
241
                        return
×
242
                case terminalOutput := <-c.terminalOutputChan:
×
243
                        o := TerminalRecordingData{
×
244
                                Type: terminalRecordingOutput,
×
245
                                Data: terminalOutput,
×
246
                        }
×
247
                        err = e.Encode(o)
×
248
                        fz.Flush()
×
249
                        if err != nil {
×
250
                                log.Err(fmt.Sprintf("Error encoding %q: %s", string(terminalOutput), err.Error()))
×
251
                                return
×
252
                        }
×
253
                }
254
        }
255
}
256

257
func (c *TerminalCmd) playback(w io.Writer) error {
×
258
        f, err := os.Open(c.playbackFile)
×
259
        if err != nil {
×
260
                log.Err(fmt.Sprintf("Can't open %s: %s", c.playbackFile, err.Error()))
×
261
                return err
×
262
        }
×
263
        defer f.Close()
×
264

×
265
        fz, err := gzip.NewReader(f)
×
266
        if err != nil {
×
267
                return err
×
268
        }
×
269
        defer fz.Close()
×
270

×
271
        var header TerminalRecordingHeader
×
272
        err = binary.Read(fz, binary.LittleEndian, &header)
×
273
        if err != nil {
×
274
                log.Err(fmt.Sprintf("Can't read header: %s", err.Error()))
×
275
                return err
×
276
        }
×
277

278
        dateTime := time.Unix(header.Timestamp, 0)
×
279

×
280
        log.Info(fmt.Sprintf("Playing back from file: %s", c.playbackFile))
×
281
        log.Info(fmt.Sprintf("Device ID: %s", string(header.DeviceID[:])))
×
282
        log.Info(fmt.Sprintf("Terminal type: %s", string(header.TerminalType[:])))
×
283
        log.Info(fmt.Sprintf("Terminal size: %dx%d", header.TerminalWidth, header.TerminalHeight))
×
284
        log.Info(fmt.Sprintf("Timestamp: %s", dateTime.Format(time.UnixDate)))
×
285
        log.Info("")
×
286

×
287
        d := gob.NewDecoder(fz)
×
288
        for {
×
289
                var o TerminalRecordingData
×
290
                err = d.Decode(&o)
×
291
                if err != nil {
×
292
                        if err != io.EOF {
×
293
                                log.Err(fmt.Sprintf("Decoding error: %s", err.Error()))
×
294
                                return err
×
295
                        }
×
296
                        break
×
297
                }
298
                if o.Type == terminalRecordingOutput {
×
299
                        _, err = w.Write(o.Data)
×
300
                        if err != nil {
×
301
                                log.Err(fmt.Sprintf("Writting error: %s", err.Error()))
×
302
                                return err
×
303
                        }
×
304
                }
305
                time.Sleep(playbackSleep)
×
306
        }
307
        log.Info("\r")
×
308
        return nil
×
309
}
310

311
// Run executes the command
312
func (c *TerminalCmd) Run() error {
×
313
        ctx, cancelContext := context.WithCancel(context.Background())
×
314
        defer cancelContext()
×
315

×
316
        // get the terminal width and height
×
317
        termWidth := defaultTermWidth
×
318
        termHeight := defaultTermHeight
×
319
        termID := int(os.Stdout.Fd())
×
320

×
321
        // when playing back, no further processing is required
×
322
        if c.playbackFile != "" {
×
323
                if _, err := os.Stat(c.playbackFile); err == nil {
×
324
                        return c.playback(os.Stdout)
×
325
                } else {
×
326
                        return err
×
327
                }
×
328
        }
329

330
        // start recording when applicable
331
        if _, err := os.Stat(c.recordFile); os.IsNotExist(err) {
×
332
                if len(c.recordFile) > 0 {
×
333
                        c.recording = true
×
334
                        go c.record()
×
335
                }
×
336
        } else {
×
337
                log.Err(fmt.Sprintf(
×
338
                        "Can't create recording file: %s exists, refused to record.",
×
339
                        c.recordFile,
×
340
                ))
×
341
        }
×
342

343
        client := deviceconnect.NewClient(c.server, c.token, c.skipVerify)
×
344

×
345
        // check if the device is connected
×
346
        device, err := client.GetDevice(c.deviceID)
×
347
        if err != nil {
×
348
                return errors.Wrap(err, "unable to get the device")
×
349
        } else if device.Status != deviceconnect.CONNECTED {
×
350
                return errors.New("the device is not connected")
×
351
        }
×
352

353
        // connect to the websocket and start the ping-pong connection health-check
354
        err = client.Connect(c.deviceID, c.token)
×
355
        if err != nil {
×
356
                return err
×
357
        }
×
358

359
        go client.PingPong(ctx)
×
360
        defer client.Close()
×
361

×
362
        // set the terminal in raw mode
×
363
        if term.IsTerminal(termID) {
×
364
                termWidth, termHeight, err = term.GetSize(termID)
×
365
                if err != nil {
×
366
                        return errors.Wrap(err, "Unable to get the terminal size")
×
367
                }
×
368

369
                fmt.Fprintln(os.Stderr, "Press CTRL+] to quit the session")
×
370

×
371
                oldState, err := term.MakeRaw(termID)
×
372
                if err != nil {
×
373
                        return errors.Wrap(err, "Unable to set the terminal in raw mode")
×
374
                }
×
375
                defer func() {
×
376
                        _ = term.Restore(termID, oldState)
×
377
                }()
×
378
        }
379

380
        // start the shell
381
        if err := c.startShell(client, termWidth, termHeight); err != nil {
×
382
                return err
×
383
        }
×
384

385
        // wait for CTRL+C, signals or stop
386
        c.runLoop(ctx, client, termID, termWidth, termHeight)
×
387

×
388
        // cancel the context
×
389
        cancelContext()
×
390

×
391
        // stop shell message
×
392
        if err := c.stopShell(client); err != nil {
×
393
                return err
×
394
        }
×
395

396
        // return the error message (if any)
397
        return c.err
×
398
}
399

400
// Run executes the command
401
func (c *TerminalCmd) runLoop(
402
        ctx context.Context,
403
        client *deviceconnect.Client,
404
        termID, termWidth, termHeight int,
405
) {
×
406
        // message channel
×
407
        msgChan := make(chan *ws.ProtoMsg)
×
408

×
409
        c.running = true
×
410
        go c.pipeStdin(msgChan, os.Stdin)
×
411
        go c.pipeStdout(msgChan, client, os.Stdout)
×
412

×
413
        // handle CTRL+C and signals
×
414
        quit := make(chan os.Signal, 1)
×
415
        signal.Notify(quit, unix.SIGINT, unix.SIGTERM)
×
416

×
417
        // resize the terminal window
×
418
        go c.resizeTerminal(ctx, msgChan, termID, termWidth, termHeight)
×
419

×
420
        healthcheckTimeout := time.Now().Add(24 * time.Hour)
×
421
        for c.running {
×
422
                select {
×
423
                case msg := <-msgChan:
×
424
                        err := client.WriteMessage(msg)
×
425
                        if err != nil {
×
426
                                fmt.Fprintf(os.Stderr, "error: %v\n", err)
×
427
                                break
×
428
                        }
429
                case healthcheckInterval := <-c.healthcheck:
×
430
                        healthcheckTimeout = time.Now().Add(time.Duration(healthcheckInterval) * time.Second)
×
431
                case <-time.After(time.Until(healthcheckTimeout)):
×
432
                        _ = c.stopShell(client)
×
433
                        c.err = errors.New("health check failed, connection with the device lost")
×
434
                        c.running = false
×
435
                case <-quit:
×
436
                        c.running = false
×
437
                case <-c.stop:
×
438
                        c.running = false
×
439
                }
440
        }
441
}
442

443
func (c *TerminalCmd) resizeTerminal(
444
        ctx context.Context,
445
        msgChan chan *ws.ProtoMsg,
446
        termID int,
447
        termWidth int,
448
        termHeight int,
449
) {
×
450
        resize := make(chan os.Signal, 1)
×
451
        signal.Notify(resize, syscall.SIGWINCH)
×
452
        defer signal.Stop(resize)
×
453

×
454
        for {
×
455
                select {
×
456
                case <-ctx.Done():
×
457
                        return
×
458
                case <-resize:
×
459
                        newTermWidth, newTermHeight, _ := term.GetSize(termID)
×
460
                        if newTermWidth != termWidth || newTermHeight != termHeight {
×
461
                                termWidth = newTermWidth
×
462
                                termHeight = newTermHeight
×
463
                                m := &ws.ProtoMsg{
×
464
                                        Header: ws.ProtoHdr{
×
465
                                                Proto:   ws.ProtoTypeShell,
×
466
                                                MsgType: wsshell.MessageTypeResizeShell,
×
467
                                                Properties: map[string]interface{}{
×
468
                                                        "terminal_width":  termWidth,
×
469
                                                        "terminal_height": termHeight,
×
470
                                                },
×
471
                                        },
×
472
                                }
×
473
                                msgChan <- m
×
474
                        }
×
475
                }
476
        }
477
}
478

479
func (c *TerminalCmd) Stop() {
×
480
        c.running = false
×
481
        c.stop <- struct{}{}
×
482
        if c.recording {
×
483
                c.stopRecording <- true
×
484
        }
×
485
}
486

487
func (c *TerminalCmd) pipeStdin(msgChan chan *ws.ProtoMsg, r io.Reader) {
×
488
        s := bufio.NewReader(r)
×
489
        for c.running {
×
490
                raw := make([]byte, 1024)
×
491
                n, err := s.Read(raw)
×
492
                if err != nil {
×
493
                        if c.running {
×
494
                                if err != io.EOF {
×
495
                                        fmt.Fprintf(os.Stderr, "error: %v\n", err)
×
496
                                }
×
497
                        } else {
×
498
                                c.Stop()
×
499
                        }
×
500
                        break
×
501
                }
502
                // CTRL+] terminates the session
503
                if raw[0] == 29 {
×
504
                        c.Stop()
×
505
                        return
×
506
                }
×
507

508
                m := &ws.ProtoMsg{
×
509
                        Header: ws.ProtoHdr{
×
510
                                Proto:     ws.ProtoTypeShell,
×
511
                                MsgType:   wsshell.MessageTypeShellCommand,
×
512
                                SessionID: c.sessionID,
×
513
                        },
×
514
                        Body: raw[:n],
×
515
                }
×
516
                msgChan <- m
×
517
        }
518
}
519

520
func (c *TerminalCmd) pipeStdout(
521
        msgChan chan *ws.ProtoMsg,
522
        client *deviceconnect.Client,
523
        w io.Writer,
524
) {
×
525
        for c.running {
×
526
                m, err := client.ReadMessage()
×
527
                if err != nil {
×
528
                        if c.running {
×
529
                                fmt.Fprintf(os.Stderr, "error: %v\n", err)
×
530
                        } else {
×
531
                                c.Stop()
×
532
                        }
×
533
                        break
×
534
                }
535
                if m.Header.Proto == ws.ProtoTypeShell &&
×
536
                        m.Header.MsgType == wsshell.MessageTypeShellCommand {
×
537
                        if _, err := w.Write(m.Body); err != nil {
×
538
                                break
×
539
                        }
540
                        if c.recording {
×
541
                                c.terminalOutputChan <- m.Body
×
542
                        }
×
543
                } else if m.Header.Proto == ws.ProtoTypeShell &&
×
544
                        m.Header.MsgType == wsshell.MessageTypePingShell {
×
545
                        if healthcheckTimeout, ok := m.Header.Properties["timeout"].(int64); ok &&
×
546
                                healthcheckTimeout > 0 {
×
547
                                c.healthcheck <- int(healthcheckTimeout)
×
548
                        }
×
549
                        m := &ws.ProtoMsg{
×
550
                                Header: ws.ProtoHdr{
×
551
                                        Proto:     ws.ProtoTypeShell,
×
552
                                        MsgType:   wsshell.MessageTypePongShell,
×
553
                                        SessionID: c.sessionID,
×
554
                                },
×
555
                        }
×
556
                        msgChan <- m
×
557
                } else if m.Header.Proto == ws.ProtoTypeShell &&
×
558
                        m.Header.MsgType == wsshell.MessageTypeSpawnShell {
×
559
                        status, ok := m.Header.Properties["status"].(int64)
×
560
                        if ok && status == int64(wsshell.ErrorMessage) {
×
561
                                c.err = errors.New(fmt.Sprintf("Unable to start the shell: %s", string(m.Body)))
×
562
                                c.Stop()
×
563
                        } else {
×
564
                                c.sessionID = string(m.Header.SessionID)
×
565
                        }
×
566
                } else if m.Header.Proto == ws.ProtoTypeShell &&
×
567
                        m.Header.MsgType == wsshell.MessageTypeStopShell {
×
568
                        c.Stop()
×
569
                        break
×
570
                }
571
        }
572
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc