From 653dd9032398e24e665b38ed64e39e274dbf16ab Mon Sep 17 00:00:00 2001 From: Sunil Mehta Date: Sun, 25 Oct 2026 11:09:08 +0530 Subject: [PATCH] webhook: fail fast without an ngrok authtoken, report connection failures, bound the connect A blank prompt with NGROK_AUTHTOKEN unset went to ngrok.WithAuthtokenFromEnv() with an empty token. The SDK treats the authentication failure as temporary and retries it forever, so `xurl webhook start` sat in connection setup with no tunnel URL and no error until it was killed. - Resolve the token up front (prompt, then NGROK_AUTHTOKEN) and exit 1 with a message that says how to supply one when there is none. - Print each connection failure through ngrok.WithDisconnectHandler, so an invalid token shows its ERR_NGROK_105 instead of nothing. - Add --connect-timeout (default 30s): the context is cancelled if the tunnel is not up in time, which makes ngrok.Listen return an error naming the last failure. The timer stops once the tunnel is up, so reconnects are unaffected. Fixes #107 --- CHANGELOG.md | 4 +++ cli/webhook.go | 71 +++++++++++++++++++++++++++++++++++++-------- cli/webhook_test.go | 48 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 cli/webhook_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 06cf6da..ab2ced8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All user-visible bugs and enhancements should be recorded here. ## Unreleased +### Fixed + +- `xurl webhook start` no longer hangs when no ngrok authtoken is available. A blank prompt with `NGROK_AUTHTOKEN` unset is an error that says how to supply one, every failed connection attempt is printed (the ngrok SDK retries most failures, an invalid authtoken among them, without reporting them), and a new `--connect-timeout` (default 30s) turns a tunnel that never comes up into an error instead of an endless wait. + ## v1.3.4 - 2026-09-29 ### Fixed diff --git a/cli/webhook.go b/cli/webhook.go index f222593..9227a5d 100644 --- a/cli/webhook.go +++ b/cli/webhook.go @@ -7,12 +7,14 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log" "net/http" "os" "strings" + "time" "github.com/xdevplatform/xurl/auth" @@ -27,6 +29,23 @@ var webhookPort int var outputFileName string // To store the output file name from the flag var quietMode bool // To store the quiet flag state var prettyMode bool // To store the pretty-print flag state +var ngrokConnectTimeout time.Duration + +// ngrokAuthtokenEnv is the variable the ngrok SDK reads an authtoken from. +const ngrokAuthtokenEnv = "NGROK_AUTHTOKEN" + +// resolveNgrokAuthtoken picks the ngrok authtoken to use: what was typed at the prompt, or else the +// environment variable. An empty result is an error, because the ngrok SDK retries an empty token +// forever without surfacing the authentication failure, and the command would hang. +func resolveNgrokAuthtoken(input string, lookupEnv func(string) (string, bool)) (string, error) { + if token := strings.TrimSpace(input); token != "" { + return token, nil + } + if token, ok := lookupEnv(ngrokAuthtokenEnv); ok && strings.TrimSpace(token) != "" { + return strings.TrimSpace(token), nil + } + return "", fmt.Errorf("no ngrok authtoken: enter one at the prompt or set %s (get it from https://dashboard.ngrok.com/get-started/your-authtoken)", ngrokAuthtokenEnv) +} // CreateWebhookCommand creates the webhook command and its subcommands. func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { @@ -69,18 +88,40 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { } // Prompt for ngrok authtoken - color.Yellow("Enter your ngrok authtoken (leave empty to try NGROK_AUTHTOKEN env var): ") + color.Yellow("Enter your ngrok authtoken (leave empty to use the %s env var): ", ngrokAuthtokenEnv) reader := bufio.NewReader(os.Stdin) - ngrokAuthToken, _ := reader.ReadString('\n') - ngrokAuthToken = strings.TrimSpace(ngrokAuthToken) - - ctx := context.Background() - var tunnelOpts []ngrok.ConnectOption - if ngrokAuthToken != "" { - tunnelOpts = append(tunnelOpts, ngrok.WithAuthtoken(ngrokAuthToken)) - } else { - color.Cyan("Attempting to use NGROK_AUTHTOKEN environment variable for ngrok authentication.") - tunnelOpts = append(tunnelOpts, ngrok.WithAuthtokenFromEnv()) // Fallback to env + input, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + color.Red("Error reading the ngrok authtoken: %v", err) + os.Exit(1) + } + ngrokAuthToken, err := resolveNgrokAuthtoken(input, os.LookupEnv) + if err != nil { + color.Red("Error: %v", err) + os.Exit(1) + } + if strings.TrimSpace(input) == "" { + color.Cyan("Using the ngrok authtoken from %s.", ngrokAuthtokenEnv) + } + + // The context outlives the connection so the session can reconnect later; the timer only + // cancels it if the tunnel is not up in time, which turns an endless retry into an error. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + connectTimer := time.AfterFunc(ngrokConnectTimeout, cancel) + var lastConnectErr error + tunnelOpts := []ngrok.ConnectOption{ + ngrok.WithAuthtoken(ngrokAuthToken), + // The SDK retries most failures, an invalid authtoken among them, without reporting + // them. The handler prints each one so a failing connection is never silent. + ngrok.WithDisconnectHandler(func(ctx context.Context, sess ngrok.Session, err error) { + // The cancellation itself arrives here too, after the real failures. + if err == nil || errors.Is(err, context.Canceled) { + return + } + lastConnectErr = err + color.Red("ngrok: %v", err) + }), } forwardToAddr := fmt.Sprintf("localhost:%d", webhookPort) @@ -92,8 +133,13 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { ), tunnelOpts..., ) + connectTimer.Stop() if err != nil { - color.Red("Error starting ngrok tunnel: %v", err) + if ctx.Err() != nil && lastConnectErr != nil { + color.Red("Error starting ngrok tunnel: not connected after %s; the last error was: %v", ngrokConnectTimeout, lastConnectErr) + } else { + color.Red("Error starting ngrok tunnel: %v", err) + } os.Exit(1) } defer ngrokListener.Close() @@ -189,6 +235,7 @@ func CreateWebhookCommand(authInstance *auth.Auth) *cobra.Command { webhookStartCmd.Flags().StringVarP(&outputFileName, "output", "o", "", "File to write incoming POST request bodies to") webhookStartCmd.Flags().BoolVarP(&quietMode, "quiet", "q", false, "Enable quiet mode (logs only that a POST event was received, not the full body to console)") webhookStartCmd.Flags().BoolVarP(&prettyMode, "pretty", "P", false, "Pretty-print JSON POST bodies in console output (ignored if -q is used)") + webhookStartCmd.Flags().DurationVar(&ngrokConnectTimeout, "connect-timeout", 30*time.Second, "Give up if the ngrok tunnel is not established within this time") webhookCmd.AddCommand(webhookStartCmd) return webhookCmd diff --git a/cli/webhook_test.go b/cli/webhook_test.go new file mode 100644 index 0000000..d20cefd --- /dev/null +++ b/cli/webhook_test.go @@ -0,0 +1,48 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestResolveNgrokAuthtoken(t *testing.T) { + env := func(values map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + value, ok := values[key] + return value, ok + } + } + tests := []struct { + name string + input string + env map[string]string + want string + wantErr bool + }{ + {name: "typed token wins", input: "typed\n", env: map[string]string{ngrokAuthtokenEnv: "from-env"}, want: "typed"}, + {name: "blank prompt falls back to the environment", input: "\n", env: map[string]string{ngrokAuthtokenEnv: "from-env"}, want: "from-env"}, + {name: "surrounding whitespace is trimmed", input: " \t\n", env: map[string]string{ngrokAuthtokenEnv: " from-env \n"}, want: "from-env"}, + {name: "blank prompt and no variable is an error", input: "\n", env: map[string]string{}, wantErr: true}, + {name: "blank prompt and an empty variable is an error", input: "", env: map[string]string{ngrokAuthtokenEnv: ""}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveNgrokAuthtoken(tt.input, env(tt.env)) + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got token %q", got) + } + if !strings.Contains(err.Error(), ngrokAuthtokenEnv) { + t.Fatalf("the error should say how to supply a token, got: %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +}