Compare commits

...

3 Commits

Author SHA1 Message Date
Luis Neto 52519f67e8 TUN-10563: introduce QUICConnection interface
The bump of the QUIC library introduces a cyclic dependency between the connection and quic modules hence it is necessary to break this coupling.

Right now, the connection module depends on the quic module for the datagram v2/v3 and to which a QUIC connection (currently an interface) is passed.

As it is there is no issue however, under the hood, interface is a wrapper around an UDP connection and a QUIC connection meaning this type must be exposed to the quic module since the QUIC Connection will no longer be a interface but a struct.

Given the above, these changes introduce an interface, QUICConnection, with the surface used today in cloudflared and a struct, ConnWithCloser, that implements said interface within the quic module.

Closes TUN-10563
2026-06-01 10:08:38 +01:00
João "Pisco" Fernandes 0e84636de9 Release 2026.5.2 2026-05-27 11:15:36 +01:00
Miguel da Costa Martins Marcelino 4177dd6936 TUN-10391: Avoid using fmt.Println
Avoid using fmt.Println and instead switch to logging pre-checks with the provided logger.
2026-05-26 22:04:54 +00:00
18 changed files with 535 additions and 239 deletions
+3
View File
@@ -1,3 +1,6 @@
2026.5.2
- 2026-05-26 TUN-10391: Avoid using fmt.Println
2026.5.1
- 2026-05-22 fix: Bump go to 1.26.3 and go.opentelemetry.io/otel and go-jose/v4 to fix CVE's
- 2026-05-22 TUN-10391: Avoid blocking cloudflared due to logging
+57
View File
@@ -1,6 +1,9 @@
package cliutil
import (
"strings"
"github.com/rs/zerolog"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
@@ -57,3 +60,57 @@ func ConfigureLoggingFlags(shouldHide bool) []cli.Flag {
FlagLogOutput,
}
}
// LogTable renders lines inside an ASCII table and logs each rendered row.
func LogTable(log *zerolog.Logger, lines []string, title ...string) {
tableTitle := ""
if len(title) > 0 {
tableTitle = title[0]
}
for _, line := range asciiBox(lines, tableTitle, 2) {
if line != "" {
log.Info().Msg(line)
}
}
}
// asciiBox wraps lines in a bordered ASCII box with an optional title row.
func asciiBox(lines []string, title string, padding int) (box []string) {
maxLen := maxLen(lines, title)
spacer := strings.Repeat(" ", padding)
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
box = append(box, border)
if title != "" {
box = append(box, renderBoxLine(centerLine(title, maxLen), maxLen, spacer))
box = append(box, border)
}
for _, line := range lines {
box = append(box, renderBoxLine(line, maxLen, spacer))
}
box = append(box, border)
return
}
// renderBoxLine pads a single line so it fills the box width.
func renderBoxLine(line string, maxLen int, spacer string) string {
return "|" + spacer + line + strings.Repeat(" ", maxLen-len(line)) + spacer + "|"
}
// centerLine pads line evenly so it is centered within width.
func centerLine(line string, width int) string {
padding := width - len(line)
leftPadding := padding / 2
rightPadding := padding - leftPadding
return strings.Repeat(" ", leftPadding) + line + strings.Repeat(" ", rightPadding)
}
// maxLen returns the longest visible line length including the title.
func maxLen(lines []string, title string) int {
max := len(title)
for _, line := range lines {
if len(line) > max {
max = len(line)
}
}
return max
}
+60
View File
@@ -0,0 +1,60 @@
package cliutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogTableWithoutTitle(t *testing.T) {
t.Parallel()
lines := captureTableLogs(t, []string{"first", "second"})
assert.Equal(t, []string{
"+----------+",
"| first |",
"| second |",
"+----------+",
}, lines)
}
func TestLogTableWithTitle(t *testing.T) {
t.Parallel()
lines := captureTableLogs(t, []string{"first", "second"}, "TT")
assert.Equal(t, []string{
"+----------+",
"| TT |",
"+----------+",
"| first |",
"| second |",
"+----------+",
}, lines)
}
func captureTableLogs(t *testing.T, lines []string, title ...string) []string {
t.Helper()
var buf bytes.Buffer
logger := zerolog.New(&buf)
LogTable(&logger, lines, title...)
// nolint: prealloc
var messages []string
for _, line := range bytes.Split(bytes.TrimSpace(buf.Bytes()), []byte("\n")) {
var entry struct {
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal(line, &entry))
messages = append(messages, entry.Message)
}
return messages
}
+2 -2
View File
@@ -551,8 +551,8 @@ func runPrechecks(c *cli.Context, log *zerolog.Logger, region string) {
report := prechecks.Run(c.Context, c.String(cfdflags.CACert), cfg, log, dialers)
// Output the human-readable table to console
fmt.Println(report.String())
// Output the human-readable table
cliutil.LogTable(log, report.String(), "CONNECTIVITY PRE-CHECKS")
// Also log structured results for log aggregation
report.LogEvent(log)
+4 -28
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/cloudflare/cloudflared/cmd/cloudflared/cliutil"
"github.com/cloudflare/cloudflared/cmd/cloudflared/flags"
"github.com/cloudflare/cloudflared/connection"
)
@@ -44,7 +45,7 @@ func RunQuickTunnel(sc *subcommandContext) error {
if err != nil {
return errors.Wrap(err, "failed to request quick Tunnel")
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
// This will read the entire response into memory so we can print it in case of error
rsp_body, err := io.ReadAll(resp.Body)
@@ -76,12 +77,10 @@ func RunQuickTunnel(sc *subcommandContext) error {
url = "https://" + url
}
for _, line := range AsciiBox([]string{
cliutil.LogTable(sc.log, []string{
"Your quick Tunnel has been created! Visit it at (it may take some time to be reachable):",
url,
}, 2) {
sc.log.Info().Msg(line)
}
})
if !sc.c.IsSet(flags.Protocol) {
_ = sc.c.Set(flags.Protocol, "quic")
@@ -116,26 +115,3 @@ type QuickTunnel struct {
AccountTag string `json:"account_tag"`
Secret []byte `json:"secret"`
}
// Print out the given lines in a nice ASCII box.
func AsciiBox(lines []string, padding int) (box []string) {
maxLen := maxLen(lines)
spacer := strings.Repeat(" ", padding)
border := "+" + strings.Repeat("-", maxLen+(padding*2)) + "+"
box = append(box, border)
for _, line := range lines {
box = append(box, "|"+spacer+line+strings.Repeat(" ", maxLen-len(line))+spacer+"|")
}
box = append(box, border)
return
}
func maxLen(lines []string) int {
max := 0
for _, line := range lines {
if len(line) > max {
max = len(line)
}
}
return max
}
+82 -40
View File
@@ -5,8 +5,8 @@ Integration tests for cloudflared connectivity pre-checks (TUN-10391).
Scope
-----
These tests verify the end-to-end behavior of cloudflared pre-checks:
- that the human-readable table written to stdout has the correct structure
and content,
- that the human-readable table written to the log output has the correct
structure and content,
- that structured JSON log lines are emitted with the expected fields, and
- that running the `diag` subcommand against a live tunnel instance produces a
zip archive that contains prechecks.json.
@@ -25,15 +25,24 @@ without real firewall intervention is:
DNS failure and Management API failure cannot be triggered via CLI flags alone;
they require network-level intervention outside the component-test harness.
stdout design
-------------
fmt.Println(report.String()) runs inside a goroutine that is started
concurrently with the tunnel. We poll a --logfile for the "precheck complete"
sentinel before leaving the `with` block, ensuring the goroutine has finished.
We then call cfd.terminate(). After the `with` block exits, the process is
dead and all output has been captured by CloudflaredProcess's background reader
thread (stderr is merged into stdout). We read the accumulated lines from
cfd.stdout_lines.
stdout/stderr design
--------------------
The pre-checks table is emitted via cliutil.LogTable, which wraps the content
in an ASCII box and logs each line at Info level through zerolog. zerolog
writes to stderr, which the test harness merges into stdout (stderr=STDOUT in
Popen). We poll a --logfile for the "precheck complete" sentinel before
leaving the `with` block, ensuring the goroutine has finished. We then call
cfd.terminate(). After the `with` block exits, the process is dead and all
output has been captured by CloudflaredProcess's background reader thread. We
read the accumulated lines from cfd.stdout_lines.
Box format (cliutil.asciiBox with padding=2, title="CONNECTIVITY PRE-CHECKS"):
+----...----+
| CONNECTIVITY PRE-CHECKS | (centered title)
+----...----+
| COMPONENT TARGET ... | (content rows)
...
+----...----+
"""
import json
@@ -46,11 +55,13 @@ import zipfile as zipfilemod
from constants import METRICS_PORT
from util import LOGGER, start_cloudflared, wait_tunnel_ready
# stdout table constants
TABLE_WIDTH = 80
HEADER_LINE = "--- CONNECTIVITY PRE-CHECKS " + "-" * (TABLE_WIDTH - len("--- CONNECTIVITY PRE-CHECKS ") - 1)
COL_HEADER = "COMPONENT" # first token of the column-header row
SEPARATOR = "-" * TABLE_WIDTH
# ASCII box constants (cliutil.asciiBox, padding=2, title="CONNECTIVITY PRE-CHECKS")
BOX_TITLE = "CONNECTIVITY PRE-CHECKS"
BOX_BORDER_RE = re.compile(r"^\+(-+)\+$", re.MULTILINE) # matches +----...----+
COL_HEADER = "COMPONENT" # first word of the column-header row
# zerolog console format: "2006-01-02T15:04:05Z LVL <message>"
_LOG_PREFIX_RE = re.compile(r"^\S+ \w+ ")
# Component names (probes.go: componentXxx)
COMP_DNS = "DNS Resolution"
@@ -164,23 +175,46 @@ class TableRow:
return f"TableRow({self.component!r}, {self.target!r}, {self.status!r}, {self.details!r})"
def _strip_log_prefix(line: str) -> str:
"""Remove the zerolog console prefix ('2006-01-02T15:04:05Z LVL ') if present."""
return _LOG_PREFIX_RE.sub("", line, count=1)
def _unbox_line(line: str) -> str:
"""Strip the box border padding from a content line: '| text |' -> 'text'.
Accepts lines that may still carry a zerolog console prefix; the prefix is
removed before the box delimiters are stripped.
"""
msg = _strip_log_prefix(line)
if msg.startswith("|") and msg.endswith("|"):
return msg[1:-1].strip()
return msg.strip()
def _parse_table(stdout: str) -> list[TableRow]:
"""
Parse the data rows from a precheck table in stdout.
text/tabwriter uses padding=2, so columns are separated by two or more
spaces. We skip the column-header row and stop at blank lines, SUMMARY,
separator, ERROR, or WARNING lines.
The table is now wrapped in an ASCII box by cliutil.LogTable. Each
content line has the form '| <content> |', optionally preceded by a
zerolog console prefix. We strip both the prefix and the box borders
before splitting on two-or-more spaces (text/tabwriter padding=2).
We skip the column-header row and stop at blank lines, SUMMARY, box
border lines, ERROR, or WARNING lines.
"""
rows = []
in_data = False
for line in stdout.splitlines():
for raw_line in stdout.splitlines():
msg = _strip_log_prefix(raw_line)
line = _unbox_line(raw_line)
if line.startswith("COMPONENT"):
in_data = True
continue
if not in_data:
continue
if (line == "" or line.startswith("SUMMARY") or line.startswith("---")
if (line == "" or line.startswith("SUMMARY") or BOX_BORDER_RE.match(msg)
or line.startswith("ERROR") or line.startswith("WARNING")):
in_data = False
continue
@@ -261,14 +295,18 @@ class TestPrechecksHappyPath:
LOGGER.debug(f"[happy-path] stdout:\n{stdout}")
LOGGER.debug(f"[happy-path] log_lines:\n{log_lines}")
# Strip zerolog console prefixes so pattern matching works on raw messages.
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
# ── table structure ──────────────────────────────────────────────────
# stderr is merged into stdout so log lines precede the table.
assert HEADER_LINE in stdout, \
f"Expected header line in output;\ngot:\n{stdout}"
assert COL_HEADER in stdout, \
# zerolog writes to stderr which is merged into stdout by the harness.
# The table is wrapped in an ASCII box by cliutil.LogTable.
assert BOX_TITLE in messages, \
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
assert COL_HEADER in messages, \
f"Expected column header row in output;\ngot:\n{stdout}"
assert SEPARATOR in stdout, \
f"Expected closing separator in output;\ngot:\n{stdout}"
assert BOX_BORDER_RE.search(messages), \
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
# ── row content ──────────────────────────────────────────────────────
rows = _parse_table(stdout)
@@ -306,11 +344,11 @@ class TestPrechecksHappyPath:
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
# ── no action lines ──────────────────────────────────────────────────
assert PREFIX_ERROR not in stdout, f"Unexpected ERROR action:\n{stdout}"
assert PREFIX_WARNING not in stdout, f"Unexpected WARNING action:\n{stdout}"
assert PREFIX_ERROR not in messages, f"Unexpected ERROR action:\n{stdout}"
assert PREFIX_WARNING not in messages, f"Unexpected WARNING action:\n{stdout}"
# ── exact summary line ───────────────────────────────────────────────
assert SUMMARY_HEALTHY in stdout, \
# ── summary line ─────────────────────────────────────────────────────
assert SUMMARY_HEALTHY in messages, \
f"Expected healthy summary;\ngot:\n{stdout}"
# ── structured log ───────────────────────────────────────────────────
@@ -366,14 +404,18 @@ class TestPrechecksHardFail:
LOGGER.debug(f"[hard-fail] stdout:\n{stdout}")
LOGGER.debug(f"[hard-fail] log_lines:\n{log_lines}")
# Strip zerolog console prefixes so pattern matching works on raw messages.
messages = "\n".join(_strip_log_prefix(l) for l in stdout.splitlines())
# ── table structure ──────────────────────────────────────────────────
# stderr is merged into stdout so log lines precede the table.
assert HEADER_LINE in stdout, \
f"Expected header line in output;\ngot:\n{stdout}"
assert COL_HEADER in stdout, \
# zerolog writes to stderr which is merged into stdout by the harness.
# The table is wrapped in an ASCII box by cliutil.LogTable.
assert BOX_TITLE in messages, \
f"Expected box title '{BOX_TITLE}' in output;\ngot:\n{stdout}"
assert COL_HEADER in messages, \
f"Expected column header row in output;\ngot:\n{stdout}"
assert SEPARATOR in stdout, \
f"Expected closing separator in output;\ngot:\n{stdout}"
assert BOX_BORDER_RE.search(messages), \
f"Expected box border line (+---+) in output;\ngot:\n{stdout}"
# ── row content ──────────────────────────────────────────────────────
rows = _parse_table(stdout)
@@ -404,12 +446,12 @@ class TestPrechecksHardFail:
assert api_rows[0].status == PASS, f"API row not PASS: {api_rows[0]}"
assert api_rows[0].details == DETAILS_API_OK, f"API row details wrong: {api_rows[0]}"
assert f"{PREFIX_ERROR}{ACTION_QUIC_BLOCKED}" in stdout, \
assert f"{PREFIX_ERROR}{ACTION_QUIC_BLOCKED}" in messages, \
f"Expected QUIC ERROR action;\ngot:\n{stdout}"
assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in stdout, \
assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in messages, \
f"Expected HTTP/2 ERROR action;\ngot:\n{stdout}"
assert SUMMARY_CRITICAL in stdout, \
assert SUMMARY_CRITICAL in messages, \
f"Expected critical summary;\ngot:\n{stdout}"
_assert_precheck_summary_log(log_lines, hard_fail=True, suggested_protocol=None)
+3 -18
View File
@@ -13,6 +13,7 @@ import (
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection/dialopts"
cfdquic "github.com/cloudflare/cloudflared/quic"
)
var (
@@ -29,7 +30,7 @@ func DialQuic(
connIndex uint8,
logger *zerolog.Logger,
opts dialopts.DialOpts,
) (quic.Connection, error) {
) (cfdquic.QUICConnection, error) {
udpConn, err := createUDPConnForConnIndex(connIndex, localAddr, edgeAddr, opts, logger)
if err != nil {
return nil, err
@@ -43,11 +44,7 @@ func DialQuic(
}
// wrap the session, so that the UDPConn is closed after session is closed.
conn = &wrapCloseableConnQuicConnection{
conn,
udpConn,
}
return conn, nil
return cfdquic.NewQUICConnection(conn, udpConn)
}
func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.AddrPort, opts dialopts.DialOpts, logger *zerolog.Logger) (*net.UDPConn, error) {
@@ -96,15 +93,3 @@ func createUDPConnForConnIndex(connIndex uint8, localIP net.IP, edgeIP netip.Add
return udpConn, err
}
type wrapCloseableConnQuicConnection struct {
quic.Connection
udpConn *net.UDPConn
}
func (w *wrapCloseableConnQuicConnection) CloseWithError(errorCode quic.ApplicationErrorCode, reason string) error {
err := w.Connection.CloseWithError(errorCode, reason)
_ = w.udpConn.Close()
return err
}
+4 -4
View File
@@ -41,7 +41,7 @@ const (
// quicConnection represents the type that facilitates Proxying via QUIC streams.
type quicConnection struct {
conn quic.Connection
conn cfdquic.QUICConnection
logger *zerolog.Logger
orchestrator Orchestrator
datagramHandler DatagramSessionHandler
@@ -54,10 +54,10 @@ type quicConnection struct {
gracePeriod time.Duration
}
// NewTunnelConnection takes a [quic.Connection] to wrap it for use with cloudflared application logic.
// NewTunnelConnection takes a [cfdquic.QUICConnection] to wrap it for use with cloudflared application logic.
func NewTunnelConnection(
ctx context.Context,
conn quic.Connection,
conn cfdquic.QUICConnection,
connIndex uint8,
orchestrator Orchestrator,
datagramSessionHandler DatagramSessionHandler,
@@ -169,7 +169,7 @@ func (q *quicConnection) acceptStream(ctx context.Context) error {
func (q *quicConnection) runStream(quicStream quic.Stream) {
ctx := quicStream.Context()
stream := cfdquic.NewSafeStreamCloser(quicStream, q.streamWriteTimeout, q.logger)
defer stream.Close()
defer func() { _ = stream.Close() }()
// we are going to fuse readers/writers from stream <- cloudflared -> origin, and we want to guarantee that
// code executed in the code path of handleStream don't trigger an earlier close to the downstream write stream.
+6 -11
View File
@@ -8,9 +8,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/pkg/errors"
pkgerrors "github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
@@ -24,7 +22,6 @@ import (
"github.com/cloudflare/cloudflared/packet"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/tracing"
"github.com/cloudflare/cloudflared/tunnelrpc/pogs"
tunnelpogs "github.com/cloudflare/cloudflared/tunnelrpc/pogs"
rpcquic "github.com/cloudflare/cloudflared/tunnelrpc/quic"
)
@@ -34,20 +31,18 @@ const (
demuxChanCapacity = 16
)
var (
errInvalidDestinationIP = errors.New("unable to parse destination IP")
)
var errInvalidDestinationIP = pkgerrors.New("unable to parse destination IP")
// DatagramSessionHandler is a service that can serve datagrams for a connection and handle sessions from incoming
// connection streams.
type DatagramSessionHandler interface {
Serve(context.Context) error
pogs.SessionManager
tunnelpogs.SessionManager
}
type datagramV2Connection struct {
conn quic.Connection
conn cfdquic.QUICConnection
index uint8
// sessionManager tracks active sessions. It receives datagrams from quic connection via datagramMuxer
@@ -69,7 +64,7 @@ type datagramV2Connection struct {
}
func NewDatagramV2Connection(ctx context.Context,
conn quic.Connection,
conn cfdquic.QUICConnection,
originDialer ingress.OriginUDPDialer,
icmpRouter ingress.ICMPRouter,
index uint8,
@@ -166,7 +161,7 @@ func (q *datagramV2Connection) RegisterUdpSession(ctx context.Context, sessionID
session, err := q.sessionManager.RegisterSession(ctx, sessionID, originProxy)
if err != nil {
originProxy.Close()
_ = originProxy.Close()
log.Err(err).Str(datagramsession.LogFieldSessionID, datagramsession.FormatSessionID(sessionID)).Msgf("Failed to register udp session")
tracing.EndWithErrorStatus(registerSpan, err)
q.flowLimiter.Release()
@@ -229,7 +224,7 @@ func (q *datagramV2Connection) closeUDPSession(ctx context.Context, sessionID uu
}
stream := cfdquic.NewSafeStreamCloser(quicStream, q.streamWriteTimeout, q.logger)
defer stream.Close()
defer func() { _ = stream.Close() }()
rpcClientStream, err := rpcquic.NewSessionClient(ctx, stream, q.rpcTimeout)
if err != nil {
// Log this at debug because this is not an error if session was closed due to lost connection
+9 -9
View File
@@ -7,12 +7,12 @@ import (
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/ingress"
"github.com/cloudflare/cloudflared/management"
cfdquic "github.com/cloudflare/cloudflared/quic/v3"
cfdquic "github.com/cloudflare/cloudflared/quic"
cfdquicv3 "github.com/cloudflare/cloudflared/quic/v3"
"github.com/cloudflare/cloudflared/tunnelrpc/pogs"
)
@@ -22,20 +22,20 @@ var (
)
type datagramV3Connection struct {
conn quic.Connection
conn cfdquic.QUICConnection
index uint8
// datagramMuxer mux/demux datagrams from quic connection
datagramMuxer cfdquic.DatagramConn
metrics cfdquic.Metrics
datagramMuxer cfdquicv3.DatagramConn
metrics cfdquicv3.Metrics
logger *zerolog.Logger
}
func NewDatagramV3Connection(ctx context.Context,
conn quic.Connection,
sessionManager cfdquic.SessionManager,
conn cfdquic.QUICConnection,
sessionManager cfdquicv3.SessionManager,
icmpRouter ingress.ICMPRouter,
index uint8,
metrics cfdquic.Metrics,
metrics cfdquicv3.Metrics,
logger *zerolog.Logger,
) DatagramSessionHandler {
log := logger.
@@ -43,7 +43,7 @@ func NewDatagramV3Connection(ctx context.Context,
Int(management.EventTypeKey, int(management.UDP)).
Uint8(LogFieldConnIndex, index).
Logger()
datagramMuxer := cfdquic.NewDatagramConn(conn, sessionManager, icmpRouter, index, metrics, &log)
datagramMuxer := cfdquicv3.NewDatagramConn(conn, sessionManager, icmpRouter, index, metrics, &log)
return &datagramV3Connection{
conn,
+7 -6
View File
@@ -17,12 +17,13 @@ import (
reflect "reflect"
time "time"
quic "github.com/quic-go/quic-go"
quic0 "github.com/quic-go/quic-go"
zerolog "github.com/rs/zerolog"
gomock "go.uber.org/mock/gomock"
dialopts "github.com/cloudflare/cloudflared/connection/dialopts"
allregions "github.com/cloudflare/cloudflared/edgediscovery/allregions"
quic "github.com/cloudflare/cloudflared/quic"
)
// MockDNSResolver is a mock of DNSResolver interface.
@@ -176,10 +177,10 @@ func (m *MockQUICDialer) EXPECT() *MockQUICDialerMockRecorder {
}
// DialQuic mocks base method.
func (m *MockQUICDialer) DialQuic(ctx context.Context, quicConfig *quic.Config, tlsConfig *tls.Config, addr netip.AddrPort, localAddr net.IP, connIndex uint8, logger *zerolog.Logger, opts dialopts.DialOpts) (quic.Connection, error) {
func (m *MockQUICDialer) DialQuic(ctx context.Context, quicConfig *quic0.Config, tlsConfig *tls.Config, addr netip.AddrPort, localAddr net.IP, connIndex uint8, logger *zerolog.Logger, opts dialopts.DialOpts) (quic.QUICConnection, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DialQuic", ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts)
ret0, _ := ret[0].(quic.Connection)
ret0, _ := ret[0].(quic.QUICConnection)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -197,19 +198,19 @@ type MockQUICDialerDialQuicCall struct {
}
// Return rewrite *gomock.Call.Return
func (c *MockQUICDialerDialQuicCall) Return(arg0 quic.Connection, arg1 error) *MockQUICDialerDialQuicCall {
func (c *MockQUICDialerDialQuicCall) Return(arg0 quic.QUICConnection, arg1 error) *MockQUICDialerDialQuicCall {
c.Call = c.Call.Return(arg0, arg1)
return c
}
// Do rewrite *gomock.Call.Do
func (c *MockQUICDialerDialQuicCall) Do(f func(context.Context, *quic.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.Connection, error)) *MockQUICDialerDialQuicCall {
func (c *MockQUICDialerDialQuicCall) Do(f func(context.Context, *quic0.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.QUICConnection, error)) *MockQUICDialerDialQuicCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
func (c *MockQUICDialerDialQuicCall) DoAndReturn(f func(context.Context, *quic.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.Connection, error)) *MockQUICDialerDialQuicCall {
func (c *MockQUICDialerDialQuicCall) DoAndReturn(f func(context.Context, *quic0.Config, *tls.Config, netip.AddrPort, net.IP, uint8, *zerolog.Logger, dialopts.DialOpts) (quic.QUICConnection, error)) *MockQUICDialerDialQuicCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/cloudflare/cloudflared/connection"
edgedial "github.com/cloudflare/cloudflared/edgediscovery"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/tlsconfig"
)
@@ -96,7 +97,7 @@ func (d *EdgeQUICDialer) DialQuic(
connIndex uint8,
logger *zerolog.Logger,
opts dialopts.DialOpts,
) (quic.Connection, error) {
) (cfdquic.QUICConnection, error) {
return connection.DialQuic(ctx, quicConfig, tlsConfig, addr, localAddr, connIndex, logger, opts)
}
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/connection/dialopts"
cfdquic "github.com/cloudflare/cloudflared/quic"
"github.com/cloudflare/cloudflared/edgediscovery/allregions"
)
@@ -44,7 +45,7 @@ type QUICDialer interface {
connIndex uint8,
logger *zerolog.Logger,
opts dialopts.DialOpts,
) (quic.Connection, error)
) (cfdquic.QUICConnection, error)
}
// ManagementDialer abstracts the TCP dial to api.cloudflare.com:443 used by
+14 -51
View File
@@ -10,19 +10,12 @@ import (
)
const (
// tableWidth is the total character width of the separator lines.
tableWidth = 80
// Status names.
passStatus = "PASS"
failStatus = "FAIL"
skipStatus = "SKIP"
unknownStatus = "UNKNOWN"
// Section separators.
sectionChar = "-"
headerTitle = "CONNECTIVITY PRE-CHECKS"
// Log message constants.
logMsgPrecheck = "precheck"
logMsgPrecheckComplete = "precheck complete"
@@ -35,8 +28,6 @@ const (
logFieldDetails = "details"
logFieldHardFail = "hard_fail"
logFieldSuggestedProtocol = "suggested_protocol"
sep = " "
)
// statusLabel returns the display label for a given Status.
@@ -58,21 +49,9 @@ func (s Status) logString() string {
return strings.ToLower(s.String())
}
// separator returns a full-width horizontal line.
func separator() string {
return strings.Repeat(sectionChar, tableWidth)
}
// header returns the top section title line.
func header() string {
leftDashes := strings.Repeat(sectionChar, 3)
rightLen := tableWidth - len(leftDashes) - len(headerTitle) - len(sep)*2
return leftDashes + sep + headerTitle + sep + strings.Repeat(sectionChar, rightLen)
}
// renderTable uses text/tabwriter to format the results rows with
// automatically aligned columns, returning the rendered string.
func renderTable(results []CheckResult) string {
// automatically aligned columns, returning the rendered lines.
func renderTable(results []CheckResult) []string {
var buf bytes.Buffer
// minwidth=0, tabwidth=8, padding=2, padchar=' ', flags=0
w := tabwriter.NewWriter(&buf, 0, 8, 2, ' ', 0)
@@ -81,27 +60,27 @@ func renderTable(results []CheckResult) string {
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Component, r.Target, r.ProbeStatus.statusLabel(), r.Details)
}
_ = w.Flush()
return buf.String()
return strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
}
// renderActions collects all non-empty Action strings from results and returns
// the formatted warning/error block that appears between the table and SUMMARY.
// A Fail result is rendered as ERROR when the report is a hard fail, and as
// WARNING otherwise (degraded but tunnel can still run).
func renderActions(r Report) string {
func renderActions(r Report) []string {
hardFail := r.hasHardFail()
var sb strings.Builder
actions := make([]string, 0)
for _, res := range r.Results {
if res.Action == "" || res.ProbeStatus != Fail {
continue
}
if hardFail {
_, _ = fmt.Fprintf(&sb, "ERROR: %s\n", res.Action)
actions = append(actions, fmt.Sprintf("ERROR: %s", res.Action))
} else {
_, _ = fmt.Fprintf(&sb, "WARNING: %s\n", res.Action)
actions = append(actions, fmt.Sprintf("WARNING: %s", res.Action))
}
}
return sb.String()
return actions
}
// summaryLine builds the SUMMARY: line based on the Report state.
@@ -181,28 +160,12 @@ func (r Report) hasWarn() bool {
return (quicFail != http2Fail) || apiFail
}
// String renders the Report as a human-readable table suitable for os.Stdout.
func (r Report) String() string {
var sb strings.Builder
sb.WriteString(header())
sb.WriteString("\n")
sb.WriteString(renderTable(r.Results))
actions := renderActions(r)
if actions != "" {
sb.WriteString(actions)
}
sb.WriteString("\n")
sb.WriteString(summaryLine(r))
sb.WriteString("\n")
sb.WriteString(separator())
sb.WriteString("\n")
return sb.String()
// String renders the Report as human-readable table lines suitable for logging.
func (r Report) String() []string {
lines := renderTable(r.Results)
lines = append(lines, renderActions(r)...)
lines = append(lines, "", summaryLine(r))
return lines
}
// LogEvent emits each CheckResult as a structured zerolog log line, followed by
+57 -62
View File
@@ -134,85 +134,80 @@ func dnsFailReport() Report {
func TestString_AllPass(t *testing.T) {
t.Parallel()
want := "" +
"--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" +
"COMPONENT TARGET STATUS DETAILS\n" +
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful\n" +
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful\n" +
"Cloudflare API api.cloudflare.com:443 PASS API is reachable\n" +
"\n" +
"SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.\n" +
"--------------------------------------------------------------------------------\n"
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"",
"SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol.",
}
assert.Equal(t, want, allPassReport().String())
}
func TestString_QuicBlocked(t *testing.T) {
t.Parallel()
want := "" +
"--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" +
"COMPONENT TARGET STATUS DETAILS\n" +
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed\n" +
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful\n" +
"Cloudflare API api.cloudflare.com:443 PASS API is reachable\n" +
"WARNING: Allow outbound QUIC traffic on port 7844 or use HTTP2.\n" +
"\n" +
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'http2'.\n" +
"--------------------------------------------------------------------------------\n"
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"WARNING: Allow outbound QUIC traffic on port 7844 or use HTTP2.",
"",
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'http2'.",
}
assert.Equal(t, want, quicBlockedReport().String())
}
func TestString_APIFail(t *testing.T) {
t.Parallel()
want := "" +
"--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" +
"COMPONENT TARGET STATUS DETAILS\n" +
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful\n" +
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful\n" +
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused\n" +
"WARNING: cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.\n" +
"\n" +
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'quic'.\n" +
"--------------------------------------------------------------------------------\n"
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) PASS QUIC connection successful",
"TCP Connectivity Port 7844 (HTTP/2) PASS HTTP/2 connection successful",
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused",
"WARNING: cloudflared will still run, but automatic software updates are unavailable. Ensure port 443 TCP to api.cloudflare.com is open if you want auto-updates.",
"",
"SUMMARY: Environment ready with degraded transport. cloudflared will proceed using 'quic'.",
}
assert.Equal(t, want, apiFailReport().String())
}
func TestString_BothTransportsBlocked(t *testing.T) {
t.Parallel()
want := "" +
"--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" +
"COMPONENT TARGET STATUS DETAILS\n" +
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully\n" +
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed\n" +
"TCP Connectivity Port 7844 (HTTP/2) FAIL HTTP/2 connection is blocked or unreachable\n" +
"Cloudflare API api.cloudflare.com:443 PASS API is reachable\n" +
"ERROR: Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.\n" +
"\n" +
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.\n" +
"--------------------------------------------------------------------------------\n"
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com PASS DNS Resolved successfully",
"DNS Resolution region2.v2.argotunnel.com PASS DNS Resolved successfully",
"UDP Connectivity Port 7844 (QUIC) FAIL QUIC connection failed",
"TCP Connectivity Port 7844 (HTTP/2) FAIL HTTP/2 connection is blocked or unreachable",
"Cloudflare API api.cloudflare.com:443 PASS API is reachable",
"ERROR: Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.",
"",
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.",
}
assert.Equal(t, want, bothTransportsBlockedReport().String())
}
func TestString_DNSFail(t *testing.T) {
t.Parallel()
want := "" +
"--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" +
"COMPONENT TARGET STATUS DETAILS\n" +
"DNS Resolution region1.v2.argotunnel.com FAIL No addresses returned\n" +
"DNS Resolution region2.v2.argotunnel.com FAIL No addresses returned\n" +
"UDP Connectivity Port 7844 (QUIC) SKIP DNS prerequisite failed\n" +
"TCP Connectivity Port 7844 (HTTP/2) SKIP DNS prerequisite failed\n" +
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused\n" +
"ERROR: Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.\n" +
"\n" +
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.\n" +
"--------------------------------------------------------------------------------\n"
want := []string{
"COMPONENT TARGET STATUS DETAILS",
"DNS Resolution region1.v2.argotunnel.com FAIL No addresses returned",
"DNS Resolution region2.v2.argotunnel.com FAIL No addresses returned",
"UDP Connectivity Port 7844 (QUIC) SKIP DNS prerequisite failed",
"TCP Connectivity Port 7844 (HTTP/2) SKIP DNS prerequisite failed",
"Cloudflare API api.cloudflare.com:443 FAIL Connection refused",
"ERROR: Ensure your DNS resolver can resolve 'region1.v2.argotunnel.com'. Run: dig A region1.v2.argotunnel.com @1.1.1.1. If that fails, contact your network administrator.",
"",
"SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel.",
}
assert.Equal(t, want, dnsFailReport().String())
}
@@ -221,9 +216,9 @@ func TestString_EmptyResults(t *testing.T) {
r := Report{RunID: fixedRunID, SuggestedProtocol: new(connection.QUIC)}
out := r.String()
// Must not panic and must still emit a valid skeleton.
assert.Contains(t, out, "CONNECTIVITY PRE-CHECKS")
assert.Contains(t, out, "SUMMARY:")
assert.Contains(t, out, separator())
require.Len(t, out, 3)
assert.Contains(t, out[0], "COMPONENT")
assert.Contains(t, out[2], "SUMMARY:")
}
// LogEvent() / structured log renderer tests
+10 -6
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"github.com/pkg/errors"
"github.com/quic-go/quic-go"
"github.com/rs/zerolog"
"github.com/cloudflare/cloudflared/packet"
@@ -51,14 +50,14 @@ func (dm *DatagramMuxerV2) mtu() int {
}
type DatagramMuxerV2 struct {
session quic.Connection
session QUICConnection
logger *zerolog.Logger
sessionDemuxChan chan<- *packet.Session
packetDemuxChan chan Packet
}
func NewDatagramMuxerV2(
quicSession quic.Connection,
quicSession QUICConnection,
log *zerolog.Logger,
sessionDemuxChan chan<- *packet.Session,
) *DatagramMuxerV2 {
@@ -110,7 +109,8 @@ func (dm *DatagramMuxerV2) SendPacket(pk Packet) error {
return nil
}
// Demux reads datagrams from the QUIC connection and demuxes depending on whether it's a session or packet
// ServeReceive reads datagrams from the QUIC connection and demuxes them
// depending on whether it's a session or packet
func (dm *DatagramMuxerV2) ServeReceive(ctx context.Context) error {
for {
msg, err := dm.session.ReceiveDatagram(ctx)
@@ -144,8 +144,10 @@ func (dm *DatagramMuxerV2) demux(ctx context.Context, msgWithType []byte) error
switch msgType {
case DatagramTypeUDP:
return dm.handleSession(ctx, msg)
default:
case DatagramTypeIP, DatagramTypeIPWithTrace, DatagramTypeTracingSpan:
return dm.handlePacket(ctx, msg, msgType)
default:
return fmt.Errorf("unexpected datagram type %d", msgType)
}
}
@@ -189,8 +191,10 @@ func (dm *DatagramMuxerV2) handlePacket(ctx context.Context, pk []byte, msgType
Spans: spans,
TracingIdentity: tracingIdentity,
}
case DatagramTypeUDP:
return fmt.Errorf("unexpected datagram type %d in handlePacket", msgType)
default:
return fmt.Errorf("Unexpected datagram type %d", msgType)
return fmt.Errorf("unexpected datagram type %d", msgType)
}
select {
case <-ctx.Done():
+105
View File
@@ -0,0 +1,105 @@
package quic
import (
"context"
"errors"
"io"
"net"
"github.com/quic-go/quic-go"
)
// QUICConnection defines the subset of [quic.Connection] methods used by cloudflared.
// Consumers should accept this interface; producers should return [*ConnWithCloser].
type QUICConnection interface {
AcceptStream(ctx context.Context) (quic.Stream, error)
OpenStream() (quic.Stream, error)
OpenStreamSync(ctx context.Context) (quic.Stream, error)
CloseWithError(code quic.ApplicationErrorCode, reason string) error
Context() context.Context
SendDatagram(payload []byte) error
ReceiveDatagram(ctx context.Context) ([]byte, error)
LocalAddr() net.Addr
RemoteAddr() net.Addr
ConnectionState() quic.ConnectionState
}
// Compile-time assertion that *ConnWithCloser implements QUICConnection.
var _ QUICConnection = (*ConnWithCloser)(nil)
var (
// error returned when the [NewConnWithCloser] is called with a nil conn argument
ErrNilQuicConnection = errors.New("the provided quic connection is nil")
// error returned when the [NewConnWithCloser] is called with a nil closer argument
ErrNilCloser = errors.New("the provided closer is nil")
)
// ConnWithCloser wraps a [quic.Connection] and an [io.Closer] (typically the
// underlying [*net.UDPConn]). When [CloseWithError] is called the QUIC
// connection is closed first, then the closer is closed deterministically.
//
// A nil conn is only safe for [CloseWithError] (used in tests). All other
// delegated methods will panic on a nil conn.
type ConnWithCloser struct {
conn quic.Connection
closer io.Closer
}
// NewQUICConnection returns a [*ConnWithCloser] that will close closer after
// the QUIC connection is closed.
func NewQUICConnection(conn quic.Connection, closer io.Closer) (*ConnWithCloser, error) {
if conn == nil {
return nil, ErrNilQuicConnection
}
if closer == nil {
return nil, ErrNilCloser
}
return &ConnWithCloser{conn: conn, closer: closer}, nil
}
// CloseWithError closes the QUIC connection and then closes the underlying
// [io.Closer]. If both operations return errors, the errors are joined so that
// the closer error is no longer silently discarded.
func (c *ConnWithCloser) CloseWithError(code quic.ApplicationErrorCode, reason string) error {
connErr := c.conn.CloseWithError(code, reason)
closerErr := c.closer.Close()
return errors.Join(connErr, closerErr)
}
func (c *ConnWithCloser) AcceptStream(ctx context.Context) (quic.Stream, error) {
return c.conn.AcceptStream(ctx)
}
func (c *ConnWithCloser) OpenStream() (quic.Stream, error) {
return c.conn.OpenStream()
}
func (c *ConnWithCloser) OpenStreamSync(ctx context.Context) (quic.Stream, error) {
return c.conn.OpenStreamSync(ctx)
}
func (c *ConnWithCloser) Context() context.Context {
return c.conn.Context()
}
func (c *ConnWithCloser) SendDatagram(payload []byte) error {
return c.conn.SendDatagram(payload)
}
func (c *ConnWithCloser) ReceiveDatagram(ctx context.Context) ([]byte, error) {
return c.conn.ReceiveDatagram(ctx)
}
func (c *ConnWithCloser) LocalAddr() net.Addr {
return c.conn.LocalAddr()
}
func (c *ConnWithCloser) RemoteAddr() net.Addr {
return c.conn.RemoteAddr()
}
func (c *ConnWithCloser) ConnectionState() quic.ConnectionState {
return c.conn.ConnectionState()
}
+108
View File
@@ -0,0 +1,108 @@
package quic
import (
"errors"
"testing"
"github.com/quic-go/quic-go"
"github.com/stretchr/testify/require"
)
// mockCloser is an [io.Closer] that returns a configurable error.
type mockCloser struct {
closeErr error
}
func (m *mockCloser) Close() error {
return m.closeErr
}
// mockQuicConnection is a minimal test double for [quic.Connection].
type mockQuicConnection struct {
quic.Connection
closeWithErrorErr error
}
func (m *mockQuicConnection) CloseWithError(_ quic.ApplicationErrorCode, _ string) error {
return m.closeWithErrorErr
}
func TestNewConnWithCloser_NilConn(t *testing.T) {
t.Parallel()
conn, err := NewQUICConnection(nil, &mockCloser{})
require.ErrorIs(t, err, ErrNilQuicConnection)
require.Nil(t, conn)
}
func TestNewConnWithCloser_NilCloser(t *testing.T) {
t.Parallel()
conn, err := NewQUICConnection(&mockQuicConnection{}, nil)
require.ErrorIs(t, err, ErrNilCloser)
require.Nil(t, conn)
}
func TestNewConnWithCloser_Success(t *testing.T) {
t.Parallel()
qc := &mockQuicConnection{}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
require.NotNil(t, conn)
}
func TestConnWithCloser_CloseWithError_BothSucceed(t *testing.T) {
t.Parallel()
qc := &mockQuicConnection{}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.NoError(t, err)
}
func TestConnWithCloser_CloseWithError_QuicFails(t *testing.T) {
t.Parallel()
quicErr := errors.New("quic close failed")
qc := &mockQuicConnection{closeWithErrorErr: quicErr}
cl := &mockCloser{}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, quicErr)
}
func TestConnWithCloser_CloseWithError_CloserFails(t *testing.T) {
t.Parallel()
closerErr := errors.New("closer failed")
qc := &mockQuicConnection{}
cl := &mockCloser{closeErr: closerErr}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, closerErr)
}
func TestConnWithCloser_CloseWithError_BothFail(t *testing.T) {
t.Parallel()
quicErr := errors.New("quic close failed")
closerErr := errors.New("closer failed")
qc := &mockQuicConnection{closeWithErrorErr: quicErr}
cl := &mockCloser{closeErr: closerErr}
conn, err := NewQUICConnection(qc, cl)
require.NoError(t, err)
err = conn.CloseWithError(0, "test")
require.ErrorIs(t, err, quicErr)
require.ErrorIs(t, err, closerErr)
}
// TestConnWithCloser_ImplementsInterface is a runtime assertion that
// *ConnWithCloser satisfies QUICConnection. The compile-time assertion is in
// quic_connection.go.
func TestConnWithCloser_ImplementsInterface(t *testing.T) {
t.Parallel()
var _ QUICConnection = (*ConnWithCloser)(nil)
}