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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ curl http://localhost:<port>
zt logs <name>
```

**`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 <name> && zt up <name> <port> --tcp
```

**Tunnel shows `stopped` in `zt ls`**
```bash
systemctl --user status zt-<name>
Expand Down
16 changes: 13 additions & 3 deletions cmd/zt/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
}
}
}
9 changes: 9 additions & 0 deletions golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "2"

linters:
default: none
enable:
- govet
- ineffassign
- staticcheck
- unused
75 changes: 75 additions & 0 deletions internal/cloudflared/protocol.go
Original file line number Diff line number Diff line change
@@ -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
}
71 changes: 71 additions & 0 deletions internal/cloudflared/protocol_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading