Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 59 additions & 12 deletions cli/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions cli/webhook_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}