hookdeck-cli 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.github/workflows/publish-npm.yml +19 -0
  2. package/.github/workflows/release.yml +85 -0
  3. package/.goreleaser/linux.yml +68 -0
  4. package/.goreleaser/mac.yml +69 -0
  5. package/.goreleaser/windows.yml +52 -0
  6. package/.tool-versions +1 -0
  7. package/Dockerfile +5 -0
  8. package/LICENSE +202 -0
  9. package/README.md +223 -0
  10. package/docs/cli-demo.gif +0 -0
  11. package/go.mod +58 -0
  12. package/go.sum +444 -0
  13. package/main.go +22 -0
  14. package/package.json +30 -0
  15. package/pkg/ansi/ansi.go +208 -0
  16. package/pkg/ansi/init_windows.go +23 -0
  17. package/pkg/cmd/completion.go +128 -0
  18. package/pkg/cmd/listen.go +114 -0
  19. package/pkg/cmd/login.go +37 -0
  20. package/pkg/cmd/logout.go +35 -0
  21. package/pkg/cmd/root.go +112 -0
  22. package/pkg/cmd/version.go +25 -0
  23. package/pkg/cmd/whoami.go +50 -0
  24. package/pkg/config/config.go +326 -0
  25. package/pkg/config/config_test.go +20 -0
  26. package/pkg/config/profile.go +296 -0
  27. package/pkg/config/profile_test.go +109 -0
  28. package/pkg/hookdeck/client.go +210 -0
  29. package/pkg/hookdeck/client_test.go +203 -0
  30. package/pkg/hookdeck/connections.go +61 -0
  31. package/pkg/hookdeck/destinations.go +14 -0
  32. package/pkg/hookdeck/guest.go +37 -0
  33. package/pkg/hookdeck/session.go +37 -0
  34. package/pkg/hookdeck/sources.go +73 -0
  35. package/pkg/hookdeck/telemetry.go +84 -0
  36. package/pkg/hookdeck/telemetry_test.go +35 -0
  37. package/pkg/hookdeck/verbosetransport.go +82 -0
  38. package/pkg/hookdeck/verbosetransport_test.go +47 -0
  39. package/pkg/listen/connection.go +91 -0
  40. package/pkg/listen/listen.go +140 -0
  41. package/pkg/listen/source.go +84 -0
  42. package/pkg/login/client_login.go +209 -0
  43. package/pkg/login/interactive_login.go +180 -0
  44. package/pkg/login/login_message.go +24 -0
  45. package/pkg/login/poll.go +78 -0
  46. package/pkg/login/validate.go +52 -0
  47. package/pkg/logout/logout.go +48 -0
  48. package/pkg/open/open.go +50 -0
  49. package/pkg/proxy/proxy.go +433 -0
  50. package/pkg/useragent/uname_unix.go +25 -0
  51. package/pkg/useragent/uname_unix_test.go +24 -0
  52. package/pkg/useragent/uname_windows.go +9 -0
  53. package/pkg/useragent/useragent.go +74 -0
  54. package/pkg/validators/cmds.go +71 -0
  55. package/pkg/validators/validate.go +144 -0
  56. package/pkg/version/version.go +58 -0
  57. package/pkg/version/version_test.go +15 -0
  58. package/pkg/websocket/attempt_messages.go +47 -0
  59. package/pkg/websocket/client.go +525 -0
  60. package/pkg/websocket/connection_messages.go +11 -0
  61. package/pkg/websocket/messages.go +64 -0
