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.
- package/.github/workflows/publish-npm.yml +19 -0
- package/.github/workflows/release.yml +85 -0
- package/.goreleaser/linux.yml +68 -0
- package/.goreleaser/mac.yml +69 -0
- package/.goreleaser/windows.yml +52 -0
- package/.tool-versions +1 -0
- package/Dockerfile +5 -0
- package/LICENSE +202 -0
- package/README.md +223 -0
- package/docs/cli-demo.gif +0 -0
- package/go.mod +58 -0
- package/go.sum +444 -0
- package/main.go +22 -0
- package/package.json +30 -0
- package/pkg/ansi/ansi.go +208 -0
- package/pkg/ansi/init_windows.go +23 -0
- package/pkg/cmd/completion.go +128 -0
- package/pkg/cmd/listen.go +114 -0
- package/pkg/cmd/login.go +37 -0
- package/pkg/cmd/logout.go +35 -0
- package/pkg/cmd/root.go +112 -0
- package/pkg/cmd/version.go +25 -0
- package/pkg/cmd/whoami.go +50 -0
- package/pkg/config/config.go +326 -0
- package/pkg/config/config_test.go +20 -0
- package/pkg/config/profile.go +296 -0
- package/pkg/config/profile_test.go +109 -0
- package/pkg/hookdeck/client.go +210 -0
- package/pkg/hookdeck/client_test.go +203 -0
- package/pkg/hookdeck/connections.go +61 -0
- package/pkg/hookdeck/destinations.go +14 -0
- package/pkg/hookdeck/guest.go +37 -0
- package/pkg/hookdeck/session.go +37 -0
- package/pkg/hookdeck/sources.go +73 -0
- package/pkg/hookdeck/telemetry.go +84 -0
- package/pkg/hookdeck/telemetry_test.go +35 -0
- package/pkg/hookdeck/verbosetransport.go +82 -0
- package/pkg/hookdeck/verbosetransport_test.go +47 -0
- package/pkg/listen/connection.go +91 -0
- package/pkg/listen/listen.go +140 -0
- package/pkg/listen/source.go +84 -0
- package/pkg/login/client_login.go +209 -0
- package/pkg/login/interactive_login.go +180 -0
- package/pkg/login/login_message.go +24 -0
- package/pkg/login/poll.go +78 -0
- package/pkg/login/validate.go +52 -0
- package/pkg/logout/logout.go +48 -0
- package/pkg/open/open.go +50 -0
- package/pkg/proxy/proxy.go +433 -0
- package/pkg/useragent/uname_unix.go +25 -0
- package/pkg/useragent/uname_unix_test.go +24 -0
- package/pkg/useragent/uname_windows.go +9 -0
- package/pkg/useragent/useragent.go +74 -0
- package/pkg/validators/cmds.go +71 -0
- package/pkg/validators/validate.go +144 -0
- package/pkg/version/version.go +58 -0
- package/pkg/version/version_test.go +15 -0
- package/pkg/websocket/attempt_messages.go +47 -0
- package/pkg/websocket/client.go +525 -0
- package/pkg/websocket/connection_messages.go +11 -0
- package/pkg/websocket/messages.go +64 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
package proxy
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"crypto/tls"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io/ioutil"
|
|
10
|
+
"math"
|
|
11
|
+
"net/http"
|
|
12
|
+
"net/url"
|
|
13
|
+
"os"
|
|
14
|
+
"os/signal"
|
|
15
|
+
"strconv"
|
|
16
|
+
"strings"
|
|
17
|
+
"syscall"
|
|
18
|
+
"time"
|
|
19
|
+
|
|
20
|
+
log "github.com/sirupsen/logrus"
|
|
21
|
+
|
|
22
|
+
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
|
|
23
|
+
"github.com/hookdeck/hookdeck-cli/pkg/config"
|
|
24
|
+
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
|
|
25
|
+
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const timeLayout = "2006-01-02 15:04:05"
|
|
29
|
+
|
|
30
|
+
//
|
|
31
|
+
// Public types
|
|
32
|
+
//
|
|
33
|
+
|
|
34
|
+
// Config provides the configuration of a Proxy
|
|
35
|
+
type Config struct {
|
|
36
|
+
// DeviceName is the name of the device sent to Hookdeck to help identify the device
|
|
37
|
+
DeviceName string
|
|
38
|
+
Profile config.Profile
|
|
39
|
+
// Key is the API key used to authenticate with Hookdeck
|
|
40
|
+
Key string
|
|
41
|
+
URL *url.URL
|
|
42
|
+
APIBaseURL string
|
|
43
|
+
DashboardBaseURL string
|
|
44
|
+
ConsoleBaseURL string
|
|
45
|
+
WSBaseURL string
|
|
46
|
+
// Indicates whether to print full JSON objects to stdout
|
|
47
|
+
PrintJSON bool
|
|
48
|
+
Log *log.Logger
|
|
49
|
+
// Force use of unencrypted ws:// protocol instead of wss://
|
|
50
|
+
NoWSS bool
|
|
51
|
+
Insecure bool
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A Proxy opens a websocket connection with Hookdeck, listens for incoming
|
|
55
|
+
// webhook events, forwards them to the local endpoint and sends the response
|
|
56
|
+
// back to Hookdeck.
|
|
57
|
+
type Proxy struct {
|
|
58
|
+
cfg *Config
|
|
59
|
+
source hookdeck.Source
|
|
60
|
+
connections []hookdeck.Connection
|
|
61
|
+
connections_paths map[string]string
|
|
62
|
+
webSocketClient *websocket.Client
|
|
63
|
+
connectionTimer *time.Timer
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func withSIGTERMCancel(ctx context.Context, onCancel func()) context.Context {
|
|
67
|
+
// Create a context that will be canceled when Ctrl+C is pressed
|
|
68
|
+
ctx, cancel := context.WithCancel(ctx)
|
|
69
|
+
|
|
70
|
+
interruptCh := make(chan os.Signal, 1)
|
|
71
|
+
signal.Notify(interruptCh, os.Interrupt, syscall.SIGTERM)
|
|
72
|
+
|
|
73
|
+
go func() {
|
|
74
|
+
<-interruptCh
|
|
75
|
+
onCancel()
|
|
76
|
+
cancel()
|
|
77
|
+
}()
|
|
78
|
+
return ctx
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Run manages the connection to Hookdeck.
|
|
82
|
+
// The connection is established in phases:
|
|
83
|
+
// - Create a new CLI session
|
|
84
|
+
// - Create a new websocket connection
|
|
85
|
+
func (p *Proxy) Run(parentCtx context.Context) error {
|
|
86
|
+
const maxConnectAttempts = 3
|
|
87
|
+
nAttempts := 0
|
|
88
|
+
|
|
89
|
+
// Track whether or not we have connected successfully.
|
|
90
|
+
// Once we have connected we no longer limit the number
|
|
91
|
+
// of connection attempts that will be made and will retry
|
|
92
|
+
// until the connection is successful or the user terminates
|
|
93
|
+
// the program.
|
|
94
|
+
hasConnectedOnce := false
|
|
95
|
+
canConnect := func() bool {
|
|
96
|
+
if hasConnectedOnce {
|
|
97
|
+
return true
|
|
98
|
+
} else {
|
|
99
|
+
return nAttempts < maxConnectAttempts
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
signalCtx := withSIGTERMCancel(parentCtx, func() {
|
|
104
|
+
log.WithFields(log.Fields{
|
|
105
|
+
"prefix": "proxy.Proxy.Run",
|
|
106
|
+
}).Debug("Ctrl+C received, cleaning up...")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
s := ansi.StartNewSpinner("Getting ready...", p.cfg.Log.Out)
|
|
110
|
+
|
|
111
|
+
session, err := p.createSession(signalCtx)
|
|
112
|
+
if err != nil {
|
|
113
|
+
ansi.StopSpinner(s, "", p.cfg.Log.Out)
|
|
114
|
+
p.cfg.Log.Fatalf("Error while authenticating with Hookdeck: %v", err)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if session.Id == "" {
|
|
118
|
+
ansi.StopSpinner(s, "", p.cfg.Log.Out)
|
|
119
|
+
p.cfg.Log.Fatalf("Error while starting a new session")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Main loop to keep attempting to connect to Hookdeck once
|
|
123
|
+
// we have created a session.
|
|
124
|
+
for canConnect() {
|
|
125
|
+
p.webSocketClient = websocket.NewClient(
|
|
126
|
+
p.cfg.WSBaseURL,
|
|
127
|
+
session.Id,
|
|
128
|
+
p.cfg.Key,
|
|
129
|
+
&websocket.Config{
|
|
130
|
+
Log: p.cfg.Log,
|
|
131
|
+
NoWSS: p.cfg.NoWSS,
|
|
132
|
+
EventHandler: websocket.EventHandlerFunc(p.processAttempt),
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// Monitor the websocket for connection and update the spinner appropriately.
|
|
137
|
+
go func() {
|
|
138
|
+
<-p.webSocketClient.Connected()
|
|
139
|
+
msg := "Ready! (^C to quit)"
|
|
140
|
+
if hasConnectedOnce {
|
|
141
|
+
msg = "Reconnected!"
|
|
142
|
+
}
|
|
143
|
+
ansi.StopSpinner(s, msg, p.cfg.Log.Out)
|
|
144
|
+
hasConnectedOnce = true
|
|
145
|
+
}()
|
|
146
|
+
|
|
147
|
+
// Run the websocket in the background
|
|
148
|
+
go p.webSocketClient.Run(signalCtx)
|
|
149
|
+
nAttempts++
|
|
150
|
+
|
|
151
|
+
// Block until ctrl+c or the websocket connection is interrupted
|
|
152
|
+
select {
|
|
153
|
+
case <-signalCtx.Done():
|
|
154
|
+
ansi.StopSpinner(s, "", p.cfg.Log.Out)
|
|
155
|
+
return nil
|
|
156
|
+
case <-p.webSocketClient.NotifyExpired:
|
|
157
|
+
if canConnect() {
|
|
158
|
+
ansi.StopSpinner(s, "", p.cfg.Log.Out)
|
|
159
|
+
s = ansi.StartNewSpinner("Connection lost, reconnecting...", p.cfg.Log.Out)
|
|
160
|
+
} else {
|
|
161
|
+
p.cfg.Log.Fatalf("Session expired. Terminating after %d failed attempts to reauthorize", nAttempts)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Determine if we should backoff the connection retries.
|
|
166
|
+
attemptsOverMax := math.Max(0, float64(nAttempts-maxConnectAttempts))
|
|
167
|
+
if canConnect() && attemptsOverMax > 0 {
|
|
168
|
+
// Determine the time to wait to reconnect, maximum of 10 second intervals
|
|
169
|
+
sleepDurationMS := int(math.Round(math.Min(100, math.Pow(attemptsOverMax, 2)) * 100))
|
|
170
|
+
log.WithField(
|
|
171
|
+
"prefix", "proxy.Proxy.Run",
|
|
172
|
+
).Debugf(
|
|
173
|
+
"Connect backoff (%dms)", sleepDurationMS,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Reset the timer to the next duration
|
|
177
|
+
p.connectionTimer.Stop()
|
|
178
|
+
p.connectionTimer.Reset(time.Duration(sleepDurationMS) * time.Millisecond)
|
|
179
|
+
|
|
180
|
+
// Block until the timer completes or we get interrupted by the user
|
|
181
|
+
select {
|
|
182
|
+
case <-p.connectionTimer.C:
|
|
183
|
+
case <-signalCtx.Done():
|
|
184
|
+
p.connectionTimer.Stop()
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if p.webSocketClient != nil {
|
|
191
|
+
p.webSocketClient.Stop()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
log.WithFields(log.Fields{
|
|
195
|
+
"prefix": "proxy.Proxy.Run",
|
|
196
|
+
}).Debug("Bye!")
|
|
197
|
+
|
|
198
|
+
return nil
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func (p *Proxy) createSession(ctx context.Context) (hookdeck.Session, error) {
|
|
202
|
+
var session hookdeck.Session
|
|
203
|
+
|
|
204
|
+
parsedBaseURL, err := url.Parse(p.cfg.APIBaseURL)
|
|
205
|
+
if err != nil {
|
|
206
|
+
return session, err
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
client := &hookdeck.Client{
|
|
210
|
+
BaseURL: parsedBaseURL,
|
|
211
|
+
APIKey: p.cfg.Key,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
var connection_ids []string
|
|
215
|
+
for _, connection := range p.connections {
|
|
216
|
+
connection_ids = append(connection_ids, connection.Id)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for i := 0; i <= 5; i++ {
|
|
220
|
+
session, err = client.CreateSession(hookdeck.CreateSessionInput{SourceId: p.source.Id,
|
|
221
|
+
ConnectionIds: connection_ids})
|
|
222
|
+
|
|
223
|
+
if err == nil {
|
|
224
|
+
return session, nil
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
select {
|
|
228
|
+
case <-ctx.Done():
|
|
229
|
+
return session, errors.New("canceled by context")
|
|
230
|
+
case <-time.After(1 * time.Second):
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return session, err
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
|
|
238
|
+
if msg.Attempt == nil {
|
|
239
|
+
p.cfg.Log.Debug("WebSocket specified for Webhooks received non-webhook event")
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
webhookEvent := msg.Attempt
|
|
244
|
+
|
|
245
|
+
p.cfg.Log.WithFields(log.Fields{
|
|
246
|
+
"prefix": "proxy.Proxy.processAttempt",
|
|
247
|
+
}).Debugf("Processing webhook event")
|
|
248
|
+
|
|
249
|
+
if p.cfg.PrintJSON {
|
|
250
|
+
fmt.Println(webhookEvent.Body.Request.DataString)
|
|
251
|
+
} else {
|
|
252
|
+
url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path
|
|
253
|
+
tr := &http.Transport{
|
|
254
|
+
TLSClientConfig: &tls.Config{InsecureSkipVerify: p.cfg.Insecure},
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
timeout := webhookEvent.Body.Request.Timeout
|
|
258
|
+
if timeout == 0 {
|
|
259
|
+
timeout = 1000 * 30
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
client := &http.Client{
|
|
263
|
+
Timeout: time.Duration(timeout) * time.Millisecond,
|
|
264
|
+
Transport: tr,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
req, err := http.NewRequest(webhookEvent.Body.Request.Method, url, nil)
|
|
268
|
+
if err != nil {
|
|
269
|
+
fmt.Printf("Error: %s\n", err)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
x := make(map[string]json.RawMessage)
|
|
273
|
+
err = json.Unmarshal(webhookEvent.Body.Request.Headers, &x)
|
|
274
|
+
if err != nil {
|
|
275
|
+
fmt.Printf("Error: %s\n", err)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for key, value := range x {
|
|
280
|
+
unquoted_value, _ := strconv.Unquote(string(value))
|
|
281
|
+
req.Header.Set(key, unquoted_value)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString))
|
|
285
|
+
req.ContentLength = int64(len(webhookEvent.Body.Request.DataString))
|
|
286
|
+
|
|
287
|
+
res, err := client.Do(req)
|
|
288
|
+
|
|
289
|
+
if err != nil {
|
|
290
|
+
color := ansi.Color(os.Stdout)
|
|
291
|
+
localTime := time.Now().Format(timeLayout)
|
|
292
|
+
|
|
293
|
+
errStr := fmt.Sprintf("%s [%s] Failed to %s: %v",
|
|
294
|
+
color.Faint(localTime),
|
|
295
|
+
color.Red("ERROR"),
|
|
296
|
+
webhookEvent.Body.Request.Method,
|
|
297
|
+
err,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
fmt.Println(errStr)
|
|
301
|
+
p.webSocketClient.SendMessage(&websocket.OutgoingMessage{
|
|
302
|
+
ErrorAttemptResponse: &websocket.ErrorAttemptResponse{
|
|
303
|
+
Event: "attempt_response",
|
|
304
|
+
Body: websocket.ErrorAttemptBody{
|
|
305
|
+
AttemptId: webhookEvent.Body.AttemptId,
|
|
306
|
+
Error: true,
|
|
307
|
+
},
|
|
308
|
+
}})
|
|
309
|
+
} else {
|
|
310
|
+
p.processEndpointResponse(webhookEvent, res)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
func (p *Proxy) processEndpointResponse(webhookEvent *websocket.Attempt, resp *http.Response) {
|
|
316
|
+
localTime := time.Now().Format(timeLayout)
|
|
317
|
+
color := ansi.Color(os.Stdout)
|
|
318
|
+
var url = p.cfg.DashboardBaseURL + "/cli/events/" + webhookEvent.Body.EventID
|
|
319
|
+
if p.cfg.Profile.GetTeamMode() == "console" {
|
|
320
|
+
url = p.cfg.ConsoleBaseURL + "/?event_id=" + webhookEvent.Body.EventID
|
|
321
|
+
}
|
|
322
|
+
outputStr := fmt.Sprintf("%s [%d] %s %s | %s",
|
|
323
|
+
color.Faint(localTime),
|
|
324
|
+
ansi.ColorizeStatus(resp.StatusCode),
|
|
325
|
+
resp.Request.Method,
|
|
326
|
+
resp.Request.URL,
|
|
327
|
+
url,
|
|
328
|
+
)
|
|
329
|
+
fmt.Println(outputStr)
|
|
330
|
+
|
|
331
|
+
buf, err := ioutil.ReadAll(resp.Body)
|
|
332
|
+
if err != nil {
|
|
333
|
+
errStr := fmt.Sprintf("%s [%s] Failed to read response from endpoint, error = %v\n",
|
|
334
|
+
color.Faint(localTime),
|
|
335
|
+
color.Red("ERROR"),
|
|
336
|
+
err,
|
|
337
|
+
)
|
|
338
|
+
log.Errorf(errStr)
|
|
339
|
+
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// body := truncate(string(buf), 5000, true)
|
|
344
|
+
|
|
345
|
+
if p.webSocketClient != nil {
|
|
346
|
+
p.webSocketClient.SendMessage(&websocket.OutgoingMessage{
|
|
347
|
+
AttemptResponse: &websocket.AttemptResponse{
|
|
348
|
+
Event: "attempt_response",
|
|
349
|
+
Body: websocket.AttemptResponseBody{
|
|
350
|
+
AttemptId: webhookEvent.Body.AttemptId,
|
|
351
|
+
CLIPath: webhookEvent.Body.Path,
|
|
352
|
+
Status: resp.StatusCode,
|
|
353
|
+
Data: string(buf),
|
|
354
|
+
},
|
|
355
|
+
}})
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
//
|
|
360
|
+
// Public functions
|
|
361
|
+
//
|
|
362
|
+
|
|
363
|
+
// New creates a new Proxy
|
|
364
|
+
func New(cfg *Config, source hookdeck.Source, connections []hookdeck.Connection) *Proxy {
|
|
365
|
+
if cfg.Log == nil {
|
|
366
|
+
cfg.Log = &log.Logger{Out: ioutil.Discard}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
connections_paths := make(map[string]string)
|
|
370
|
+
|
|
371
|
+
for _, connection := range connections {
|
|
372
|
+
connections_paths[connection.Id] = connection.Destination.CliPath
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
p := &Proxy{
|
|
376
|
+
cfg: cfg,
|
|
377
|
+
connections: connections,
|
|
378
|
+
connections_paths: connections_paths,
|
|
379
|
+
source: source,
|
|
380
|
+
connectionTimer: time.NewTimer(0), // Defaults to no delay
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return p
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
//
|
|
387
|
+
// Private constants
|
|
388
|
+
//
|
|
389
|
+
|
|
390
|
+
const (
|
|
391
|
+
maxBodySize = 5000
|
|
392
|
+
maxNumHeaders = 20
|
|
393
|
+
maxHeaderKeySize = 50
|
|
394
|
+
maxHeaderValueSize = 200
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
//
|
|
398
|
+
// Private functions
|
|
399
|
+
//
|
|
400
|
+
|
|
401
|
+
// truncate will truncate str to be less than or equal to maxByteLength bytes.
|
|
402
|
+
// It will respect UTF8 and truncate the string at a code point boundary.
|
|
403
|
+
// If ellipsis is true, we'll append "..." to the truncated string if the string
|
|
404
|
+
// was in fact truncated, and if there's enough room. Note that the
|
|
405
|
+
// full string returned will always be <= maxByteLength bytes long, even with ellipsis.
|
|
406
|
+
func truncate(str string, maxByteLength int, ellipsis bool) string {
|
|
407
|
+
if len(str) <= maxByteLength {
|
|
408
|
+
return str
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
bytes := []byte(str)
|
|
412
|
+
|
|
413
|
+
if ellipsis && maxByteLength > 3 {
|
|
414
|
+
maxByteLength -= 3
|
|
415
|
+
} else {
|
|
416
|
+
ellipsis = false
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for maxByteLength > 0 && maxByteLength < len(bytes) && isUTF8ContinuationByte(bytes[maxByteLength]) {
|
|
420
|
+
maxByteLength--
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
result := string(bytes[0:maxByteLength])
|
|
424
|
+
if ellipsis {
|
|
425
|
+
result += "..."
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return result
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func isUTF8ContinuationByte(b byte) bool {
|
|
432
|
+
return (b & 0xC0) == 0x80
|
|
433
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// +build !windows
|
|
2
|
+
|
|
3
|
+
package useragent
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bytes"
|
|
7
|
+
"fmt"
|
|
8
|
+
|
|
9
|
+
"golang.org/x/sys/unix"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func trimNulls(input []byte) []byte {
|
|
13
|
+
return bytes.Trim(input, "\x00")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func getUname() string {
|
|
17
|
+
u := new(unix.Utsname)
|
|
18
|
+
|
|
19
|
+
err := unix.Uname(u)
|
|
20
|
+
if err != nil {
|
|
21
|
+
panic(err)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return fmt.Sprintf("%s %s %s %s %s", trimNulls(u.Sysname[:]), trimNulls(u.Nodename[:]), trimNulls(u.Release[:]), trimNulls(u.Version[:]), trimNulls(u.Machine[:]))
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// +build !windows
|
|
2
|
+
|
|
3
|
+
package useragent
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"fmt"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"github.com/stretchr/testify/require"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestUnameNotEmpty(t *testing.T) {
|
|
13
|
+
u := getUname()
|
|
14
|
+
t.Log(fmt.Sprintf("%x", u)) // For NULL trim paranoia
|
|
15
|
+
require.NotEmpty(t, u)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func TestTrimNulls(t *testing.T) {
|
|
19
|
+
input := [256]byte{0xff}
|
|
20
|
+
t.Log(input)
|
|
21
|
+
output := trimNulls(input[:])
|
|
22
|
+
t.Log(output)
|
|
23
|
+
require.NotEqual(t, len(input), len(output))
|
|
24
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package useragent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"runtime"
|
|
6
|
+
|
|
7
|
+
"github.com/hookdeck/hookdeck-cli/pkg/version"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
//
|
|
11
|
+
// Public functions
|
|
12
|
+
//
|
|
13
|
+
|
|
14
|
+
// GetEncodedHookdeckUserAgent returns the string to be used as the value for
|
|
15
|
+
// the `X-Hookdeck-Client-User-Agent` HTTP header.
|
|
16
|
+
func GetEncodedHookdeckUserAgent() string {
|
|
17
|
+
return encodedHookdeckUserAgent
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// GetEncodedUserAgent returns the string to be used as the value for
|
|
21
|
+
// the `User-Agent` HTTP header.
|
|
22
|
+
func GetEncodedUserAgent() string {
|
|
23
|
+
return encodedUserAgent
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//
|
|
27
|
+
// Private types
|
|
28
|
+
//
|
|
29
|
+
|
|
30
|
+
// hookdeckClientUserAgent contains information about the current runtime which
|
|
31
|
+
// is serialized and sent in the `X-Hookdeck-Client-User-Agent` as additional
|
|
32
|
+
// debugging information.
|
|
33
|
+
type hookdeckClientUserAgent struct {
|
|
34
|
+
Name string `json:"name"`
|
|
35
|
+
OS string `json:"os"`
|
|
36
|
+
Publisher string `json:"publisher"`
|
|
37
|
+
Uname string `json:"uname"`
|
|
38
|
+
Version string `json:"version"`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//
|
|
42
|
+
// Private variables
|
|
43
|
+
//
|
|
44
|
+
|
|
45
|
+
var encodedHookdeckUserAgent string
|
|
46
|
+
var encodedUserAgent string
|
|
47
|
+
|
|
48
|
+
//
|
|
49
|
+
// Private functions
|
|
50
|
+
//
|
|
51
|
+
|
|
52
|
+
func init() {
|
|
53
|
+
initUserAgent()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func initUserAgent() {
|
|
57
|
+
encodedUserAgent = "Hookdeck/v1 hookdeck-cli/" + version.Version
|
|
58
|
+
|
|
59
|
+
hookdeckUserAgent := &hookdeckClientUserAgent{
|
|
60
|
+
Name: "hookdeck-cli",
|
|
61
|
+
Version: version.Version,
|
|
62
|
+
Publisher: "hookdeck",
|
|
63
|
+
OS: runtime.GOOS,
|
|
64
|
+
Uname: getUname(),
|
|
65
|
+
}
|
|
66
|
+
marshaled, err := json.Marshal(hookdeckUserAgent)
|
|
67
|
+
// Encoding this struct should never be a problem, so we're okay to panic
|
|
68
|
+
// in case it is for some reason.
|
|
69
|
+
if err != nil {
|
|
70
|
+
panic(err)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
encodedHookdeckUserAgent = string(marshaled)
|
|
74
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package validators
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"github.com/spf13/cobra"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// NoArgs is a validator for commands to print an error when an argument is provided
|
|
11
|
+
func NoArgs(cmd *cobra.Command, args []string) error {
|
|
12
|
+
errorMessage := fmt.Sprintf(
|
|
13
|
+
"`%s` does not take any positional arguments. See `%s --help` for supported flags and usage",
|
|
14
|
+
cmd.CommandPath(),
|
|
15
|
+
cmd.CommandPath(),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if len(args) > 0 {
|
|
19
|
+
return errors.New(errorMessage)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return nil
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ExactArgs is a validator for commands to print an error when the number provided
|
|
26
|
+
// is different than the arguments passed in
|
|
27
|
+
func ExactArgs(num int) cobra.PositionalArgs {
|
|
28
|
+
return func(cmd *cobra.Command, args []string) error {
|
|
29
|
+
argument := "positional argument"
|
|
30
|
+
if num != 1 {
|
|
31
|
+
argument = "positional arguments"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
errorMessage := fmt.Sprintf(
|
|
35
|
+
"`%s` requires exactly %d %s. See `%s --help` for supported flags and usage",
|
|
36
|
+
cmd.CommandPath(),
|
|
37
|
+
num,
|
|
38
|
+
argument,
|
|
39
|
+
cmd.CommandPath(),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if len(args) != num {
|
|
43
|
+
return errors.New(errorMessage)
|
|
44
|
+
}
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MaximumNArgs is a validator for commands to print an error when the provided
|
|
50
|
+
// args are greater than the maximum amount
|
|
51
|
+
func MaximumNArgs(num int) cobra.PositionalArgs {
|
|
52
|
+
return func(cmd *cobra.Command, args []string) error {
|
|
53
|
+
argument := "positional argument"
|
|
54
|
+
if num > 1 {
|
|
55
|
+
argument = "positional arguments"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
errorMessage := fmt.Sprintf(
|
|
59
|
+
"`%s` accepts at maximum %d %s. See `%s --help` for supported flags and usage",
|
|
60
|
+
cmd.CommandPath(),
|
|
61
|
+
num,
|
|
62
|
+
argument,
|
|
63
|
+
cmd.CommandPath(),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if len(args) > num {
|
|
67
|
+
return errors.New(errorMessage)
|
|
68
|
+
}
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
}
|