diff --git a/component-tests/test_prechecks.py b/component-tests/test_prechecks.py new file mode 100644 index 00000000..b47410cc --- /dev/null +++ b/component-tests/test_prechecks.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python +""" +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 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. + +They do NOT cover every failure mode of the precheck logic — those are owned +by the unit tests in prechecks/checker_test.go which use mock dialers. + +At the integration level the only reliable way to induce specific failure modes +without real firewall intervention is: + + - --edge : StaticEdgeDNSResolver resolves the literal IP + directly (DNS row = PASS), then both QUIC and HTTP/2 probes time out + -> hard fail (both transports blocked). + This does NOT exercise the DNS-failure -> transport-skip path. + +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. +""" + +import json +import os +import re +import subprocess +import time +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 + +# Component names (probes.go: componentXxx) +COMP_DNS = "DNS Resolution" +COMP_QUIC = "UDP Connectivity" +COMP_H2 = "TCP Connectivity" +COMP_API = "Cloudflare API" + +# Target labels used in the rendered table. +# +# probeRegion() (checker.go:216) always overwrites the Target field of +# whatever CheckResult the inner probe function returns with the regionTarget +# hostname, so QUIC and HTTP/2 rows carry the same region hostname as the +# corresponding DNS row — not the "Port 7844 (QUIC/HTTP2)" strings that +# targetPortQUIC/targetPortHTTP2 define. Those port-label constants are only +# used in the empty-addrs SKIP branch and inside action message strings. +TARGET_API = "api.cloudflare.com:443" +TARGET_REGION1 = "region1.v2.argotunnel.com" +TARGET_REGION2 = "region2.v2.argotunnel.com" + +# Details strings (probes.go: detailsXxx) +DETAILS_DNS_RESOLVED = "DNS Resolved successfully" +DETAILS_QUIC_OK = "QUIC connection successful" +DETAILS_HTTP2_OK = "HTTP/2 connection successful" +DETAILS_API_OK = "API is reachable" +DETAILS_QUIC_FAIL = "QUIC connection failed" +DETAILS_HTTP2_FAIL = "HTTP/2 connection is blocked or unreachable" + +# Status labels (result.go: xyzStatus) +PASS = "PASS" +FAIL = "FAIL" +SKIP = "SKIP" + +# Action prefixes (result.go: renderActions) +PREFIX_ERROR = "ERROR: " +PREFIX_WARNING = "WARNING: " + +# Action messages (probes.go: actionXxx) +ACTION_QUIC_BLOCKED = "Allow outbound QUIC traffic on port 7844 or use HTTP2." +ACTION_HTTP2_BLOCKED = "Allow outbound TCP on port 7844." + +# Exact summary lines (result.go: summaryLine) +SUMMARY_HEALTHY = "SUMMARY: Environment is healthy. cloudflared will use 'quic' as primary protocol." +SUMMARY_CRITICAL = "SUMMARY: Environment has critical failures. cloudflared may not be able to establish a tunnel." + +# structured log constants (result.go) + +LOG_MSG_PRECHECK = "precheck" +LOG_MSG_PRECHECK_COMPLETE = "precheck complete" +STATUS_PASS_LOG = "pass" + +UNREACHABLE_EDGE = "192.0.2.1:7844" + +# cloudflared dial timeout per probe: 5 s, up to 2 retries -> ~15 s total. +PRECHECK_POLL_TIMEOUT_SECS = 15 +PRECHECK_POLL_INTERVAL_SECS = 1 + +# ---------- helpers ---------- + +def _poll_log_file_for_precheck_complete(log_file: str, timeout: float) -> list[dict]: + """ + Poll a JSON log file until a 'precheck complete' line appears or timeout + expires. Returns all precheck-related log lines found. + + cloudflared's --logfile writes one JSON object per line. Polling keeps + the test fast on healthy networks and still tolerates slow CI hosts. + + We re-read from the beginning of the file on every poll because the file + is append-only, small, and tracking a byte offset would add complexity with + no meaningful performance benefit for a ~15 s total window. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + lines = _read_precheck_log_lines_from_file(log_file) + if any(l.get("message") == LOG_MSG_PRECHECK_COMPLETE for l in lines): + return lines + time.sleep(PRECHECK_POLL_INTERVAL_SECS) + return _read_precheck_log_lines_from_file(log_file) + + +def _read_precheck_log_lines_from_file(log_file: str) -> list[dict]: + """Parse all precheck-related JSON log lines from a --logfile path.""" + result = [] + try: + with open(log_file, "r") as f: + for raw_line in f: + raw_line = raw_line.strip() + if not raw_line: + continue + try: + obj = json.loads(raw_line) + except json.JSONDecodeError: + continue + msg = obj.get("message") or obj.get("msg", "") + if msg in (LOG_MSG_PRECHECK, LOG_MSG_PRECHECK_COMPLETE): + result.append(obj) + except FileNotFoundError: + pass + return result + + +# stdout table parse +class TableRow: + """One data row parsed from the rendered precheck table.""" + def __init__(self, component: str, target: str, status: str, details: str): + self.component = component + self.target = target + self.status = status + self.details = details + + def __repr__(self): + return f"TableRow({self.component!r}, {self.target!r}, {self.status!r}, {self.details!r})" + + +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. + """ + rows = [] + in_data = False + for line in stdout.splitlines(): + if line.startswith("COMPONENT"): + in_data = True + continue + if not in_data: + continue + if (line == "" or line.startswith("SUMMARY") or line.startswith("---") + or line.startswith("ERROR") or line.startswith("WARNING")): + in_data = False + continue + parts = re.split(r" +", line.rstrip()) + if len(parts) >= 3: + rows.append(TableRow( + component=parts[0], + target=parts[1], + status=parts[2], + details=parts[3] if len(parts) >= 4 else "", + )) + return rows + + +def _rows_for(rows: list[TableRow], component: str) -> list[TableRow]: + return [r for r in rows if r.component == component] + + +# log assertions + +def _assert_precheck_summary_log( + log_lines: list[dict], + *, + hard_fail: bool, + suggested_protocol: str | None = None, +): + """Assert the 'precheck complete' summary log line has the expected fields.""" + summary_lines = [l for l in log_lines if l.get("message") == LOG_MSG_PRECHECK_COMPLETE] + assert len(summary_lines) == 1, \ + f"Expected exactly one '{LOG_MSG_PRECHECK_COMPLETE}' log line; got {summary_lines}" + summary = summary_lines[0] + + assert summary.get("hard_fail") is hard_fail, \ + f"Expected hard_fail={hard_fail} in summary log: {summary}" + + if suggested_protocol is not None: + assert summary.get("suggested_protocol") == suggested_protocol, \ + (f"Expected suggested_protocol={suggested_protocol!r}; " + f"got {summary.get('suggested_protocol')!r}") + + +# ---------- Tests ---------- + +class TestPrechecksHappyPath: + """ + On a healthy connection all probes pass. We assert: + - the full table structure (header, column header, separator) + - every row's component, target, status, and details + - no ERROR/WARNING action lines + - the exact summary line + - the structured log summary (hard_fail=false, suggested_protocol=quic) + """ + + def test_prechecks_pass_on_healthy_connection(self, tmp_path, component_tests_config): + log_file = str(tmp_path / "cloudflared.log") + config = component_tests_config({"logfile": log_file}) + + with start_cloudflared( + tmp_path, + config, + cfd_pre_args=["tunnel", "--ha-connections", "1"], + cfd_args=["run"], + new_process=True, + capture_output=True, + ) as cfd: + wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) + # Poll the log file for the sentinel before signalling the process. + log_lines = _poll_log_file_for_precheck_complete( + log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS + ) + # Signal shutdown. + cfd.terminate() + + # The process is now dead. All output was captured by the background + # reader thread into cfd.stdout_lines (stderr is merged into stdout). + stdout = b"".join(cfd.stdout_lines).decode(errors="replace") + + LOGGER.debug(f"[happy-path] stdout:\n{stdout}") + LOGGER.debug(f"[happy-path] log_lines:\n{log_lines}") + + # ── 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, \ + f"Expected column header row in output;\ngot:\n{stdout}" + assert SEPARATOR in stdout, \ + f"Expected closing separator in output;\ngot:\n{stdout}" + + # ── row content ────────────────────────────────────────────────────── + rows = _parse_table(stdout) + assert len(rows) == 7, \ + f"Expected 7 rows (2 DNS + 2 QUIC + 2 HTTP/2 + 1 API); got {len(rows)}: {rows}" + + dns_rows = _rows_for(rows, COMP_DNS) + assert len(dns_rows) == 2, f"Expected 2 DNS rows; got {dns_rows}" + assert dns_rows[0].target == TARGET_REGION1 + assert dns_rows[1].target == TARGET_REGION2 + for r in dns_rows: + assert r.status == PASS, f"DNS row not PASS: {r}" + assert r.details == DETAILS_DNS_RESOLVED, f"DNS row details wrong: {r}" + + quic_rows = _rows_for(rows, COMP_QUIC) + assert len(quic_rows) == 2, f"Expected 2 QUIC rows; got {quic_rows}" + assert quic_rows[0].target == TARGET_REGION1, f"QUIC row[0] target wrong: {quic_rows[0]}" + assert quic_rows[1].target == TARGET_REGION2, f"QUIC row[1] target wrong: {quic_rows[1]}" + for r in quic_rows: + assert r.status == PASS, f"QUIC row not PASS: {r}" + assert r.details == DETAILS_QUIC_OK, f"QUIC row details wrong: {r}" + + h2_rows = _rows_for(rows, COMP_H2) + assert len(h2_rows) == 2, f"Expected 2 HTTP/2 rows; got {h2_rows}" + assert h2_rows[0].target == TARGET_REGION1, f"HTTP/2 row[0] target wrong: {h2_rows[0]}" + assert h2_rows[1].target == TARGET_REGION2, f"HTTP/2 row[1] target wrong: {h2_rows[1]}" + for r in h2_rows: + assert r.status == PASS, f"HTTP/2 row not PASS: {r}" + assert r.details == DETAILS_HTTP2_OK, f"HTTP/2 row details wrong: {r}" + + api_rows = _rows_for(rows, COMP_API) + assert len(api_rows) == 1, f"Expected 1 API row; got {api_rows}" + assert api_rows[0].target == TARGET_API, f"API row target wrong: {api_rows[0]}" + 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]}" + + # ── 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}" + + # ── exact summary line ─────────────────────────────────────────────── + assert SUMMARY_HEALTHY in stdout, \ + f"Expected healthy summary;\ngot:\n{stdout}" + + # ── structured log ─────────────────────────────────────────────────── + assert len(log_lines) > 0, \ + "Expected at least one structured precheck log line in log file" + for line in log_lines: + if line.get("message") == LOG_MSG_PRECHECK: + assert line.get("status") == STATUS_PASS_LOG, \ + f"Expected status=pass in precheck log line: {line}" + _assert_precheck_summary_log(log_lines, hard_fail=False, suggested_protocol="quic") + + +class TestPrechecksHardFail: + """ + When --edge points at an unreachable IP, StaticEdgeDNSResolver resolves + the literal address directly (DNS row = PASS), but both transport probes + time out -> hard fail. We assert: + - the full table structure + - DNS row: PASS (the literal IP was resolved) + - QUIC row: FAIL with correct details + ERROR action + - HTTP/2 row: FAIL with correct details + ERROR action + - API row: PASS (api.cloudflare.com:443 is independently reachable) + - the exact critical summary line + - the structured log summary (hard_fail=true) + + This test does NOT call wait_tunnel_ready because the tunnel will not + connect to the unreachable address. + """ + + def test_prechecks_hard_fail_when_edge_unreachable(self, tmp_path, component_tests_config): + log_file = str(tmp_path / "cloudflared.log") + config = component_tests_config({"logfile": log_file}) + + with start_cloudflared( + tmp_path, + config, + cfd_pre_args=[ + "tunnel", + "--ha-connections", "1", + "--edge", UNREACHABLE_EDGE, + ], + cfd_args=["run"], + new_process=True, + capture_output=True, + ) as cfd: + log_lines = _poll_log_file_for_precheck_complete( + log_file, timeout=PRECHECK_POLL_TIMEOUT_SECS + ) + cfd.terminate() + + stdout = b"".join(cfd.stdout_lines).decode(errors="replace") + + LOGGER.debug(f"[hard-fail] stdout:\n{stdout}") + LOGGER.debug(f"[hard-fail] log_lines:\n{log_lines}") + + # ── 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, \ + f"Expected column header row in output;\ngot:\n{stdout}" + assert SEPARATOR in stdout, \ + f"Expected closing separator in output;\ngot:\n{stdout}" + + # ── row content ────────────────────────────────────────────────────── + rows = _parse_table(stdout) + assert len(rows) == 4, \ + f"Expected 4 rows (1 DNS + 1 QUIC + 1 HTTP/2 + 1 API); got {len(rows)}: {rows}" + + dns_rows = _rows_for(rows, COMP_DNS) + assert len(dns_rows) == 1, f"Expected 1 DNS row; got {dns_rows}" + assert dns_rows[0].target == UNREACHABLE_EDGE + assert dns_rows[0].status == PASS, f"DNS row not PASS: {dns_rows[0]}" + assert dns_rows[0].details == DETAILS_DNS_RESOLVED, f"DNS row details wrong: {dns_rows[0]}" + + quic_rows = _rows_for(rows, COMP_QUIC) + assert len(quic_rows) == 1, f"Expected 1 QUIC row; got {quic_rows}" + assert quic_rows[0].target == UNREACHABLE_EDGE, f"QUIC row target wrong: {quic_rows[0]}" + assert quic_rows[0].status == FAIL, f"QUIC row not FAIL: {quic_rows[0]}" + assert quic_rows[0].details == DETAILS_QUIC_FAIL, f"QUIC row details wrong: {quic_rows[0]}" + + h2_rows = _rows_for(rows, COMP_H2) + assert len(h2_rows) == 1, f"Expected 1 HTTP/2 row; got {h2_rows}" + assert h2_rows[0].target == UNREACHABLE_EDGE, f"HTTP/2 row target wrong: {h2_rows[0]}" + assert h2_rows[0].status == FAIL, f"HTTP/2 row not FAIL: {h2_rows[0]}" + assert h2_rows[0].details == DETAILS_HTTP2_FAIL, f"HTTP/2 row details wrong: {h2_rows[0]}" + + api_rows = _rows_for(rows, COMP_API) + assert len(api_rows) == 1, f"Expected 1 API row; got {api_rows}" + assert api_rows[0].target == TARGET_API, f"API row target wrong: {api_rows[0]}" + 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, \ + f"Expected QUIC ERROR action;\ngot:\n{stdout}" + assert f"{PREFIX_ERROR}{ACTION_HTTP2_BLOCKED}" in stdout, \ + f"Expected HTTP/2 ERROR action;\ngot:\n{stdout}" + + assert SUMMARY_CRITICAL in stdout, \ + f"Expected critical summary;\ngot:\n{stdout}" + + _assert_precheck_summary_log(log_lines, hard_fail=True, suggested_protocol=None) + + +class TestPreChecksDiag: + """ + Verify that `cloudflared tunnel diag` includes prechecks.json in the + diagnostic zip archive produced against a live tunnel instance. + + The precheck job in diagnostic.go is gated on noDiagNetwork; we do NOT + pass --no-diag-network so prechecks.json must be present. We skip the + heavier collectors (logs, metrics, system, runtime) to keep the test fast. + + The diag subcommand writes the zip to its current working directory. We + run it with cwd=tmp_path so the archive lands there and is cleaned up + automatically by pytest. We resolve config.cloudflared_binary to an + absolute path before changing cwd, because the binary path may be relative + to the original working directory. + """ + + def test_diag_contains_prechecks_json(self, tmp_path, component_tests_config): + config = component_tests_config() + binary = os.path.abspath(config.cloudflared_binary) + + with start_cloudflared( + tmp_path, + config, + cfd_pre_args=["tunnel", "--ha-connections", "1"], + cfd_args=["run"], + new_process=True, + capture_output=True, + ) as cfd: + wait_tunnel_ready(tunnel_url=config.get_url(), require_min_connections=1) + + # Run the diag subcommand as a one-shot process against the + # already-running instance. We skip log/metrics/system/runtime + # collectors; the network collector (which runs prechecks) is left + # enabled. + diag_result = subprocess.run( + [ + binary, + "tunnel", + "diag", + "--metrics", f"localhost:{METRICS_PORT}", + "--no-diag-logs", + "--no-diag-metrics", + "--no-diag-system", + "--no-diag-runtime", + ], + cwd=str(tmp_path), + capture_output=True, + timeout=60, + ) + + cfd.terminate() + + diag_stdout = diag_result.stdout.decode(errors="replace") + diag_stderr = diag_result.stderr.decode(errors="replace") + LOGGER.debug(f"[diag] stdout:\n{diag_stdout}") + LOGGER.debug(f"[diag] stderr:\n{diag_stderr}") + + assert diag_result.returncode == 0, ( + f"cloudflared tunnel diag exited with code {diag_result.returncode}\n" + f"stdout:\n{diag_stdout}\nstderr:\n{diag_stderr}" + ) + + # Locate the zip file written to tmp_path by the diag command. + zip_files = list(tmp_path.glob("cloudflared-diag-*.zip")) + assert len(zip_files) == 1, \ + f"Expected exactly one cloudflared-diag-*.zip in {tmp_path}; found {zip_files}" + + zip_path = zip_files[0] + with zipfilemod.ZipFile(zip_path) as zf: + names = zf.namelist() + LOGGER.debug(f"[diag] zip contents: {names}") + + assert "prechecks.json" in names, \ + f"Expected prechecks.json in diag zip; got: {names}" + + # Must be valid JSON containing at least the RunID field that + # prechecks.Run() always sets. + with zf.open("prechecks.json") as fh: + data = json.load(fh) + + assert "RunID" in data, \ + f"Expected RunID key in prechecks.json; got keys: {list(data.keys())}" diff --git a/prechecks/probes.go b/prechecks/probes.go index 8142618f..72a07480 100644 --- a/prechecks/probes.go +++ b/prechecks/probes.go @@ -25,7 +25,7 @@ const ( // Action messages for each probe outcome. actionDNSFail = "Ensure your DNS resolver can resolve '%s'. Run: dig A %s @1.1.1.1. If that fails, contact your network administrator." - actionQUICBlocked = "QUIC traffic failed to connect to port 7844." + actionQUICBlocked = "Allow outbound QUIC traffic on port 7844 or use HTTP2." actionHTTP2Blocked = "Allow outbound TCP on port 7844." actionAPIUnreachable = "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." @@ -43,16 +43,16 @@ const ( noDNSTarget = "No DNS target (Using edge flag)" // Details messages for CheckResult. - detailsNoAddressesReturned = "No addresses returned" - detailsResolvedSuccessfully = "Resolved successfully" - detailsHandshakeFailed = "Handshake failed" - detailsHandshakeSuccessful = "Handshake successful" - detailsBlockedOrUnreachable = "Blocked or unreachable" - detailsTLSHandshakeSuccessful = "TLS handshake successful" - detailsConnectionFailed = "Connection failed" - detailsTCPPortReachable = "TCP port reachable (TLS not validated)" - detailsDNSPrerequisiteFailed = "DNS prerequisite failed" - detailsTLSConfigFailed = "TLS configuration failed" + dnsNoAddressesReturned = "No addresses returned" + dnsResolvedSuccessfully = "DNS Resolved successfully" + detailsQUICHandshakeFailed = "QUIC connection failed" + detailsQUICHandshakeSuccessful = "QUIC connection successful" + detailsHTTP2BlockedOrUnreachable = "HTTP/2 connection is blocked or unreachable" + detailsHTTP2HandshakeSuccessful = "HTTP/2 connection successful" + detailsAPIConnectionFailed = "API Connection failed" + detailsApiReachable = "API is reachable" + detailsDNSPrerequisiteFailed = "DNS prerequisite failed" + detailsTLSConfigFailed = "TLS configuration failed" // Region hostname templates. region1Global = "region1.v2.argotunnel.com" @@ -140,7 +140,7 @@ func probeDNS( addrGroups, err := resolver.Resolve(region) if err != nil || len(addrGroups) == 0 { - detail := detailsNoAddressesReturned + detail := dnsNoAddressesReturned if err != nil { detail = err.Error() } @@ -158,12 +158,12 @@ func probeDNS( group := addrGroups[i] if len(group) == 0 { resolved = append(resolved, ResolvedTarget{ - DNSResult: newDNSCheckResult(target, Fail, detailsNoAddressesReturned, fmt.Sprintf(actionDNSFail, target, target)), + DNSResult: newDNSCheckResult(target, Fail, dnsNoAddressesReturned, fmt.Sprintf(actionDNSFail, target, target)), }) } else { resolved = append(resolved, ResolvedTarget{ Addrs: group, - DNSResult: newDNSCheckResult(target, Pass, detailsResolvedSuccessfully, ""), + DNSResult: newDNSCheckResult(target, Pass, dnsResolvedSuccessfully, ""), }) } } @@ -209,7 +209,7 @@ func probeQUIC( Component: componentUDPConnectivity, Target: targetPortQUIC, ProbeStatus: Fail, - Details: detailsHandshakeFailed, + Details: detailsQUICHandshakeFailed, Action: actionQUICBlocked, } } @@ -223,7 +223,7 @@ func probeQUIC( Component: componentUDPConnectivity, Target: targetPortQUIC, ProbeStatus: Pass, - Details: detailsHandshakeSuccessful, + Details: detailsQUICHandshakeSuccessful, } } @@ -243,7 +243,7 @@ func probeHTTP2(ctx context.Context, tlsConfig *tls.Config, dialer TCPDialer, ad Component: componentTCPConnectivity, Target: targetPortHTTP2, ProbeStatus: Fail, - Details: detailsBlockedOrUnreachable, + Details: detailsHTTP2BlockedOrUnreachable, Action: actionHTTP2Blocked, } } @@ -254,7 +254,7 @@ func probeHTTP2(ctx context.Context, tlsConfig *tls.Config, dialer TCPDialer, ad Component: componentTCPConnectivity, Target: targetPortHTTP2, ProbeStatus: Pass, - Details: detailsTLSHandshakeSuccessful, + Details: detailsHTTP2HandshakeSuccessful, } } @@ -273,7 +273,7 @@ func probeManagementAPI(ctx context.Context, dialer ManagementDialer) CheckResul Component: componentCloudflareAPI, Target: targetAPI, ProbeStatus: Fail, - Details: detailsConnectionFailed, + Details: detailsAPIConnectionFailed, Action: actionAPIUnreachable, } } @@ -284,7 +284,7 @@ func probeManagementAPI(ctx context.Context, dialer ManagementDialer) CheckResul Component: componentCloudflareAPI, Target: targetAPI, ProbeStatus: Pass, - Details: detailsTCPPortReachable, + Details: detailsApiReachable, } } @@ -363,11 +363,11 @@ func resolveStaticEdge(addrs []string, log *zerolog.Logger) []ResolvedTarget { if len(resolved) > 0 { targets = append(targets, ResolvedTarget{ Addrs: resolved, - DNSResult: newDNSCheckResult(addr, Pass, detailsResolvedSuccessfully, ""), + DNSResult: newDNSCheckResult(addr, Pass, dnsResolvedSuccessfully, ""), }) } else { targets = append(targets, ResolvedTarget{ - DNSResult: newDNSCheckResult(addr, Fail, detailsNoAddressesReturned, fmt.Sprintf(actionDNSFail, addr, addr)), + DNSResult: newDNSCheckResult(addr, Fail, dnsNoAddressesReturned, fmt.Sprintf(actionDNSFail, addr, addr)), }) } } diff --git a/prechecks/probes_test.go b/prechecks/probes_test.go index 7a350f91..1d2375b2 100644 --- a/prechecks/probes_test.go +++ b/prechecks/probes_test.go @@ -124,7 +124,7 @@ func TestProbeDNS_Success(t *testing.T) { assert.Equal(t, ProbeTypeDNS, targets[0].DNSResult.Type) assert.Equal(t, testRegion1Global, targets[0].DNSResult.Target) assert.Equal(t, Pass, targets[0].DNSResult.ProbeStatus) - assert.Equal(t, detailsResolvedSuccessfully, targets[0].DNSResult.Details) + assert.Equal(t, dnsResolvedSuccessfully, targets[0].DNSResult.Details) } func TestProbeDNS_MultipleRegions(t *testing.T) { @@ -184,7 +184,7 @@ func TestProbeDNS_EmptyResults(t *testing.T) { require.Len(t, targets, 2) assert.Empty(t, targets[0].Addrs) assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus) - assert.Equal(t, detailsNoAddressesReturned, targets[0].DNSResult.Details) + assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details) } func TestProbeDNS_EmptyGroup(t *testing.T) { @@ -200,7 +200,7 @@ func TestProbeDNS_EmptyGroup(t *testing.T) { require.Len(t, targets, 1) assert.Empty(t, targets[0].Addrs) assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus) - assert.Equal(t, detailsNoAddressesReturned, targets[0].DNSResult.Details) + assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details) } func TestProbeDNS_RegionFlag(t *testing.T) { @@ -236,7 +236,7 @@ func TestProbeQUIC_Success(t *testing.T) { assert.Equal(t, ProbeTypeQUIC, result.Type) assert.Equal(t, Pass, result.ProbeStatus) - assert.Equal(t, detailsHandshakeSuccessful, result.Details) + assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details) } func TestProbeQUIC_DialError(t *testing.T) { @@ -254,7 +254,7 @@ func TestProbeQUIC_DialError(t *testing.T) { assert.Equal(t, ProbeTypeQUIC, result.Type) assert.Equal(t, Fail, result.ProbeStatus) - assert.Equal(t, detailsHandshakeFailed, result.Details) + assert.Equal(t, detailsQUICHandshakeFailed, result.Details) assert.Equal(t, actionQUICBlocked, result.Action) } @@ -274,7 +274,7 @@ func TestProbeQUIC_CloseErrorDoesNotAffectResult(t *testing.T) { assert.Equal(t, ProbeTypeQUIC, result.Type) assert.Equal(t, Pass, result.ProbeStatus) - assert.Equal(t, detailsHandshakeSuccessful, result.Details) + assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details) } func TestProbeQUIC_ContextTimeout(t *testing.T) { @@ -291,7 +291,7 @@ func TestProbeQUIC_ContextTimeout(t *testing.T) { result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger) assert.Equal(t, Fail, result.ProbeStatus) - assert.Equal(t, detailsHandshakeFailed, result.Details) + assert.Equal(t, detailsQUICHandshakeFailed, result.Details) } // probeHTTP2 tests. @@ -310,7 +310,7 @@ func TestProbeHTTP2_Success(t *testing.T) { assert.Equal(t, ProbeTypeHTTP2, result.Type) assert.Equal(t, Pass, result.ProbeStatus) - assert.Equal(t, detailsTLSHandshakeSuccessful, result.Details) + assert.Equal(t, detailsHTTP2HandshakeSuccessful, result.Details) } func TestProbeHTTP2_DialError(t *testing.T) { @@ -327,7 +327,7 @@ func TestProbeHTTP2_DialError(t *testing.T) { assert.Equal(t, ProbeTypeHTTP2, result.Type) assert.Equal(t, Fail, result.ProbeStatus) - assert.Equal(t, detailsBlockedOrUnreachable, result.Details) + assert.Equal(t, detailsHTTP2BlockedOrUnreachable, result.Details) assert.Equal(t, actionHTTP2Blocked, result.Action) } @@ -347,7 +347,7 @@ func TestProbeManagementAPI_Success(t *testing.T) { assert.Equal(t, "Cloudflare API", result.Component) assert.Equal(t, "api.cloudflare.com:443", result.Target) assert.Equal(t, Pass, result.ProbeStatus) - assert.Equal(t, detailsTCPPortReachable, result.Details) + assert.Equal(t, detailsApiReachable, result.Details) } func TestProbeManagementAPI_DialError(t *testing.T) { @@ -362,7 +362,7 @@ func TestProbeManagementAPI_DialError(t *testing.T) { assert.Equal(t, ProbeTypeManagementAPI, result.Type) assert.Equal(t, Fail, result.ProbeStatus) - assert.Equal(t, detailsConnectionFailed, result.Details) + assert.Equal(t, detailsAPIConnectionFailed, result.Details) assert.Equal(t, actionAPIUnreachable, result.Action) } @@ -516,7 +516,7 @@ func TestProbeQUIC_IPv6Address(t *testing.T) { result := probeQUIC(context.Background(), testTLSConfig, dialer, addr, &logger) assert.Equal(t, Pass, result.ProbeStatus) - assert.Equal(t, detailsHandshakeSuccessful, result.Details) + assert.Equal(t, detailsQUICHandshakeSuccessful, result.Details) } // IPv6 address tests for probeHTTP2. @@ -571,7 +571,7 @@ func TestResolveStaticEdge_InvalidAddr(t *testing.T) { require.Len(t, targets, 1) assert.Equal(t, "not-a-valid-addr", targets[0].DNSResult.Target) assert.Equal(t, Fail, targets[0].DNSResult.ProbeStatus) - assert.Equal(t, detailsNoAddressesReturned, targets[0].DNSResult.Details) + assert.Equal(t, dnsNoAddressesReturned, targets[0].DNSResult.Details) assert.Empty(t, targets[0].Addrs) } diff --git a/prechecks/result_test.go b/prechecks/result_test.go index 650f9d19..8b65214b 100644 --- a/prechecks/result_test.go +++ b/prechecks/result_test.go @@ -25,11 +25,11 @@ func allPassReport() Report { RunID: fixedRunID, SuggestedProtocol: new(connection.QUIC), Results: []CheckResult{ - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: "Handshake successful"}, - {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, - {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: detailsQUICHandshakeSuccessful}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable}, }, } } @@ -41,18 +41,18 @@ func quicBlockedReport() Report { RunID: fixedRunID, SuggestedProtocol: new(connection.HTTP2), Results: []CheckResult{ - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, { Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Fail, - Details: "Handshake failed", - Action: "Allow outbound QUIC on port 7844. cloudflared will use http2 in the meantime.", + Details: detailsQUICHandshakeFailed, + Action: actionQUICBlocked, }, - {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, - {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable}, }, } } @@ -64,10 +64,10 @@ func apiFailReport() Report { RunID: fixedRunID, SuggestedProtocol: new(connection.QUIC), Results: []CheckResult{ - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: "Handshake successful"}, - {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: "TLS handshake successful"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Pass, Details: detailsQUICHandshakeSuccessful}, + {Type: ProbeTypeHTTP2, Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Pass, Details: detailsHTTP2HandshakeSuccessful}, { Type: ProbeTypeManagementAPI, Component: "Cloudflare API", @@ -86,14 +86,14 @@ func bothTransportsBlockedReport() Report { RunID: fixedRunID, SuggestedProtocol: nil, Results: []CheckResult{ - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, - {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: "Resolved successfully"}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region1.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, + {Type: ProbeTypeDNS, Component: "DNS Resolution", Target: "region2.v2.argotunnel.com", ProbeStatus: Pass, Details: dnsResolvedSuccessfully}, { Type: ProbeTypeQUIC, Component: "UDP Connectivity", Target: "Port 7844 (QUIC)", ProbeStatus: Fail, - Details: "Handshake failed", + Details: detailsQUICHandshakeFailed, Action: "Allow outbound QUIC and/or TCP on port 7844 to the Cloudflare edge.", }, { @@ -101,9 +101,9 @@ func bothTransportsBlockedReport() Report { Component: "TCP Connectivity", Target: "Port 7844 (HTTP/2)", ProbeStatus: Fail, - Details: "Blocked or unreachable", + Details: detailsHTTP2BlockedOrUnreachable, }, - {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: "Reachable"}, + {Type: ProbeTypeManagementAPI, Component: "Cloudflare API", Target: "api.cloudflare.com:443", ProbeStatus: Pass, Details: detailsApiReachable}, }, } } @@ -137,11 +137,11 @@ func TestString_AllPass(t *testing.T) { want := "" + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + "COMPONENT TARGET STATUS DETAILS\n" + - "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + - "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + - "UDP Connectivity Port 7844 (QUIC) PASS Handshake successful\n" + - "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\n" + - "Cloudflare API api.cloudflare.com:443 PASS Reachable\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" @@ -153,12 +153,12 @@ func TestString_QuicBlocked(t *testing.T) { want := "" + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + "COMPONENT TARGET STATUS DETAILS\n" + - "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + - "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + - "UDP Connectivity Port 7844 (QUIC) FAIL Handshake failed\n" + - "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\n" + - "Cloudflare API api.cloudflare.com:443 PASS Reachable\n" + - "WARNING: Allow outbound QUIC on port 7844. cloudflared will use http2 in the meantime.\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" @@ -170,10 +170,10 @@ func TestString_APIFail(t *testing.T) { want := "" + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + "COMPONENT TARGET STATUS DETAILS\n" + - "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + - "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + - "UDP Connectivity Port 7844 (QUIC) PASS Handshake successful\n" + - "TCP Connectivity Port 7844 (HTTP/2) PASS TLS handshake successful\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" + @@ -187,11 +187,11 @@ func TestString_BothTransportsBlocked(t *testing.T) { want := "" + "--- CONNECTIVITY PRE-CHECKS ----------------------------------------------------\n" + "COMPONENT TARGET STATUS DETAILS\n" + - "DNS Resolution region1.v2.argotunnel.com PASS Resolved successfully\n" + - "DNS Resolution region2.v2.argotunnel.com PASS Resolved successfully\n" + - "UDP Connectivity Port 7844 (QUIC) FAIL Handshake failed\n" + - "TCP Connectivity Port 7844 (HTTP/2) FAIL Blocked or unreachable\n" + - "Cloudflare API api.cloudflare.com:443 PASS Reachable\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" + @@ -276,11 +276,11 @@ func TestLogEvent_AllPass(t *testing.T) { status string details string }{ - {"DNS Resolution", "region1.v2.argotunnel.com", "pass", "Resolved successfully"}, - {"DNS Resolution", "region2.v2.argotunnel.com", "pass", "Resolved successfully"}, - {"UDP Connectivity", "Port 7844 (QUIC)", "pass", "Handshake successful"}, - {"TCP Connectivity", "Port 7844 (HTTP/2)", "pass", "TLS handshake successful"}, - {"Cloudflare API", "api.cloudflare.com:443", "pass", "Reachable"}, + {"DNS Resolution", "region1.v2.argotunnel.com", "pass", dnsResolvedSuccessfully}, + {"DNS Resolution", "region2.v2.argotunnel.com", "pass", dnsResolvedSuccessfully}, + {"UDP Connectivity", "Port 7844 (QUIC)", "pass", detailsQUICHandshakeSuccessful}, + {"TCP Connectivity", "Port 7844 (HTTP/2)", "pass", detailsHTTP2HandshakeSuccessful}, + {"Cloudflare API", "api.cloudflare.com:443", "pass", detailsApiReachable}, } for i, exp := range expected { e := entries[i] @@ -312,7 +312,7 @@ func TestLogEvent_QuicBlocked(t *testing.T) { assert.Equal(t, "fail", quic.Status) assert.Equal(t, "UDP Connectivity", quic.Component) assert.Equal(t, "Port 7844 (QUIC)", quic.Target) - assert.Equal(t, "Handshake failed", quic.Details) + assert.Equal(t, "QUIC connection failed", quic.Details) assert.Equal(t, fixedRunID.String(), quic.RunID) // Summary: not a hard fail (HTTP/2 still works), protocol falls back to http2. @@ -354,9 +354,9 @@ func TestLogEvent_BothTransportsBlocked(t *testing.T) { // Both transport rows carry status=fail. assert.Equal(t, "fail", entries[2].Status) - assert.Equal(t, "Handshake failed", entries[2].Details) + assert.Equal(t, "QUIC connection failed", entries[2].Details) assert.Equal(t, "fail", entries[3].Status) - assert.Equal(t, "Blocked or unreachable", entries[3].Details) + assert.Equal(t, "HTTP/2 connection is blocked or unreachable", entries[3].Details) summary := entries[len(entries)-1] require.NotNil(t, summary.HardFail) diff --git a/supervisor/tunnel.go b/supervisor/tunnel.go index fc2f0c46..2a342b78 100644 --- a/supervisor/tunnel.go +++ b/supervisor/tunnel.go @@ -66,9 +66,6 @@ type TunnelConfig struct { // NoPrechecks disables connectivity pre-checks at startup. NoPrechecks bool - // Prechecks enables connectivity pre-checks at startup. - Prechecks bool - NamedTunnel *connection.TunnelProperties ProtocolSelector connection.ProtocolSelector EdgeTLSConfigs map[connection.Protocol]*tls.Config