Skip to content
Merged
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ hookdeck logout

When forwarding events to an HTTPS URL as the first argument to `hookdeck listen` (e.g., `https://localhost:1234/webhook`), you might encounter SSL validation errors if the destination is using a self-signed certificate.

For local development scenarios, you can instruct the `listen` command to bypass this SSL certificate validation by using its `--insecure` flag. You must provide the full HTTPS URL.
For local development scenarios, you can instruct the `listen` command to bypass this SSL certificate validation by using its `--insecure` flag. You must provide the full HTTPS URL. This flag also applies to the periodic server health checks that the CLI performs.

**This is dangerous and should only be used in trusted local development environments for destinations you control.**

Expand All @@ -404,6 +404,14 @@ Example of skipping SSL validation for an HTTPS destination:
hookdeck listen --insecure https://<your-ssl-url-or-url:port>/ <source-alias?> <connection-query?>
```

### Disable health checks

The CLI periodically checks if your local server is reachable and displays warnings if the connection fails. If these health checks cause issues in your environment, you can disable them with the `--no-healthcheck` flag:

```sh
hookdeck listen --no-healthcheck 3000 <source-alias?>
```

### Version

Print your CLI version and whether or not a new version is available.
Expand Down
4 changes: 4 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ hookdeck project use --profile production
[source] # Optional positional argument for source name
[connection] # Optional positional argument for connection name
--path string # Specific path to forward to (e.g., "/webhooks")
--no-healthcheck # Disable periodic health checks of the local server
--no-wss # Force unencrypted WebSocket connection (hidden flag)
```

Expand All @@ -206,6 +207,9 @@ hookdeck listen 3000 stripe-webhooks payment-connection
# Forward to specific path
hookdeck listen --path /webhooks

# Disable periodic health checks of the local server
hookdeck listen --no-healthcheck 3000

# Force unencrypted WebSocket connection (hidden flag)
hookdeck listen --no-wss

Expand Down
4 changes: 4 additions & 0 deletions pkg/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
type listenCmd struct {
cmd *cobra.Command
noWSS bool
noHealthcheck bool
path string
maxConnections int
output string
Expand Down Expand Up @@ -155,6 +156,8 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,

lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only)")

lc.cmd.Flags().BoolVar(&lc.noHealthcheck, "no-healthcheck", false, "Disable periodic health checks of the local server")

lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)")
lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)")
lc.cmd.Flags().StringVar(&lc.filterQuery, "filter-query", "", "Filter events by query parameters using Hookdeck filter syntax (JSON)")
Expand Down Expand Up @@ -255,6 +258,7 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error {

return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{
NoWSS: lc.noWSS,
NoHealthcheck: lc.noHealthcheck,
Path: lc.path,
Output: lc.output,
MaxConnections: lc.maxConnections,
Expand Down
7 changes: 4 additions & 3 deletions pkg/listen/healthcheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ const (
HealthUnreachable = healthcheck.HealthUnreachable
)

// CheckServerHealth performs a TCP connection check to the target URL
// CheckServerHealth performs a connection check to the target URL
// For HTTPS URLs, it performs a TLS handshake with optional certificate verification skip.
// This is a wrapper around the healthcheck package function for backward compatibility
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
return healthcheck.CheckServerHealth(targetURL, timeout)
func CheckServerHealth(targetURL *url.URL, timeout time.Duration, insecure bool) HealthCheckResult {
return healthcheck.CheckServerHealth(targetURL, timeout, insecure)
}

// FormatHealthMessage creates a user-friendly health status message
Expand Down
26 changes: 23 additions & 3 deletions pkg/listen/healthcheck/healthcheck.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package healthcheck

import (
"crypto/tls"
"fmt"
"net"
"net/url"
Expand All @@ -27,11 +28,14 @@ type HealthCheckResult struct {
Duration time.Duration
}

// CheckServerHealth performs a TCP connection check to verify a server is listening.
// CheckServerHealth performs a connection check to verify a server is listening.
// For HTTPS URLs, it performs a TLS handshake to avoid incomplete handshake warnings
// on the server side. The insecure parameter controls whether to skip TLS certificate
// verification (matching the --insecure flag behavior for webhook forwarding).
// The timeout parameter should be appropriate for the deployment context:
// - Local development: 3s is typically sufficient
// - Production/edge: May require longer timeouts due to network conditions
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
func CheckServerHealth(targetURL *url.URL, timeout time.Duration, insecure bool) HealthCheckResult {
start := time.Now()

host := targetURL.Hostname()
Expand All @@ -48,7 +52,23 @@ func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckRes

address := net.JoinHostPort(host, port)

conn, err := net.DialTimeout("tcp", address, timeout)
var conn net.Conn
var err error

if targetURL.Scheme == "https" {
// Use TLS connection for HTTPS endpoints to complete handshake properly
// and avoid TLS handshake warnings on the server
dialer := &net.Dialer{Timeout: timeout}
tlsConfig := &tls.Config{
InsecureSkipVerify: insecure,
ServerName: host,
}
conn, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
} else {
// Use plain TCP for HTTP endpoints
conn, err = net.DialTimeout("tcp", address, timeout)
}

duration := time.Since(start)

result := HealthCheckResult{
Expand Down
124 changes: 118 additions & 6 deletions pkg/listen/healthcheck/healthcheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ func TestCheckServerHealth_HealthyServer(t *testing.T) {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Perform health check
result := CheckServerHealth(serverURL, 3*time.Second)
// Perform health check (insecure=false, not relevant for HTTP)
result := CheckServerHealth(serverURL, 3*time.Second, false)

// Verify result
if !result.Healthy {
Expand All @@ -50,7 +50,7 @@ func TestCheckServerHealth_UnreachableServer(t *testing.T) {
}

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)
result := CheckServerHealth(targetURL, 1*time.Second, false)

// Verify result
if result.Healthy {
Expand Down Expand Up @@ -101,8 +101,8 @@ func TestCheckServerHealth_DefaultPorts(t *testing.T) {
}
defer listener.Close()

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)
// Perform health check (insecure=true to handle self-signed certs in test)
result := CheckServerHealth(targetURL, 1*time.Second, true)

// Should be healthy since we have a listener
if !result.Healthy {
Expand Down Expand Up @@ -185,7 +185,7 @@ func TestCheckServerHealth_PortInURL(t *testing.T) {
targetURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d/path", addr.Port))

// Perform health check
result := CheckServerHealth(targetURL, 3*time.Second)
result := CheckServerHealth(targetURL, 3*time.Second, false)

// Verify that the health check succeeded
// This confirms that when a port is already in the URL, we don't append
Expand All @@ -197,3 +197,115 @@ func TestCheckServerHealth_PortInURL(t *testing.T) {
t.Errorf("Expected no error for server with port in URL, got: %v", result.Error)
}
}

func TestCheckServerHealth_HTTPS_SelfSigned_InsecureTrue(t *testing.T) {
// Start a test HTTPS server with self-signed certificate
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

// Parse server URL (will be https://...)
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Verify it's HTTPS
if serverURL.Scheme != "https" {
t.Fatalf("Expected HTTPS scheme, got: %s", serverURL.Scheme)
}

// Perform health check with insecure=true (should succeed)
result := CheckServerHealth(serverURL, 3*time.Second, true)

// Should be healthy because we skip certificate verification
if !result.Healthy {
t.Errorf("Expected server to be healthy with insecure=true, got unhealthy: %v", result.Error)
}
if result.Status != HealthHealthy {
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
}
if result.Error != nil {
t.Errorf("Expected no error with insecure=true, got: %v", result.Error)
}
}

func TestCheckServerHealth_HTTPS_SelfSigned_InsecureFalse(t *testing.T) {
// Start a test HTTPS server with self-signed certificate
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

// Parse server URL (will be https://...)
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Perform health check with insecure=false (should fail due to self-signed cert)
result := CheckServerHealth(serverURL, 3*time.Second, false)

// Should be unhealthy because certificate verification fails
if result.Healthy {
t.Errorf("Expected server to be unhealthy with insecure=false on self-signed cert, got healthy")
}
if result.Status != HealthUnreachable {
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
}
if result.Error == nil {
t.Errorf("Expected certificate error, got nil")
}
// Verify it's a certificate-related error
if result.Error != nil && !strings.Contains(result.Error.Error(), "certificate") {
t.Logf("Error message: %v (may vary by platform)", result.Error)
}
}

func TestCheckServerHealth_HTTPS_UsesTLSHandshake(t *testing.T) {
// This test verifies that HTTPS URLs use TLS dial (not raw TCP)
// by using httptest.NewTLSServer which creates a proper TLS server
// and checking that the health check completes successfully

// Start a test HTTPS server - this will only succeed if TLS handshake completes
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Verify it's HTTPS
if serverURL.Scheme != "https" {
t.Fatalf("Expected HTTPS scheme, got: %s", serverURL.Scheme)
}

// Perform health check with insecure=true (to accept self-signed cert)
// If this succeeds, it proves TLS handshake was performed (not just TCP connect)
result := CheckServerHealth(serverURL, 3*time.Second, true)

// Should be healthy - this proves TLS handshake succeeded
if !result.Healthy {
t.Errorf("Expected healthy result for HTTPS server (TLS handshake should succeed), got: %v", result.Error)
}

// Verify that for HTTP URLs, we still use TCP (not TLS)
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer httpServer.Close()

httpURL, _ := url.Parse(httpServer.URL)
if httpURL.Scheme != "http" {
t.Fatalf("Expected HTTP scheme, got: %s", httpURL.Scheme)
}

httpResult := CheckServerHealth(httpURL, 3*time.Second, false)
if !httpResult.Healthy {
t.Errorf("Expected healthy result for HTTP server, got: %v", httpResult.Error)
}
}
42 changes: 23 additions & 19 deletions pkg/listen/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (

type Flags struct {
NoWSS bool
NoHealthcheck bool
Path string
MaxConnections int
Output string
Expand Down Expand Up @@ -123,28 +124,30 @@ Specify a single destination to update the path. For example, pass a connection
return err
}

// Perform initial health check on target server
// Perform initial health check on target server (unless disabled)
// Using 3-second timeout optimized for local development scenarios.
// This assumes low latency to localhost. For production/edge deployments,
// this timeout may need to be configurable in future iterations.
healthCheckTimeout := 3 * time.Second
healthResult := CheckServerHealth(URL, healthCheckTimeout)

// For all output modes, warn if server isn't reachable
if !healthResult.Healthy {
warningMsg := FormatHealthMessage(healthResult, URL)

if flags.Output == "interactive" {
// Interactive mode will show warning before TUI starts
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
} else {
// Compact/quiet modes: print warning before connection info
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
if !flags.NoHealthcheck {
healthCheckTimeout := 3 * time.Second
healthResult := CheckServerHealth(URL, healthCheckTimeout, config.Insecure)

// For all output modes, warn if server isn't reachable
if !healthResult.Healthy {
warningMsg := FormatHealthMessage(healthResult, URL)

if flags.Output == "interactive" {
// Interactive mode will show warning before TUI starts
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
} else {
// Compact/quiet modes: print warning before connection info
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
}
}
}

Expand All @@ -168,6 +171,7 @@ Specify a single destination to update the path. For example, pass a connection
ConsoleBaseURL: config.ConsoleBaseURL,
WSBaseURL: config.WSBaseURL,
NoWSS: flags.NoWSS,
NoHealthcheck: flags.NoHealthcheck,
URL: URL,
Log: log.StandardLogger(),
Insecure: config.Insecure,
Expand Down
Loading