Skip to content

Commit 9941110

Browse files
committed
feat: add initial version of remote terminal
1 parent 658124e commit 9941110

File tree

5 files changed

+279
-14
lines changed

5 files changed

+279
-14
lines changed

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
module github.com/enapter/enapter-cli
22

3-
go 1.23.0
4-
3+
go 1.24.0
54

65
require (
76
github.com/gorilla/websocket v1.5.3
87
github.com/stretchr/testify v1.9.0
98
github.com/urfave/cli/v2 v2.27.4
9+
golang.org/x/term v0.37.0
1010
)
1111

1212
require (
@@ -15,5 +15,6 @@ require (
1515
github.com/pmezard/go-difflib v1.0.0 // indirect
1616
github.com/russross/blackfriday/v2 v2.1.0 // indirect
1717
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
18+
golang.org/x/sys v0.38.0 // indirect
1819
gopkg.in/yaml.v3 v3.0.1 // indirect
1920
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
1414
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
1515
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
1616
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
17+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
18+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
19+
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
20+
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
1721
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1822
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1923
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/app/enaptercli/cmd_base.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,13 +149,23 @@ type runWebSocketParams struct {
149149
}
150150

151151
func (c *cmdBase) runWebSocket(ctx context.Context, p runWebSocketParams) error {
152+
url, err := url.Parse(c.apiHost + "/v3" + p.Path)
153+
if err != nil {
154+
return fmt.Errorf("parse url: %w", err)
155+
}
156+
url.RawQuery = p.Query.Encode()
157+
158+
headers := make(http.Header)
159+
headers.Set("X-Enapter-Auth-Token", c.token)
160+
headers.Set("User-Agent", c.userAgent)
161+
152162
for retry := false; ; retry = true {
153163
if retry {
154164
fmt.Fprintln(c.errWriter, "Reconnecting...")
155165
time.Sleep(time.Second)
156166
}
157167

158-
conn, err := c.dialWebSocket(ctx, p.Path, p.Query)
168+
conn, err := c.dialWebSocket(ctx, url, headers)
159169
if err != nil {
160170
if e := cli.ExitCoder(nil); errors.As(err, &e) {
161171
return err
@@ -205,18 +215,8 @@ func (c *cmdBase) defaultRespProcessor(resp *http.Response) error {
205215
}
206216

207217
func (c *cmdBase) dialWebSocket(
208-
ctx context.Context, path string, query url.Values,
218+
ctx context.Context, url *url.URL, headers http.Header,
209219
) (*websocket.Conn, error) {
210-
url, err := url.Parse(c.apiHost + "/v3" + path)
211-
if err != nil {
212-
return nil, fmt.Errorf("parse url: %w", err)
213-
}
214-
url.RawQuery = query.Encode()
215-
216-
headers := make(http.Header)
217-
headers.Set("X-Enapter-Auth-Token", c.token)
218-
headers.Set("User-Agent", c.userAgent)
219-
220220
const timeout = 5 * time.Second
221221
dialer := websocket.Dialer{
222222
HandshakeTimeout: timeout,

internal/app/enaptercli/cmd_device.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func buildCmdDevice() *cli.Command {
2929
buildCmdDeviceCommand(),
3030
buildCmdDeviceTelemetry(),
3131
buildCmdDeviceCommunicationConfig(),
32+
buildCmdDeviceRunTerminal(),
3233
},
3334
}
3435
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
package enaptercli
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"os"
10+
"time"
11+
12+
"github.com/gorilla/websocket"
13+
"github.com/urfave/cli/v2"
14+
"golang.org/x/term"
15+
)
16+
17+
type cmdDeviceRunTerminal struct {
18+
cmdDevice
19+
deviceID string
20+
winWidth int
21+
winHeight int
22+
}
23+
24+
func buildCmdDeviceRunTerminal() *cli.Command {
25+
cmd := &cmdDeviceRunTerminal{}
26+
return &cli.Command{
27+
Name: "run-terminal",
28+
Usage: "Run a new gateway terminal session",
29+
Description: "Remote terminal feature should be enabled in gateway settings. " +
30+
"Use Ctrl+] sequence to force connection close.",
31+
CustomHelpTemplate: cmd.CommandHelpTemplate(),
32+
Flags: cmd.Flags(),
33+
Before: cmd.Before,
34+
Action: func(cliCtx *cli.Context) error {
35+
return cmd.do(cliCtx.Context)
36+
},
37+
}
38+
}
39+
40+
func (c *cmdDeviceRunTerminal) Flags() []cli.Flag {
41+
flags := c.cmdDevice.Flags()
42+
return append(flags, &cli.StringFlag{
43+
Name: "device-id",
44+
Aliases: []string{"d"},
45+
Usage: "gateway device ID",
46+
Destination: &c.deviceID,
47+
Required: true,
48+
})
49+
}
50+
51+
func (c *cmdDeviceRunTerminal) do(ctx context.Context) error {
52+
fin := os.Stdin
53+
fd := int(fin.Fd())
54+
if !term.IsTerminal(fd) {
55+
return cli.Exit("Standard input should be a terminal.", 1)
56+
}
57+
58+
var credentials struct {
59+
ChannelID string `json:"channel_id"`
60+
Token string `json:"token"`
61+
WebSocketURL string `json:"websocket_url"`
62+
}
63+
if err := c.doHTTPRequest(ctx, doHTTPRequestParams{
64+
Method: http.MethodPost,
65+
Path: "/" + c.deviceID + "/run_terminal",
66+
RespProcessor: func(r *http.Response) error {
67+
if r.StatusCode != http.StatusOK {
68+
return cli.Exit(parseRespErrorMessage(r), 1)
69+
}
70+
return json.NewDecoder(r.Body).Decode(&credentials)
71+
},
72+
}); err != nil {
73+
return err
74+
}
75+
76+
url, err := url.Parse(credentials.WebSocketURL)
77+
if err != nil {
78+
return fmt.Errorf("parse url: %w", err)
79+
}
80+
headers := make(http.Header)
81+
headers.Set("Authorization", "Bearer "+credentials.Token)
82+
83+
conn, err := c.dialWebSocket(ctx, url, headers)
84+
if err != nil {
85+
return fmt.Errorf("dial websocket: %w", err)
86+
}
87+
defer conn.Close()
88+
89+
oldState, err := term.MakeRaw(fd)
90+
if err != nil {
91+
return fmt.Errorf("make raw terminal: %w", err)
92+
}
93+
defer func() { _ = term.Restore(fd, oldState) }()
94+
95+
// TODO: wait for pong?
96+
if err := c.writePing(conn, credentials.ChannelID); err != nil {
97+
return fmt.Errorf("ping: %w", err)
98+
}
99+
100+
fmt.Fprint(c.writer, "Use Ctrl+] to terminate the session.\r\n\r\n")
101+
102+
errCh := make(chan error)
103+
stdinCh := make(chan byte)
104+
go func() { errCh <- c.runFileReader(ctx, fin, stdinCh) }()
105+
go func() { errCh <- c.runConnReader(ctx, conn) }()
106+
107+
return c.run(ctx, conn, credentials.ChannelID, stdinCh, errCh, fd)
108+
}
109+
110+
func (c *cmdDeviceRunTerminal) runFileReader(
111+
ctx context.Context, f *os.File, ch chan<- byte,
112+
) error {
113+
for {
114+
select {
115+
case <-ctx.Done():
116+
return nil
117+
default:
118+
}
119+
120+
var buf [1]byte
121+
n, err := f.Read(buf[:])
122+
if err != nil {
123+
return err
124+
}
125+
126+
if n > 0 {
127+
select {
128+
case <-ctx.Done():
129+
return nil
130+
case ch <- buf[0]:
131+
}
132+
}
133+
}
134+
}
135+
136+
func (c *cmdDeviceRunTerminal) runConnReader(
137+
ctx context.Context, conn *websocket.Conn,
138+
) error {
139+
for {
140+
select {
141+
case <-ctx.Done():
142+
return nil
143+
default:
144+
}
145+
146+
var msg struct {
147+
Data string `json:"data"`
148+
}
149+
if err := conn.ReadJSON(&msg); err != nil {
150+
return fmt.Errorf("read: %w", err)
151+
}
152+
153+
var data []string
154+
if err := json.Unmarshal([]byte(msg.Data), &data); err != nil {
155+
return fmt.Errorf("unmarhal data: %w", err)
156+
}
157+
158+
if len(data) == 0 {
159+
return cli.Exit("Unexpected payload from server.", 1)
160+
}
161+
if data[0] == "exit" {
162+
return nil
163+
}
164+
if data[0] == "stdin" && len(data) > 1 {
165+
fmt.Fprint(c.writer, data[1])
166+
}
167+
}
168+
}
169+
170+
func (c *cmdDeviceRunTerminal) run(
171+
ctx context.Context, conn *websocket.Conn, channelID string,
172+
stdinCh <-chan byte, errCh <-chan error, fd int,
173+
) error {
174+
const keepAliveInterval = 30 * time.Second
175+
const updateSizeInterval = time.Second
176+
177+
keepAliveTicker := time.NewTicker(keepAliveInterval)
178+
updateSizeTicker := time.NewTicker(updateSizeInterval)
179+
180+
for {
181+
select {
182+
case <-ctx.Done():
183+
return nil
184+
case err := <-errCh:
185+
return err
186+
default:
187+
}
188+
189+
select {
190+
case <-ctx.Done():
191+
return nil
192+
case err := <-errCh:
193+
return err
194+
case <-keepAliveTicker.C:
195+
if err := c.writeKeepalive(conn, channelID); err != nil {
196+
return err
197+
}
198+
case <-updateSizeTicker.C:
199+
if err := c.writeSetSize(conn, channelID, fd); err != nil {
200+
return err
201+
}
202+
case b := <-stdinCh:
203+
const GS = 29 // ^]
204+
if b == GS {
205+
return cli.Exit("Exiting session.", 0)
206+
}
207+
if err := c.writeStdin(conn, channelID, b); err != nil {
208+
return err
209+
}
210+
}
211+
}
212+
}
213+
214+
func (c *cmdDeviceRunTerminal) writePing(conn *websocket.Conn, channelID string) error {
215+
return conn.WriteJSON(map[string]any{
216+
"channel": channelID,
217+
"data": `["ping"]`,
218+
})
219+
}
220+
221+
func (c *cmdDeviceRunTerminal) writeStdin(conn *websocket.Conn, channelID string, b byte) error {
222+
data, err := json.Marshal([]string{"stdin", string(b)})
223+
if err != nil {
224+
return err
225+
}
226+
return conn.WriteJSON(map[string]any{
227+
"channel": channelID,
228+
"data": string(data),
229+
})
230+
}
231+
232+
func (c *cmdDeviceRunTerminal) writeKeepalive(conn *websocket.Conn, channelID string) error {
233+
return conn.WriteJSON(map[string]any{
234+
"channel": channelID,
235+
"data": `["keepalive_ping"]`,
236+
})
237+
}
238+
239+
func (c *cmdDeviceRunTerminal) writeSetSize(conn *websocket.Conn, channelID string, fd int) error {
240+
w, h, err := term.GetSize(fd)
241+
if err != nil {
242+
// FIXME: error on Windows
243+
return nil
244+
}
245+
if c.winWidth == w && c.winHeight == h {
246+
return nil
247+
}
248+
249+
if err := conn.WriteJSON(map[string]any{
250+
"channel": channelID,
251+
"data": fmt.Sprintf("[%q, %d, %d]", "set_size", h, w),
252+
}); err != nil {
253+
return err
254+
}
255+
256+
c.winWidth = w
257+
c.winHeight = h
258+
return nil
259+
}

0 commit comments

Comments
 (0)