From 295c66388fb29567593e536816b27f199215f565 Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Sun, 20 Sep 2026 08:35:58 +0530 Subject: [PATCH] Fall back to HTTP/2 when the edge rejects the QUIC handshake `--protocol auto` only leaves QUIC when `isQuicBroken` recognises the failure: an idle timeout, or a transport error caused by "operation not permitted". A handshake the peer rejects with a CRYPTO_ERROR -- for example TLS alert 120, no_application_protocol, from a middlebox that intercepts QUIC without offering the tunnel's ALPN -- matches neither, so `selectNextProtocol` keeps choosing the current protocol and the connection retries QUIC forever while the tunnel stays down. Treat a CRYPTO_ERROR raised by the peer as a broken QUIC transport, so the connection switches to the fallback protocol on the first failure like it does for the other unrecoverable cases. Fixes #1736 Co-Authored-By: Claude Opus 5 --- supervisor/tunnel.go | 10 ++++++++++ supervisor/tunnel_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/supervisor/tunnel.go b/supervisor/tunnel.go index 01cca579321..723f8bbebf4 100644 --- a/supervisor/tunnel.go +++ b/supervisor/tunnel.go @@ -380,6 +380,16 @@ func isQuicBroken(cause error) bool { return true } + // A CRYPTO_ERROR raised by the peer means the TLS handshake was rejected, + // for example with no_application_protocol (0x178) when something on the + // path intercepts QUIC without supporting the protocols cloudflared offers. + // Retrying QUIC cannot recover from that, so treat it like a broken + // transport and let the connection fall back. + if transportErr, ok := errors.AsType[*quic.TransportError](cause); ok && + transportErr.Remote && transportErr.ErrorCode.IsCryptoError() { + return true + } + return false } diff --git a/supervisor/tunnel_test.go b/supervisor/tunnel_test.go index 66229c543a7..a37f1ba14ed 100644 --- a/supervisor/tunnel_test.go +++ b/supervisor/tunnel_test.go @@ -99,6 +99,36 @@ func TestWaitForBackoffFallback(t *testing.T) { assert.False(t, ok) } +// A QUIC handshake the peer rejects with a CRYPTO_ERROR (e.g. TLS alert 120, +// no_application_protocol, from a middlebox intercepting QUIC) cannot be +// recovered by retrying QUIC, so it must switch to the fallback protocol +// straight away instead of retrying until the backoff is exhausted. +func TestFallbackOnRemoteCryptoError(t *testing.T) { + maxRetries := uint(3) + backoff := retry.NewBackoff(maxRetries, 40*time.Millisecond, false) + backoff.Clock.After = immediateTimeAfter + log := zerolog.Nop() + protocolSelector, err := connection.NewProtocolSelector("auto", &log) + require.NoError(t, err) + + protoFallback := &protocolFallback{backoff, protocolSelector.Current(), false} + assert.Equal(t, connection.QUIC, protoFallback.protocol) + + // 0x178 is CRYPTO_ERROR for TLS alert 120 (no_application_protocol). + cause := &connection.EdgeQuicDialError{ + Cause: &quic.TransportError{ + Remote: true, + ErrorCode: quic.TransportErrorCode(0x178), + ErrorMessage: "tls: no application protocol", + }, + } + + protoFallback.BackoffTimer() // simulate retry + ok := selectNextProtocol(&log, protoFallback, protocolSelector, cause) + assert.True(t, ok) + assert.Equal(t, connection.HTTP2, protoFallback.protocol) +} + func TestIsRetryableStartupError(t *testing.T) { t.Parallel()