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) + } + }) + } +}