diff --git a/.github/scripts/check-demo-reachability.sh b/.github/scripts/check-demo-reachability.sh index d3323b964..90cd21b1c 100755 --- a/.github/scripts/check-demo-reachability.sh +++ b/.github/scripts/check-demo-reachability.sh @@ -8,21 +8,29 @@ TCP_PORT="${DEMO_SERVER_PORT:-22}" TCP_ATTEMPTS="${DEMO_TCP_ATTEMPTS:-6}" TCP_RETRY_SECONDS="${DEMO_TCP_RETRY_SECONDS:-5}" +if [ "$MODE" != "check" ] && [ "$MODE" != "diagnose" ]; then + echo "Usage: $0 [check|diagnose]" >&2 + exit 2 +fi + +# Only local daemon state is collected here. Never print tailnet addresses, +# names, tags, relay locations or raw CLI errors into public workflow logs. +# Success means the daemon is running, not that the demo is reachable. print_safe_status() { - local status_file + local status_file status_result=0 if ! command -v tailscale >/dev/null 2>&1; then echo "Tailscale CLI is not available." - return 0 + return 1 fi status_file="$(mktemp)" if ! tailscale status --json >"$status_file" 2>/dev/null; then echo "Tailscale status JSON is unavailable." rm -f "$status_file" - return 0 + return 1 fi - python3 - "$DEMO_SERVER_HOST" "$status_file" <<'PY' || true + python3 - "$DEMO_SERVER_HOST" "$status_file" <<'PY' || status_result=$? import json import sys @@ -30,66 +38,69 @@ host = sys.argv[1] try: with open(sys.argv[2], encoding="utf-8") as status_file: status = json.load(status_file) -except (json.JSONDecodeError, OSError): +except (ValueError, OSError): print("Tailscale status JSON is unavailable.") - raise SystemExit(0) + raise SystemExit(1) + +states = {"NoState", "InUseOtherUser", "NeedsLogin", "NeedsMachineAuth", "Stopped", "Starting", "Running"} +state = status.get("BackendState") if isinstance(status, dict) else None +if not isinstance(state, str) or state not in states: + print("Tailscale backend: unknown") + raise SystemExit(1) +print(f"Tailscale backend: {state}") +if state != "Running": + raise SystemExit(1) -self_node = status.get("Self") or {} -self_ips = [ip for ip in self_node.get("TailscaleIPs") or [] if ":" not in ip] -self_dns = (self_node.get("DNSName") or "").rstrip(".") -self_tags = sorted(self_node.get("Tags") or []) target = None -for peer in (status.get("Peer") or {}).values(): - peer_ips = peer.get("TailscaleIPs") or [] - peer_dns = (peer.get("DNSName") or "").rstrip(".") +peers = status.get("Peer") +for peer in peers.values() if isinstance(peers, dict) else (): + if not isinstance(peer, dict): + continue + peer_ips = peer.get("TailscaleIPs") + peer_ips = peer_ips if isinstance(peer_ips, list) else [] + peer_dns = peer.get("DNSName") + peer_dns = peer_dns.rstrip(".") if isinstance(peer_dns, str) else "" if host in peer_ips or host.rstrip(".") == peer_dns: target = peer break -print(f"Tailscale backend: {status.get('BackendState', 'unknown')}") -print(f"Runner Tailscale IPv4: {self_ips[0] if self_ips else 'unavailable'}") -print(f"Runner Tailscale DNS: {self_dns or 'unavailable'}") -print(f"Runner Tailscale tags: {','.join(self_tags) if self_tags else 'none'}") if target is None: print("Demo peer is not present in the runner peer map yet.") else: print( "Demo peer state: " - f"online={bool(target.get('Online'))} " - f"active={bool(target.get('Active'))} " - f"relay={target.get('Relay') or 'none'}" + f"online={target.get('Online') is True} " + f"active={target.get('Active') is True}" ) PY rm -f "$status_file" -} - -diagnose() { - print_safe_status - if command -v tailscale >/dev/null 2>&1; then - tailscale ping --c 1 --timeout 5s "$DEMO_SERVER_HOST" || true - fi - nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT" || true + return "$status_result" } if [ "$MODE" = "diagnose" ]; then - diagnose + # Both callers use this after the setup action fails. A provider refusal must + # not become another network attempt or a direct-host fallback diagnostic. + print_safe_status || true + echo "Diagnostic mode is local-only; demo connectivity and installed state remain unverified." exit 0 fi -if [ "$MODE" != "check" ]; then - echo "Usage: $0 [check|diagnose]" >&2 - exit 2 + +if ! print_safe_status; then + echo "::error::Tailscale setup has not established a running backend; no demo reachability probes attempted. This is not evidence that the demo host is down." + exit 1 fi -print_safe_status - -if ! tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"; then +# Probe output can contain private peer names/addresses or raw client errors. +# Retain the exit verdict, not that topology, in public workflow diagnostics. +if ! tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST" >/dev/null 2>&1; then echo "::error::Tailscale cannot reach the demo peer. Verify that the workflow tag is authorized to reach the demo host tag and that the peer is online." - diagnose + # Never try TCP after a failed tailnet readiness probe, or replay that probe + # as a diagnostic. Preserve the failed result for the workflow owner. exit 1 fi for attempt in $(seq 1 "$TCP_ATTEMPTS"); do - if nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"; then + if nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT" >/dev/null 2>&1; then echo "Demo SSH transport is reachable over Tailscale." exit 0 fi @@ -101,5 +112,4 @@ for attempt in $(seq 1 "$TCP_ATTEMPTS"); do done echo "::error::Tailscale reached the demo peer, but TCP/${TCP_PORT} remained closed. Verify sshd and the host firewall on tailscale0." -diagnose exit 1 diff --git a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md index 03af0d530..d53e286ff 100644 --- a/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md +++ b/docs/release-control/v6/internal/HIGH_RISK_RELEASE_VERIFICATION_MATRIX.md @@ -1029,6 +1029,14 @@ Companion drill: complete missing-package list is a failed boundary check, not qualification. `cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test` `go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1` + - `TestDemoReachabilityStopsBeforeUnreadyOrFailedTailnetProbes` executes + synthetic local daemon/probe commands: failed setup diagnostics never + contact the host, non-running/malformed status cannot pass readiness, + failed tailnet ping cannot fall back to TCP, and workflow output excludes + private topology, including stdout/stderr from both probes on success or + failure. Running-state success still requires both ping and SSH + transport, with bounded TCP failures retained. This is offline helper + correctness, not live OAuth access or installed demo acceptance. - Manual scenario: - Compare the exact selected release notes with the install-metadata verdict. Authored grouped notes must retain version identity, unsigned-Windows and diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 23c1e54bb..cf42b9597 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -4114,8 +4114,23 @@ hostnames or Tailscale IPs, rather than silently depending on public SSH reachability from GitHub-hosted runners. The workflow must use the current pinned Tailscale GitHub Action, its target `ping` readiness gate, and the shared `.github/scripts/check-demo-reachability.sh` TCP/22 diagnostic before SSH key -capture. A successful tailnet join alone is not connectivity proof. After that -network preflight, shared SSH +capture. A successful tailnet join alone is not connectivity proof. + +The shared helper's `diagnose` mode is local-only after a failed setup action: +it must not ping the peer or attempt direct TCP as a fallback. Its `check` mode +requires recognised local `Running` state before any probe, then a successful +tailnet ping before TCP/22. Missing, malformed or non-running daemon state is +an incomplete setup result, not proof that the demo is down. Failed ping is +retained without another diagnostic ping or TCP attempt. Status diagnostics +report only allowlisted backend states and peer-presence/online/active booleans; +they never print tailnet addresses, DNS names, tags, relay locations, raw JSON +or local CLI errors. Tailnet and TCP probe stdout/stderr are also suppressed +on both success and failure; their exits and bounded, topology-free verdicts +remain visible. Diagnostic success cannot establish connectivity or +installed acceptance, and neither mode changes credentials or authorises +replaying a provider refusal. + +After that network preflight, shared SSH setup must wait for configured demo hostnames to resolve, accept configured IP literals without a DNS precheck, and then capture host keys with bounded short retries before any installer or binary copy runs; a long `ssh-keyscan` diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index cd292905f..d5f0966dd 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2599,8 +2599,8 @@ func TestDemoReachabilityHelperSeparatesTailnetAndSshTransportProof(t *testing.T `tailscale status --json`, `tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"`, `nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"`, - `Runner Tailscale DNS:`, - `Runner Tailscale tags:`, + `Tailscale backend:`, + `Diagnostic mode is local-only`, `Demo peer is not present in the runner peer map yet.`, `Verify sshd and the host firewall on tailscale0.`, } { @@ -2641,7 +2641,7 @@ exit 1 if err != nil { t.Fatalf("demo reachability helper failed: %v\n%s", err, output) } - for _, needle := range []string{"Tailscale backend: Running", "Demo peer state: online=True active=True relay=lhr", "Demo SSH transport is reachable over Tailscale."} { + for _, needle := range []string{"Tailscale backend: Running", "Demo peer state: online=True active=True", "Demo SSH transport is reachable over Tailscale."} { if !strings.Contains(string(output), needle) { t.Fatalf("demo reachability output missing %q: %s", needle, output) } diff --git a/scripts/installtests/demo_reachability_test.go b/scripts/installtests/demo_reachability_test.go new file mode 100644 index 000000000..ca363add5 --- /dev/null +++ b/scripts/installtests/demo_reachability_test.go @@ -0,0 +1,127 @@ +package installtests + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestDemoReachabilityStopsBeforeUnreadyOrFailedTailnetProbes(t *testing.T) { + for _, tc := range []struct { + name, mode, state, rawStatus string + statusExit, pingExit, tcpExit int + wantExit, pings, tcpProbes int + want string + }{ + {name: "needs-login", state: "NeedsLogin", wantExit: 1, want: "no demo reachability probes attempted"}, + {name: "machine-auth", state: "NeedsMachineAuth", wantExit: 1, want: "no demo reachability probes attempted"}, + {name: "stopped", state: "Stopped", wantExit: 1}, + {name: "starting", state: "Starting", wantExit: 1}, + {name: "unknown-state", state: "private-data-never-log", wantExit: 1, want: "Tailscale backend: unknown"}, + {name: "unavailable-status", statusExit: 1, wantExit: 1, want: "status JSON is unavailable"}, + {name: "invalid-json", rawStatus: "private-data-never-log", wantExit: 1}, + {name: "array-status", rawStatus: "[]", wantExit: 1}, + {name: "null-status", rawStatus: "null", wantExit: 1}, + {name: "missing-state", rawStatus: "{}", wantExit: 1}, + {name: "invalid-state-type", rawStatus: `{"BackendState":[]}`, wantExit: 1}, + {name: "diagnose-after-login-failure", mode: "diagnose", state: "NeedsLogin", want: "Diagnostic mode is local-only"}, + {name: "diagnose-after-running-setup-failure", mode: "diagnose", state: "Running", want: "installed state remain unverified"}, + {name: "diagnose-unavailable-status", mode: "diagnose", statusExit: 1, want: "Diagnostic mode is local-only"}, + {name: "failed-tailnet-ping", state: "Running", pingExit: 1, wantExit: 1, pings: 1, want: "Tailscale cannot reach the demo peer"}, + {name: "closed-ssh", state: "Running", tcpExit: 1, wantExit: 1, pings: 1, tcpProbes: 2, want: "TCP/22 remained closed"}, + {name: "ready", state: "Running", pings: 1, tcpProbes: 1, want: "Demo SSH transport is reachable over Tailscale"}, + {name: "malformed-peer-map", rawStatus: `{"BackendState":"Running","Peer":[]}`, pings: 1, tcpProbes: 1}, + {name: "malformed-peer", rawStatus: `{"BackendState":"Running","Peer":{"a":null,"b":{"DNSName":[],"TailscaleIPs":3}}}`, pings: 1, tcpProbes: 1}, + {name: "invalid-mode", mode: "unsupported", state: "Running", wantExit: 2, want: "Usage:"}, + } { + t.Run(tc.name, func(t *testing.T) { + tmp := t.TempDir() + bin := filepath.Join(tmp, "bin") + if err := os.Mkdir(bin, 0o755); err != nil { + t.Fatal(err) + } + status := tc.rawStatus + if status == "" { + data, err := json.Marshal(map[string]any{ + "BackendState": tc.state, + "Self": map[string]any{"TailscaleIPs": []string{"100.100.100.1"}, "DNSName": "private-data-never-log.test.", "Tags": []string{"tag:private-data-never-log"}}, + "Peer": map[string]any{"demo": map[string]any{"DNSName": "demo.synthetic.test.", "Online": true, "Active": true, "Relay": "private-data-never-log"}}, + }) + if err != nil { + t.Fatal(err) + } + status = string(data) + } + statusFile := filepath.Join(tmp, "status.json") + if err := os.WriteFile(statusFile, []byte(status), 0o600); err != nil { + t.Fatal(err) + } + callsFile := filepath.Join(tmp, "calls") + for name, script := range map[string]string{ + "tailscale": `#!/bin/sh +printf 'tailscale %s\n' "$*" >> "$CALLS_FILE" +case "$1" in + status) cat "$STATUS_FILE"; echo 'private-data-never-log' >&2; exit "$STATUS_EXIT" ;; + ping) echo 'private-data-never-log'; echo 'private-data-never-log' >&2; exit "$PING_EXIT" ;; + *) exit 97 ;; +esac +`, + "nc": `#!/bin/sh +printf 'nc %s\n' "$*" >> "$CALLS_FILE" +echo 'private-data-never-log' +echo 'private-data-never-log' >&2 +exit "$TCP_EXIT" +`, + } { + if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + mode := tc.mode + if mode == "" { + mode = "check" + } + cmd := exec.Command("bash", repoFile(".github", "scripts", "check-demo-reachability.sh"), mode) + cmd.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "DEMO_SERVER_HOST=demo.synthetic.test", "DEMO_SERVER_PORT=22", "DEMO_TCP_ATTEMPTS=2", "DEMO_TCP_RETRY_SECONDS=0", + "CALLS_FILE="+callsFile, "STATUS_FILE="+statusFile, + "STATUS_EXIT="+strconv.Itoa(tc.statusExit), "PING_EXIT="+strconv.Itoa(tc.pingExit), "TCP_EXIT="+strconv.Itoa(tc.tcpExit), + ) + output, err := cmd.CombinedOutput() + if cmd.ProcessState == nil || cmd.ProcessState.ExitCode() != tc.wantExit { + t.Fatalf("helper exit: %v, want %d; output=%s", err, tc.wantExit, output) + } + calls, err := os.ReadFile(callsFile) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if got := strings.Count(string(calls), "tailscale ping "); got != tc.pings { + t.Fatalf("tailnet probes=%d, want %d; calls=%s", got, tc.pings, calls) + } + if got := strings.Count(string(calls), "nc "); got != tc.tcpProbes { + t.Fatalf("TCP probes=%d, want %d; calls=%s", got, tc.tcpProbes, calls) + } + if tc.mode == "unsupported" && len(calls) != 0 { + t.Fatalf("invalid mode attempted a diagnostic: %s", calls) + } + if !strings.Contains(string(output), tc.want) { + t.Fatalf("output missing %q: %s", tc.want, output) + } + for _, private := range []string{"private-data-never-log", "100.100.100.1", "demo.synthetic.test", "Traceback"} { + if strings.Contains(string(output), private) { + t.Fatalf("helper exposed private or raw diagnostic content %q: %s", private, output) + } + } + if tc.wantExit != 0 || tc.mode == "diagnose" { + if strings.Contains(string(output), "Demo SSH transport is reachable") { + t.Fatalf("helper claimed connectivity without successful check: %s", output) + } + } + }) + } +}