Skip to content

Commit 4a5368c

Browse files
authored
Merge pull request #199 from hookdeck/fix/healthcheck-https
fix: Use TLS dial for HTTPS healthchecks to prevent handshake warnings
2 parents 0e4c8a2 + 5a8e82a commit 4a5368c

8 files changed

Lines changed: 203 additions & 42 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ hookdeck logout
394394

395395
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.
396396

397-
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.
397+
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.
398398

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

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

407+
### Disable health checks
408+
409+
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:
410+
411+
```sh
412+
hookdeck listen --no-healthcheck 3000 <source-alias?>
413+
```
414+
407415
### Version
408416

409417
Print your CLI version and whether or not a new version is available.

REFERENCE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ hookdeck project use --profile production
186186
[source] # Optional positional argument for source name
187187
[connection] # Optional positional argument for connection name
188188
--path string # Specific path to forward to (e.g., "/webhooks")
189+
--no-healthcheck # Disable periodic health checks of the local server
189190
--no-wss # Force unencrypted WebSocket connection (hidden flag)
190191
```
191192

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

210+
# Disable periodic health checks of the local server
211+
hookdeck listen --no-healthcheck 3000
212+
209213
# Force unencrypted WebSocket connection (hidden flag)
210214
hookdeck listen --no-wss
211215

pkg/cmd/listen.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
type listenCmd struct {
3333
cmd *cobra.Command
3434
noWSS bool
35+
noHealthcheck bool
3536
path string
3637
maxConnections int
3738
output string
@@ -155,6 +156,8 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,
155156

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

159+
lc.cmd.Flags().BoolVar(&lc.noHealthcheck, "no-healthcheck", false, "Disable periodic health checks of the local server")
160+
158161
lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)")
159162
lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)")
160163
lc.cmd.Flags().StringVar(&lc.filterQuery, "filter-query", "", "Filter events by query parameters using Hookdeck filter syntax (JSON)")
@@ -255,6 +258,7 @@ func (lc *listenCmd) runListenCmd(cmd *cobra.Command, args []string) error {
255258

256259
return listen.Listen(url, sourceQuery, connectionQuery, listen.Flags{
257260
NoWSS: lc.noWSS,
261+
NoHealthcheck: lc.noHealthcheck,
258262
Path: lc.path,
259263
Output: lc.output,
260264
MaxConnections: lc.maxConnections,

pkg/listen/healthcheck.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@ const (
1616
HealthUnreachable = healthcheck.HealthUnreachable
1717
)
1818

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

2526
// FormatHealthMessage creates a user-friendly health status message

pkg/listen/healthcheck/healthcheck.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package healthcheck
22

33
import (
4+
"crypto/tls"
45
"fmt"
56
"net"
67
"net/url"
@@ -27,11 +28,14 @@ type HealthCheckResult struct {
2728
Duration time.Duration
2829
}
2930

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

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

4953
address := net.JoinHostPort(host, port)
5054

51-
conn, err := net.DialTimeout("tcp", address, timeout)
55+
var conn net.Conn
56+
var err error
57+
58+
if targetURL.Scheme == "https" {
59+
// Use TLS connection for HTTPS endpoints to complete handshake properly
60+
// and avoid TLS handshake warnings on the server
61+
dialer := &net.Dialer{Timeout: timeout}
62+
tlsConfig := &tls.Config{
63+
InsecureSkipVerify: insecure,
64+
ServerName: host,
65+
}
66+
conn, err = tls.DialWithDialer(dialer, "tcp", address, tlsConfig)
67+
} else {
68+
// Use plain TCP for HTTP endpoints
69+
conn, err = net.DialTimeout("tcp", address, timeout)
70+
}
71+
5272
duration := time.Since(start)
5373

5474
result := HealthCheckResult{

pkg/listen/healthcheck/healthcheck_test.go

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ func TestCheckServerHealth_HealthyServer(t *testing.T) {
2424
t.Fatalf("Failed to parse server URL: %v", err)
2525
}
2626

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

3030
// Verify result
3131
if !result.Healthy {
@@ -50,7 +50,7 @@ func TestCheckServerHealth_UnreachableServer(t *testing.T) {
5050
}
5151

5252
// Perform health check
53-
result := CheckServerHealth(targetURL, 1*time.Second)
53+
result := CheckServerHealth(targetURL, 1*time.Second, false)
5454

5555
// Verify result
5656
if result.Healthy {
@@ -101,8 +101,8 @@ func TestCheckServerHealth_DefaultPorts(t *testing.T) {
101101
}
102102
defer listener.Close()
103103

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

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

187187
// Perform health check
188-
result := CheckServerHealth(targetURL, 3*time.Second)
188+
result := CheckServerHealth(targetURL, 3*time.Second, false)
189189

190190
// Verify that the health check succeeded
191191
// This confirms that when a port is already in the URL, we don't append
@@ -197,3 +197,115 @@ func TestCheckServerHealth_PortInURL(t *testing.T) {
197197
t.Errorf("Expected no error for server with port in URL, got: %v", result.Error)
198198
}
199199
}
200+
201+
func TestCheckServerHealth_HTTPS_SelfSigned_InsecureTrue(t *testing.T) {
202+
// Start a test HTTPS server with self-signed certificate
203+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
204+
w.WriteHeader(http.StatusOK)
205+
}))
206+
defer server.Close()
207+
208+
// Parse server URL (will be https://...)
209+
serverURL, err := url.Parse(server.URL)
210+
if err != nil {
211+
t.Fatalf("Failed to parse server URL: %v", err)
212+
}
213+
214+
// Verify it's HTTPS
215+
if serverURL.Scheme != "https" {
216+
t.Fatalf("Expected HTTPS scheme, got: %s", serverURL.Scheme)
217+
}
218+
219+
// Perform health check with insecure=true (should succeed)
220+
result := CheckServerHealth(serverURL, 3*time.Second, true)
221+
222+
// Should be healthy because we skip certificate verification
223+
if !result.Healthy {
224+
t.Errorf("Expected server to be healthy with insecure=true, got unhealthy: %v", result.Error)
225+
}
226+
if result.Status != HealthHealthy {
227+
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
228+
}
229+
if result.Error != nil {
230+
t.Errorf("Expected no error with insecure=true, got: %v", result.Error)
231+
}
232+
}
233+
234+
func TestCheckServerHealth_HTTPS_SelfSigned_InsecureFalse(t *testing.T) {
235+
// Start a test HTTPS server with self-signed certificate
236+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
237+
w.WriteHeader(http.StatusOK)
238+
}))
239+
defer server.Close()
240+
241+
// Parse server URL (will be https://...)
242+
serverURL, err := url.Parse(server.URL)
243+
if err != nil {
244+
t.Fatalf("Failed to parse server URL: %v", err)
245+
}
246+
247+
// Perform health check with insecure=false (should fail due to self-signed cert)
248+
result := CheckServerHealth(serverURL, 3*time.Second, false)
249+
250+
// Should be unhealthy because certificate verification fails
251+
if result.Healthy {
252+
t.Errorf("Expected server to be unhealthy with insecure=false on self-signed cert, got healthy")
253+
}
254+
if result.Status != HealthUnreachable {
255+
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
256+
}
257+
if result.Error == nil {
258+
t.Errorf("Expected certificate error, got nil")
259+
}
260+
// Verify it's a certificate-related error
261+
if result.Error != nil && !strings.Contains(result.Error.Error(), "certificate") {
262+
t.Logf("Error message: %v (may vary by platform)", result.Error)
263+
}
264+
}
265+
266+
func TestCheckServerHealth_HTTPS_UsesTLSHandshake(t *testing.T) {
267+
// This test verifies that HTTPS URLs use TLS dial (not raw TCP)
268+
// by using httptest.NewTLSServer which creates a proper TLS server
269+
// and checking that the health check completes successfully
270+
271+
// Start a test HTTPS server - this will only succeed if TLS handshake completes
272+
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
273+
w.WriteHeader(http.StatusOK)
274+
}))
275+
defer server.Close()
276+
277+
serverURL, err := url.Parse(server.URL)
278+
if err != nil {
279+
t.Fatalf("Failed to parse server URL: %v", err)
280+
}
281+
282+
// Verify it's HTTPS
283+
if serverURL.Scheme != "https" {
284+
t.Fatalf("Expected HTTPS scheme, got: %s", serverURL.Scheme)
285+
}
286+
287+
// Perform health check with insecure=true (to accept self-signed cert)
288+
// If this succeeds, it proves TLS handshake was performed (not just TCP connect)
289+
result := CheckServerHealth(serverURL, 3*time.Second, true)
290+
291+
// Should be healthy - this proves TLS handshake succeeded
292+
if !result.Healthy {
293+
t.Errorf("Expected healthy result for HTTPS server (TLS handshake should succeed), got: %v", result.Error)
294+
}
295+
296+
// Verify that for HTTP URLs, we still use TCP (not TLS)
297+
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298+
w.WriteHeader(http.StatusOK)
299+
}))
300+
defer httpServer.Close()
301+
302+
httpURL, _ := url.Parse(httpServer.URL)
303+
if httpURL.Scheme != "http" {
304+
t.Fatalf("Expected HTTP scheme, got: %s", httpURL.Scheme)
305+
}
306+
307+
httpResult := CheckServerHealth(httpURL, 3*time.Second, false)
308+
if !httpResult.Healthy {
309+
t.Errorf("Expected healthy result for HTTP server, got: %v", httpResult.Error)
310+
}
311+
}

pkg/listen/listen.go

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535

3636
type Flags struct {
3737
NoWSS bool
38+
NoHealthcheck bool
3839
Path string
3940
MaxConnections int
4041
Output string
@@ -123,28 +124,30 @@ Specify a single destination to update the path. For example, pass a connection
123124
return err
124125
}
125126

126-
// Perform initial health check on target server
127+
// Perform initial health check on target server (unless disabled)
127128
// Using 3-second timeout optimized for local development scenarios.
128129
// This assumes low latency to localhost. For production/edge deployments,
129130
// this timeout may need to be configurable in future iterations.
130-
healthCheckTimeout := 3 * time.Second
131-
healthResult := CheckServerHealth(URL, healthCheckTimeout)
132-
133-
// For all output modes, warn if server isn't reachable
134-
if !healthResult.Healthy {
135-
warningMsg := FormatHealthMessage(healthResult, URL)
136-
137-
if flags.Output == "interactive" {
138-
// Interactive mode will show warning before TUI starts
139-
fmt.Println()
140-
fmt.Println(warningMsg)
141-
fmt.Println()
142-
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
143-
} else {
144-
// Compact/quiet modes: print warning before connection info
145-
fmt.Println()
146-
fmt.Println(warningMsg)
147-
fmt.Println()
131+
if !flags.NoHealthcheck {
132+
healthCheckTimeout := 3 * time.Second
133+
healthResult := CheckServerHealth(URL, healthCheckTimeout, config.Insecure)
134+
135+
// For all output modes, warn if server isn't reachable
136+
if !healthResult.Healthy {
137+
warningMsg := FormatHealthMessage(healthResult, URL)
138+
139+
if flags.Output == "interactive" {
140+
// Interactive mode will show warning before TUI starts
141+
fmt.Println()
142+
fmt.Println(warningMsg)
143+
fmt.Println()
144+
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
145+
} else {
146+
// Compact/quiet modes: print warning before connection info
147+
fmt.Println()
148+
fmt.Println(warningMsg)
149+
fmt.Println()
150+
}
148151
}
149152
}
150153

@@ -168,6 +171,7 @@ Specify a single destination to update the path. For example, pass a connection
168171
ConsoleBaseURL: config.ConsoleBaseURL,
169172
WSBaseURL: config.WSBaseURL,
170173
NoWSS: flags.NoWSS,
174+
NoHealthcheck: flags.NoHealthcheck,
171175
URL: URL,
172176
Log: log.StandardLogger(),
173177
Insecure: config.Insecure,

0 commit comments

Comments
 (0)