diff --git a/README.md b/README.md index c7c3a75..023aeca 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,19 @@ curl http://localhost: zt logs ``` +**`502 Bad Gateway` even though `zt status` shows the QUIC connection is up** +`zt status` reflects what cloudflared's connectivity pre-check and connection +registration reported - but that pre-check only opens a small test UDP +connection, not real traffic. On some networks the pre-check passes (QUIC +control packets get through fine) while actual response data gets silently +dropped due to UDP fragmentation/MTU issues on the path, causing request +timeouts that surface as 502s. This is not something `zt` or the watchdog +can detect or fix automatically - if you're seeing 502s with an apparently +healthy QUIC connection, force TCP: +```bash +zt down && zt up --tcp +``` + **Tunnel shows `stopped` in `zt ls`** ```bash systemctl --user status zt- diff --git a/cmd/zt/list.go b/cmd/zt/list.go index 02af8dd..1bd22b2 100644 --- a/cmd/zt/list.go +++ b/cmd/zt/list.go @@ -287,7 +287,7 @@ func runStatus(cmd *cobra.Command, args []string) error { fmt.Printf(" Port: %d\n", t.Port) fmt.Printf(" Tunnel ID: %s\n", t.TunnelID) fmt.Printf(" Managed by: %s\n", managedBy) - fmt.Printf(" Protocol: %s\n", protocolLabel(t.Protocol)) + fmt.Printf(" Protocol: %s\n", protocolLabel(t.Protocol, path)) fmt.Printf(" Status: %s\n", statusStr) fmt.Printf(" Created: %s\n", t.CreatedAt.Format("2006-01-02 15:04:05")) fmt.Printf(" Log: %s\n", path) @@ -299,13 +299,23 @@ func runStatus(cmd *cobra.Command, args []string) error { return nil } -func protocolLabel(p state.Protocol) string { +func protocolLabel(p state.Protocol, logPath string) string { switch p { case state.ProtocolHTTP2: return "http2 (TCP)" case state.ProtocolQUIC: return "quic (UDP)" default: - return "auto" + // Pinned to "auto" - show what cloudflared actually negotiated, + // not just the static config value, since the two can diverge + // (e.g. QUIC blocked upstream, cloudflared fell back to http2). + switch cloudflared.DetectEffectiveProtocol(logPath) { + case cloudflared.EffectiveHTTP2: + return "auto (http2)" + case cloudflared.EffectiveQUIC: + return "auto (quic)" + default: + return "auto" + } } } diff --git a/golangci.yml b/golangci.yml new file mode 100644 index 0000000..34914a8 --- /dev/null +++ b/golangci.yml @@ -0,0 +1,9 @@ +version: "2" + +linters: + default: none + enable: + - govet + - ineffassign + - staticcheck + - unused diff --git a/internal/cloudflared/protocol.go b/internal/cloudflared/protocol.go new file mode 100644 index 0000000..4130901 --- /dev/null +++ b/internal/cloudflared/protocol.go @@ -0,0 +1,75 @@ +package cloudflared + +import ( + "bufio" + "os" + "regexp" +) + +// EffectiveProtocol is what cloudflared actually negotiated, as opposed to +// what the tunnel is configured/pinned to. cloudflared decides this at +// startup (connectivity pre-check, "suggested_protocol=...") and can also +// change it mid-session (fallback marker below). +type EffectiveProtocol string + +const ( + EffectiveUnknown EffectiveProtocol = "" + EffectiveQUIC EffectiveProtocol = "quic" + EffectiveHTTP2 EffectiveProtocol = "http2" +) + +// registeredConnRe matches lines like: +// +// INF Registered tunnel connection connIndex=1 ... protocol=http2 +// +// which cloudflared emits once per edge connection, on every start and +// whenever a connection is re-established (including after an in-session +// fallback). This is the most reliable live signal, since it reflects +// what actually got negotiated rather than what was merely suggested. +var registeredConnRe = regexp.MustCompile(`Registered tunnel connection.*protocol=(quic|http2)`) + +// precheckRe matches the summary line cloudflared prints after its startup +// connectivity pre-check, e.g.: +// +// precheck complete hard_fail=false ... suggested_protocol=http2 +// +// Used as a fallback when no "Registered tunnel connection" line has been +// seen yet (e.g. tunnel is still coming up). +var precheckRe = regexp.MustCompile(`precheck complete.*suggested_protocol=(quic|http2)`) + +// DetectEffectiveProtocol tails a tunnel's cloudflared.log and returns the +// protocol currently in effect, based on the most recent signal found. +// It scans the whole file each call; logs are rotated per-service-restart +// via systemd/launchd so this stays cheap in practice. Returns +// EffectiveUnknown if the log doesn't exist yet or contains no signal. +func DetectEffectiveProtocol(logPath string) EffectiveProtocol { + f, err := os.Open(logPath) + if err != nil { + return EffectiveUnknown + } + defer func() { _ = f.Close() }() + + var last EffectiveProtocol + + scanner := bufio.NewScanner(f) + // cloudflared lines can be long (the precheck table), give some headroom. + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + for scanner.Scan() { + line := scanner.Text() + + // Chronological order in the log wins: whichever signal appears + // last - a registered connection or a precheck summary - is the + // most current statement of what cloudflared is actually using. + if m := registeredConnRe.FindStringSubmatch(line); m != nil { + last = EffectiveProtocol(m[1]) + continue + } + if m := precheckRe.FindStringSubmatch(line); m != nil { + last = EffectiveProtocol(m[1]) + } + } + + return last +} diff --git a/internal/cloudflared/protocol_test.go b/internal/cloudflared/protocol_test.go new file mode 100644 index 0000000..ca5a311 --- /dev/null +++ b/internal/cloudflared/protocol_test.go @@ -0,0 +1,71 @@ +package cloudflared + +import ( + "os" + "path/filepath" + "testing" +) + +func writeLog(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "cloudflared.log") + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("writing test log: %v", err) + } + return path +} + +func TestDetectEffectiveProtocol_MissingLog(t *testing.T) { + got := DetectEffectiveProtocol(filepath.Join(t.TempDir(), "does-not-exist.log")) + if got != EffectiveUnknown { + t.Fatalf("expected EffectiveUnknown for missing log, got %q", got) + } +} + +func TestDetectEffectiveProtocol_NoSignal(t *testing.T) { + path := writeLog(t, "2026-07-13T20:24:16Z INF Updated to new configuration version=0\n") + got := DetectEffectiveProtocol(path) + if got != EffectiveUnknown { + t.Fatalf("expected EffectiveUnknown, got %q", got) + } +} + +func TestDetectEffectiveProtocol_QUICRegistered(t *testing.T) { + path := writeLog(t, `2026-07-13T20:24:17Z INF Registered tunnel connection connIndex=1 connection=50a5223a event=0 ip=198.41.192.77 location=ams07 protocol=quic +2026-07-13T20:24:18Z INF Registered tunnel connection connIndex=2 connection=b533e1ca event=0 ip=198.41.200.63 location=ams18 protocol=quic +`) + got := DetectEffectiveProtocol(path) + if got != EffectiveQUIC { + t.Fatalf("expected quic, got %q", got) + } +} + +// This mirrors a real-world log: cloudflared's startup connectivity +// pre-check finds UDP dead and every connection registers on http2 - +// the exact shape reported against the auto-restart-loop bug. +func TestDetectEffectiveProtocol_PrecheckDegradedToHTTP2(t *testing.T) { + path := writeLog(t, `2026-07-13T20:24:17Z INF Registered tunnel connection connIndex=1 connection=50a5223a-f9c5-4211-a5bb-0a6f9ad159b6 event=0 ip=198.41.192.77 location=ams07 protocol=http2 +2026-07-13T20:24:18Z INF Registered tunnel connection connIndex=2 connection=b533e1ca-d617-4af7-8629-095a4a6d0fee event=0 ip=198.41.200.63 location=ams18 protocol=http2 +2026-07-13T20:24:19Z INF Registered tunnel connection connIndex=3 connection=fe35ba8a-888b-440d-b0be-a5b21ab722e0 event=0 ip=198.41.192.57 location=ams21 protocol=http2 +2026-07-13T20:24:25Z INF precheck component="UDP Connectivity" details="QUIC connection failed" run_id=d39bc56f status=fail target=region1.v2.argotunnel.com +2026-07-13T20:24:25Z INF precheck complete hard_fail=false run_id=d39bc56f-a7ce-494c-b64f-d74fb85ded63 suggested_protocol=http2 +`) + got := DetectEffectiveProtocol(path) + if got != EffectiveHTTP2 { + t.Fatalf("expected http2, got %q", got) + } +} + +// A mid-session fallback: tunnel started on quic, then dropped to http2 +// later in the log. The last signal should win, not the first. +func TestDetectEffectiveProtocol_MidSessionFallback(t *testing.T) { + path := writeLog(t, `2026-07-13T20:24:17Z INF Registered tunnel connection connIndex=1 connection=aaa event=0 protocol=quic +2026-07-13T20:40:02Z INF Switching to fallback protocol http2 +2026-07-13T20:40:03Z INF Registered tunnel connection connIndex=1 connection=bbb event=0 protocol=http2 +`) + got := DetectEffectiveProtocol(path) + if got != EffectiveHTTP2 { + t.Fatalf("expected http2 after mid-session fallback, got %q", got) + } +}