@@ -0,0 +1,525 @@
1
+ package websocket
2
+
3
+ import (
4
+ "context"
5
+ "encoding/base64"
6
+ "encoding/json"
7
+ "errors"
8
+ "io/ioutil"
9
+ "net"
10
+ "net/http"
11
+ "os"
12
+ "strings"
13
+ "sync"
14
+ "time"
15
+
16
+ ws "github.com/gorilla/websocket"
17
+ log "github.com/sirupsen/logrus"
18
+
19
+ "github.com/hookdeck/hookdeck-cli/pkg/useragent"
20
+ )
21
+
22
+ //
23
+ // Public types
24
+ //
25
+
26
+ // Config contains the optional configuration parameters of a Client.
27
+ type Config struct {
28
+ ConnectAttemptWait time.Duration
29
+
30
+ Dialer *ws.Dialer
31
+
32
+ Log *log.Logger
33
+
34
+ // Force use of unencrypted ws:// protocol instead of wss://
35
+ NoWSS bool
36
+
37
+ PingPeriod time.Duration
38
+
39
+ PongWait time.Duration
40
+
41
+ WriteWait time.Duration
42
+
43
+ EventHandler EventHandler
44
+ }
45
+
46
+ // EventHandler handles an event.
47
+ type EventHandler interface {
48
+ ProcessEvent(IncomingMessage)
49
+ }
50
+
51
+ // EventHandlerFunc is an adapter to allow the use of ordinary
52
+ // functions as event handlers. If f is a function with the
53
+ // appropriate signature, EventHandlerFunc(f) is a
54
+ // EventHandler that calls f.
55
+ type EventHandlerFunc func(IncomingMessage)
56
+
57
+ // ProcessEvent calls f(msg).
58
+ func (f EventHandlerFunc) ProcessEvent(msg IncomingMessage) {
59
+ f(msg)
60
+ }
61
+
62
+ // Client is the client used to receive webhook requests from Hookdeck
63
+ // and send back webhook responses from the local endpoint to Hookdeck.
64
+ type Client struct {
65
+ // URL the client connects to
66
+ URL string
67
+
68
+ CLIKey string
69
+
70
+ // ID sent by the client in the `Websocket-Id` header when connecting
71
+ WebSocketID string
72
+
73
+ // Feature that the websocket is specified for
74
+ //WebSocketAuthorizedFeature string
75
+
76
+ // Optional configuration parameters
77
+ cfg *Config
78
+
79
+ conn *ws.Conn
80
+ done chan struct{}
81
+ isConnected bool
82
+
83
+ NotifyExpired chan struct{}
84
+ notifyClose chan error
85
+ send chan *OutgoingMessage
86
+ stopReadPump chan struct{}
87
+ stopWritePump chan struct{}
88
+ wg *sync.WaitGroup
89
+ }
90
+
91
+ // Connected returns a channel that's closed when the client has finished
92
+ // establishing the websocket connection.
93
+ func (c *Client) Connected() <-chan struct{} {
94
+ d := make(chan struct{})
95
+
96
+ go func() {
97
+ for !c.isConnected {
98
+ time.Sleep(100 * time.Millisecond)
99
+ }
100
+ close(d)
101
+ }()
102
+
103
+ return d
104
+ }
105
+
106
+ // Run starts listening for incoming webhook requests from Hookdeck.
107
+ func (c *Client) Run(ctx context.Context) {
108
+ c.isConnected = false
109
+ c.cfg.Log.WithFields(log.Fields{
110
+ "prefix": "websocket.client.Run",
111
+ }).Debug("Attempting to connect to Hookdeck")
112
+
113
+ err := c.connect(ctx)
114
+ if err != nil {
115
+ c.cfg.Log.WithFields(log.Fields{
116
+ "prefix": "websocket.client.Run",
117
+ }).Debug(err)
118
+
119
+ c.cfg.Log.WithFields(log.Fields{
120
+ "prefix": "websocket.client.Run",
121
+ }).Debug("Failed to connect to Hookdeck. Retrying...")
122
+
123
+ if err == ErrUnknownID {
124
+ c.cfg.Log.WithFields(log.Fields{
125
+ "prefix": "websocket.client.Run",
126
+ }).Debug("Websocket session is expired.")
127
+ }
128
+ c.ConnectionLost()
129
+ return
130
+ }
131
+
132
+ select {
133
+ case <-ctx.Done():
134
+ close(c.send)
135
+ close(c.stopReadPump)
136
+ close(c.stopWritePump)
137
+
138
+ return
139
+ case <-c.done:
140
+ close(c.send)
141
+ close(c.stopReadPump)
142
+ close(c.stopWritePump)
143
+ close(c.NotifyExpired)
144
+
145
+ return
146
+ case <-c.notifyClose:
147
+ c.cfg.Log.WithFields(log.Fields{
148
+ "prefix": "websocket.client.Run",
149
+ }).Debug("Disconnected from Hookdeck")
150
+
151
+ c.ConnectionLost()
152
+ close(c.stopReadPump)
153
+ close(c.stopWritePump)
154
+ c.wg.Wait()
155
+ }
156
+ }
157
+
158
+ // ConnectionLost sends NotifyExpired
159
+ func (c *Client) ConnectionLost() {
160
+ c.NotifyExpired <- struct{}{}
161
+ }
162
+
163
+ // Stop stops listening for incoming webhook events.
164
+ func (c *Client) Stop() {
165
+ close(c.done)
166
+ }
167
+
168
+ // SendMessage sends a message to Hookdeck through the websocket.
169
+ func (c *Client) SendMessage(msg *OutgoingMessage) {
170
+ c.send <- msg
171
+ }
172
+
173
+ func readWSConnectErrorMessage(resp *http.Response) string {
174
+ if resp == nil {
175
+ return ""
176
+ }
177
+ if resp.Body == nil {
178
+ return ""
179
+ }
180
+
181
+ se := struct {
182
+ InnerError struct {
183
+ Message string `json:"message"`
184
+ } `json:"error"`
185
+ }{}
186
+
187
+ body, err := ioutil.ReadAll(resp.Body)
188
+
189
+ if err != nil {
190
+ return ""
191
+ }
192
+
193
+ err = json.Unmarshal(body, &se)
194
+ if err != nil {
195
+ return ""
196
+ }
197
+
198
+ return se.InnerError.Message
199
+ }
200
+
201
+ var unknownIDMessage string = "Unknown WebSocket ID."
202
+
203
+ // ErrUnknownID can occur when the websocket session is expired or invalid
204
+ var ErrUnknownID error = errors.New(unknownIDMessage)
205
+
206
+ func basicAuth(username, password string) string {
207
+ auth := username + ":" + password
208
+ return base64.StdEncoding.EncodeToString([]byte(auth))
209
+ }
210
+
211
+ // connect makes a single attempt to connect to the websocket URL. It returns
212
+ // the success of the attempt.
213
+ func (c *Client) connect(ctx context.Context) error {
214
+ header := http.Header{}
215
+ // Disable compression by requiring "identity"
216
+ header.Set("Accept-Encoding", "identity")
217
+ header.Set("User-Agent", useragent.GetEncodedUserAgent())
218
+ header.Set("X-Hookdeck-Client-User-Agent", useragent.GetEncodedHookdeckUserAgent())
219
+ header.Set("Websocket-Id", c.WebSocketID)
220
+ header.Set("Authorization", "Basic "+basicAuth(c.CLIKey, ""))
221
+
222
+ url := c.URL
223
+ if c.cfg.NoWSS && strings.HasPrefix(url, "wss") {
224
+ url = "ws" + strings.TrimPrefix(c.URL, "wss")
225
+ }
226
+
227
+ c.cfg.Log.WithFields(log.Fields{
228
+ "prefix": "websocket.Client.connect",
229
+ "url": url,
230
+ }).Debug("Dialing websocket")
231
+
232
+ conn, resp, err := c.cfg.Dialer.DialContext(ctx, url, header)
233
+ if err != nil {
234
+ message := readWSConnectErrorMessage(resp)
235
+ c.cfg.Log.WithFields(log.Fields{
236
+ "prefix": "websocket.Client.connect",
237
+ "error": err,
238
+ "message": message,
239
+ }).Debug("Websocket connection error")
240
+ if message == unknownIDMessage {
241
+ return ErrUnknownID
242
+ }
243
+ return err
244
+ }
245
+
246
+ defer resp.Body.Close()
247
+
248
+ c.changeConnection(conn)
249
+ c.isConnected = true
250
+
251
+ c.wg = &sync.WaitGroup{}
252
+ c.wg.Add(2)
253
+
254
+ go c.readPump()
255
+
256
+ go c.writePump()
257
+
258
+ c.cfg.Log.WithFields(log.Fields{
259
+ "prefix": "websocket.client.connect",
260
+ }).Debug("Connected!")
261
+
262
+ return err
263
+ }
264
+
265
+ // changeConnection takes a new connection and recreates the channels.
266
+ func (c *Client) changeConnection(conn *ws.Conn) {
267
+ c.conn = conn
268
+ c.notifyClose = make(chan error)
269
+ c.stopReadPump = make(chan struct{})
270
+ c.stopWritePump = make(chan struct{})
271
+ }
272
+
273
+ // readPump pumps messages from the websocket connection and pushes them into
274
+ // RequestHandler's ProcessWebhookRequest.
275
+ //
276
+ // The application runs readPump in a per-connection goroutine. The application
277
+ // ensures that there is at most one reader on a connection by executing all
278
+ // reads from this goroutine.
279
+ func (c *Client) readPump() {
280
+ defer c.wg.Done()
281
+
282
+ err := c.conn.SetReadDeadline(time.Now().Add(c.cfg.PongWait))
283
+ if err != nil {
284
+ c.cfg.Log.Debug("SetReadDeadline error: ", err)
285
+ }
286
+
287
+ c.conn.SetPongHandler(func(string) error {
288
+ c.cfg.Log.WithFields(log.Fields{
289
+ "prefix": "websocket.Client.readPump",
290
+ }).Debug("Received pong message")
291
+
292
+ err := c.conn.SetReadDeadline(time.Now().Add(c.cfg.PongWait))
293
+ if err != nil {
294
+ c.cfg.Log.Debug("SetReadDeadline error: ", err)
295
+ }
296
+
297
+ return nil
298
+ })
299
+
300
+ for {
301
+ _, data, err := c.conn.ReadMessage()
302
+ if err != nil {
303
+ select {
304
+ case <-c.stopReadPump:
305
+ c.cfg.Log.WithFields(log.Fields{
306
+ "prefix": "websocket.Client.readPump",
307
+ }).Debug("stopReadPump")
308
+ default:
309
+ switch {
310
+ case !ws.IsCloseError(err):
311
+ // read errors do not prevent websocket reconnects in the CLI so we should
312
+ // only display this on debug-level logging
313
+ c.cfg.Log.WithFields(log.Fields{
314
+ "prefix": "websocket.Client.Close",
315
+ }).Debug("read error: ", err)
316
+ case ws.IsUnexpectedCloseError(err, ws.CloseNormalClosure):
317
+ c.cfg.Log.WithFields(log.Fields{
318
+ "prefix": "websocket.Client.Close",
319
+ }).Error("close error: ", err)
320
+ c.cfg.Log.WithFields(log.Fields{
321
+ "prefix": "hookdeckcli.ADDITIONAL_INFO",
322
+ }).Error("If you run into issues, please re-run with `--log-level debug` and share the output with the Hookdeck team on GitHub.")
323
+ default:
324
+ c.cfg.Log.Error("other error: ", err)
325
+ c.cfg.Log.WithFields(log.Fields{
326
+ "prefix": "hookdeckcli.ADDITIONAL_INFO",
327
+ }).Error("If you run into issues, please re-run with `--log-level debug` and share the output with the Hookdeck team on GitHub.")
328
+ }
329
+ c.notifyClose <- err
330
+ }
331
+
332
+ return
333
+ }
334
+
335
+ c.cfg.Log.WithFields(log.Fields{
336
+ "prefix": "websocket.Client.readPump",
337
+ "message": string(data),
338
+ }).Debug("Incoming message")
339
+
340
+ var msg IncomingMessage
341
+ if err = json.Unmarshal(data, &msg); err != nil {
342
+ c.cfg.Log.Debug("Received malformed message: ", err)
343
+
344
+ continue
345
+ }
346
+
347
+ go c.cfg.EventHandler.ProcessEvent(msg)
348
+ }
349
+ }
350
+
351
+ // writePump pumps messages to the websocket connection that are queued with
352
+ // SendWebhookResponse.
353
+ //
354
+ // A goroutine running writePump is started for each connection. The
355
+ // application ensures that there is at most one writer to a connection by
356
+ // executing all writes from this goroutine.
357
+ func (c *Client) writePump() {
358
+ ticker := time.NewTicker(c.cfg.PingPeriod)
359
+ defer func() {
360
+ ticker.Stop()
361
+ c.wg.Done()
362
+ }()
363
+
364
+ for {
365
+ select {
366
+ case whResp, ok := <-c.send:
367
+ err := c.conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteWait))
368
+ if err != nil {
369
+ c.cfg.Log.Debug("SetWriteDeadline error: ", err)
370
+ }
371
+
372
+ if !ok {
373
+ c.cfg.Log.WithFields(log.Fields{
374
+ "prefix": "websocket.Client.writePump",
375
+ }).Debug("Sending close message")
376
+
377
+ err = c.conn.WriteMessage(ws.CloseMessage, ws.FormatCloseMessage(ws.CloseNormalClosure, ""))
378
+ if err != nil {
379
+ c.cfg.Log.Debug("WriteMessage error: ", err)
380
+ }
381
+
382
+ return
383
+ }
384
+
385
+ c.cfg.Log.WithFields(log.Fields{
386
+ "prefix": "websocket.Client.writePump",
387
+ }).Debug("Sending text message")
388
+
389
+ err = c.conn.WriteJSON(whResp)
390
+ if err != nil {
391
+ if ws.IsUnexpectedCloseError(err, ws.CloseNormalClosure) {
392
+ c.cfg.Log.Error("write error: ", err)
393
+ }
394
+ // Requeue the message to be processed when writePump restarts
395
+ c.send <- whResp
396
+ c.notifyClose <- err
397
+
398
+ return
399
+ }
400
+ case <-ticker.C:
401
+ err := c.conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteWait))
402
+ if err != nil {
403
+ c.cfg.Log.Debug("SetWriteDeadline error: ", err)
404
+ }
405
+
406
+ c.cfg.Log.WithFields(log.Fields{
407
+ "prefix": "websocket.Client.writePump",
408
+ }).Debug("Sending ping message")
409
+
410
+ if err = c.conn.WriteMessage(ws.PingMessage, nil); err != nil {
411
+ if ws.IsUnexpectedCloseError(err, ws.CloseNormalClosure) {
412
+ c.cfg.Log.Error("write error: ", err)
413
+ }
414
+ c.notifyClose <- err
415
+
416
+ return
417
+ }
418
+ case <-c.stopWritePump:
419
+ c.cfg.Log.WithFields(log.Fields{
420
+ "prefix": "websocket.Client.writePump",
421
+ }).Debug("stopWritePump")
422
+
423
+ return
424
+ }
425
+ }
426
+ }
427
+
428
+ //
429
+ // Public functions
430
+ //
431
+
432
+ // NewClient returns a new Client.
433
+ func NewClient(url string, webSocketID string, CLIKey string, cfg *Config) *Client {
434
+ if cfg == nil {
435
+ cfg = &Config{}
436
+ }
437
+
438
+ if cfg.ConnectAttemptWait == 0 {
439
+ cfg.ConnectAttemptWait = defaultConnectAttemptWait
440
+ }
441
+
442
+ if cfg.Dialer == nil {
443
+ cfg.Dialer = newWebSocketDialer(os.Getenv("HOOKDECK_CLI_UNIX_SOCKET"))
444
+ }
445
+
446
+ if cfg.Log == nil {
447
+ cfg.Log = &log.Logger{Out: ioutil.Discard}
448
+ }
449
+
450
+ if cfg.PongWait == 0 {
451
+ cfg.PongWait = defaultPongWait
452
+ }
453
+
454
+ if cfg.PingPeriod == 0 {
455
+ cfg.PingPeriod = (cfg.PongWait * 9) / 10
456
+ }
457
+
458
+ if cfg.WriteWait == 0 {
459
+ cfg.WriteWait = defaultWriteWait
460
+ }
461
+
462
+ if cfg.EventHandler == nil {
463
+ cfg.EventHandler = nullEventHandler
464
+ }
465
+
466
+ // Note that this client is not configured for websocket communications
467
+ // and you must call c.changeConnection
468
+ return &Client{
469
+ URL: url,
470
+ WebSocketID: webSocketID,
471
+ // WebSocketAuthorizedFeature: websocketAuthorizedFeature,
472
+ CLIKey: CLIKey,
473
+ cfg: cfg,
474
+ done: make(chan struct{}),
475
+ send: make(chan *OutgoingMessage),
476
+ NotifyExpired: make(chan struct{}),
477
+ }
478
+ }
479
+
480
+ //
481
+ // Private constants
482
+ //
483
+
484
+ const (
485
+ defaultConnectAttemptWait = 10 * time.Second
486
+
487
+ defaultPongWait = 10 * time.Second
488
+
489
+ defaultWriteWait = 10 * time.Second
490
+ )
491
+
492
+ //
493
+ // Private variables
494
+ //
495
+
496
+ var subprotocols = [...]string{"hookdeckcli-devproxy-v1"}
497
+
498
+ var nullEventHandler = EventHandlerFunc(func(IncomingMessage) {})
499
+
500
+ //
501
+ // Private functions
502
+ //
503
+
504
+ func newWebSocketDialer(unixSocket string) *ws.Dialer {
505
+ var dialer *ws.Dialer
506
+
507
+ if unixSocket != "" {
508
+ dialFunc := func(network, addr string) (net.Conn, error) {
509
+ return net.Dial("unix", unixSocket)
510
+ }
511
+ dialer = &ws.Dialer{
512
+ HandshakeTimeout: 10 * time.Second,
513
+ NetDial: dialFunc,
514
+ Subprotocols: subprotocols[:],
515
+ }
516
+ } else {
517
+ dialer = &ws.Dialer{
518
+ HandshakeTimeout: 10 * time.Second,
519
+ Proxy: http.ProxyFromEnvironment,
520
+ Subprotocols: subprotocols[:],
521
+ }
522
+ }
523
+
524
+ return dialer
525
+ }
@@ -0,0 +1,11 @@
1
+ package websocket
2
+
3
+ type ConnectionMessageBody struct {
4
+ SourceId string `json:"source_id"`
5
+ ConnectionIds []string `json:"webhook_ids"`
6
+ }
7
+
8
+ type ConnectionMessage struct {
9
+ Event string `json:"event"`
10
+ Body ConnectionMessageBody `json:"body"`
11
+ }
@@ -0,0 +1,64 @@
1
+ package websocket
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ )
7
+
8
+ // IncomingMessage represents any incoming message sent by Hookdeck.
9
+ type IncomingMessage struct {
10
+ *Attempt
11
+ // *RequestLogEvent
12
+ }
13
+
14
+ // UnmarshalJSON deserializes incoming messages sent by Hookdeck into the
15
+ // appropriate structure.
16
+ func (m *IncomingMessage) UnmarshalJSON(data []byte) error {
17
+ incomingMessageEventOnly := struct {
18
+ Event string `json:"event"`
19
+ }{}
20
+
21
+ if err := json.Unmarshal(data, &incomingMessageEventOnly); err != nil {
22
+ return err
23
+ }
24
+
25
+ switch incomingMessageEventOnly.Event {
26
+ case "attempt":
27
+ var evt Attempt
28
+ if err := json.Unmarshal(data, &evt); err != nil {
29
+ return err
30
+ }
31
+
32
+ m.Attempt = &evt
33
+ case "connect_response":
34
+ return nil
35
+ default:
36
+ return fmt.Errorf("Unexpected message type: %s", incomingMessageEventOnly.Event)
37
+ }
38
+
39
+ return nil
40
+ }
41
+
42
+ // MarshalJSON serializes outgoing messages sent to Hookdeck.
43
+ func (m OutgoingMessage) MarshalJSON() ([]byte, error) {
44
+ if m.AttemptResponse != nil {
45
+ return json.Marshal(m.AttemptResponse)
46
+ }
47
+
48
+ if m.ErrorAttemptResponse != nil {
49
+ return json.Marshal(m.ErrorAttemptResponse)
50
+ }
51
+
52
+ if m.ConnectionMessage != nil {
53
+ return json.Marshal(m.ConnectionMessage)
54
+ }
55
+
56
+ return json.Marshal(nil)
57
+ }
58
+
59
+ // OutgoingMessage represents any outgoing message sent to Hookdeck.
60
+ type OutgoingMessage struct {
61
+ *ErrorAttemptResponse
62
+ *AttemptResponse
63
+ *ConnectionMessage
64
+ }