diff --git a/internal/availabilityprobe/ping_args_branchcov0722am_test.go b/internal/availabilityprobe/ping_args_branchcov0722am_test.go new file mode 100644 index 000000000..0f5a77a27 --- /dev/null +++ b/internal/availabilityprobe/ping_args_branchcov0722am_test.go @@ -0,0 +1,148 @@ +package availabilityprobe + +import ( + "reflect" + "runtime" + "strconv" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// Branch-coverage test set for pingArgs, moved verbatim (aside from the +// package clause) from +// internal/monitoring/availability_poller_purehelpers_branchcov0722am_test.go +// when the probe execution core was extracted into this package. It is still +// selected via `-run "^TestBranchcov0722"` and covers: +// +// - pingArgs: the timeoutMillis <= 0 fallback to the config default, the +// runtime.GOOS switch (windows / darwin+BSD / default), and the default +// arm's ceiling arithmetic ((ms+999)/1000) plus the clamp-to-1 guard. +// +// Conventions match the sibling monitoring tests: stdlib `testing` only, +// table-driven subtests, t.Fatalf assertions, no testify. + +func TestBranchcov0722PingArgs(t *testing.T) { + const host = "probe.example.test" + + t.Run("positive timeout reaches the platform arm verbatim", func(t *testing.T) { + got := pingArgs(host, 5000) + switch runtime.GOOS { + case "windows": + want := []string{"-n", "1", "-w", "5000", host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, 5000) on windows = %v, want %v", host, got, want) + } + case "darwin", "freebsd", "openbsd", "netbsd": + want := []string{"-n", "-c", "1", "-W", "5000", host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, 5000) on %s = %v, want %v", host, runtime.GOOS, got, want) + } + default: + // Default arm: (5000 + 999) / 1000 = 5 whole seconds. + want := []string{"-n", "-c", "1", "-W", "5", host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, 5000) on %s = %v, want %v", host, runtime.GOOS, got, want) + } + } + }) + + t.Run("zero or negative timeout falls back to config default", func(t *testing.T) { + defaultMillis := config.DefaultAvailabilityTimeoutMillis + for _, ms := range []int{0, -1, -99999} { + got := pingArgs(host, ms) + switch runtime.GOOS { + case "windows": + want := []string{"-n", "1", "-w", strconv.Itoa(defaultMillis), host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, %d) on windows = %v, want %v", host, ms, got, want) + } + case "darwin", "freebsd", "openbsd", "netbsd": + want := []string{"-n", "-c", "1", "-W", strconv.Itoa(defaultMillis), host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, %d) on %s = %v, want %v", host, ms, runtime.GOOS, got, want) + } + default: + secs := (defaultMillis + 999) / 1000 + want := []string{"-n", "-c", "1", "-W", strconv.Itoa(secs), host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, %d) on %s = %v, want %v", host, ms, runtime.GOOS, got, want) + } + } + } + }) + +} + +// TestBranchcov0722PingArgsDefaultArmCeilingArithmetic targets the +// non-Windows / non-BSD ("default") arm of the runtime.GOOS switch, which is +// the only arm that performs the (ms+999)/1000 ceiling division and the +// clamp-to-1 guard. pingArgs reads runtime.GOOS directly, so this arm can only +// be reached on platforms such as linux; on windows/darwin/BSD the test skips +// honestly rather than asserting behaviour it cannot reach. +func TestBranchcov0722PingArgsDefaultArmCeilingArithmetic(t *testing.T) { + switch runtime.GOOS { + case "windows", "darwin", "freebsd", "openbsd", "netbsd": + t.Skipf("ceiling arithmetic lives only in the default (linux-like) pingArgs arm; skipping on %s", runtime.GOOS) + } + + const host = "probe.example.test" + cases := []struct { + name string + timeoutMs int + wantSeconds int + }{ + // Ceiling division: any sub-second value rounds up to 1 second. + {"one millisecond rounds up to one second", 1, 1}, + {"just under a second rounds up to one second", 999, 1}, + // Exact second boundary stays put. + {"exact one thousand millis is one second", 1000, 1}, + // Fractional seconds round up to the next whole second. + {"one thousand one millis rounds up to two seconds", 1001, 2}, + {"fifteen hundred millis rounds up to two seconds", 1500, 2}, + {"five thousand nine hundred ninety nine rounds up to six seconds", 5999, 6}, + // NOTE: the `if timeoutSeconds <= 0 { timeoutSeconds = 1 }` clamp is + // unreachable here. The non-positive fallback guarantees timeoutMillis + // is at least DefaultAvailabilityTimeoutMillis (2000) before this arm, + // and any positive value yields (ms+999)/1000 >= 1. No table case can + // exercise the clamp; this is documented in the report, not "fixed". + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := pingArgs(host, tc.timeoutMs) + // Default arm shape: ["-n", "-c", "1", "-W", , host]. + want := []string{"-n", "-c", "1", "-W", strconv.Itoa(tc.wantSeconds), host} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pingArgs(%q, %d) = %v, want %v", host, tc.timeoutMs, got, want) + } + }) + } +} + +// TestBranchcov0722PingArgsCloneIndependence asserts that the returned slice +// is freshly allocated on every call: mutating one result must not corrupt a +// previously returned or subsequently returned slice, and vice versa. (pingArgs +// takes only immutable inputs — a string and an int — so the relevant +// invariant is that distinct calls do not alias backing arrays.) +func TestBranchcov0722PingArgsCloneIndependence(t *testing.T) { + first := pingArgs("alpha", 1000) + second := pingArgs("beta", 2000) + + snapshot := append([]string(nil), first...) + for i := range second { + second[i] = "MUTATED" + } + if !reflect.DeepEqual(first, snapshot) { + t.Fatalf("mutating a pingArgs result corrupted a distinct result: first = %v, want %v", first, snapshot) + } + + third := pingArgs("gamma", 3000) + snapshotThird := append([]string(nil), third...) + for i := range first { + first[i] = "MUTATED" + } + if !reflect.DeepEqual(third, snapshotThird) { + t.Fatalf("mutating an earlier pingArgs result corrupted a later result: third = %v, want %v", third, snapshotThird) + } +} diff --git a/internal/availabilityprobe/probe.go b/internal/availabilityprobe/probe.go new file mode 100644 index 000000000..0608536e9 --- /dev/null +++ b/internal/availabilityprobe/probe.go @@ -0,0 +1,307 @@ +// Package availabilityprobe executes a single agentless availability check +// against a configured target (ICMP, TCP, UDP, HTTP/HTTPS). +// +// It holds only the probe execution core, deliberately free of scheduling, +// status bookkeeping and resource projection, so that it can be shared between +// the monitoring poller (which schedules probes and records their outcomes) and +// the host agent's external-probe module (which runs the same checks from a +// remote vantage point) without either side pulling in the other's +// dependencies. +package availabilityprobe + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" + "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" +) + +// Outcome describes what a completed probe proved about the target. +type Outcome string + +const ( + // OutcomeReachable means the probe proved the endpoint responds. + OutcomeReachable Outcome = "reachable" + // OutcomeUnreachable means the probe ran and the endpoint did not respond. + OutcomeUnreachable Outcome = "unreachable" + // OutcomeIndeterminate means the probe ran cleanly but could not prove + // reachability either way (an open-or-filtered UDP timeout). + OutcomeIndeterminate Outcome = "indeterminate" +) + +// Run executes one agentless availability check. +func Run(ctx context.Context, target config.AvailabilityTarget) error { + _, err := Result(ctx, target) + return err +} + +// Result preserves UDP's open-or-filtered state rather than incorrectly +// claiming that a silent UDP endpoint was proven reachable. +func Result(ctx context.Context, target config.AvailabilityTarget) (Outcome, error) { + target = config.NormalizeAvailabilityTarget(target) + if err := target.Validate(); err != nil { + return OutcomeUnreachable, err + } + + timeout := time.Duration(target.EffectiveTimeoutMillis()) * time.Millisecond + if timeout <= 0 { + timeout = time.Duration(config.DefaultAvailabilityTimeoutMillis) * time.Millisecond + } + probeCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch target.Protocol { + case config.AvailabilityProbeICMP: + return outcomeFromError(probeICMP(probeCtx, target)) + case config.AvailabilityProbeTCP: + return outcomeFromError(probeTCP(probeCtx, target)) + case config.AvailabilityProbeUDP: + return probeUDP(probeCtx, target) + case config.AvailabilityProbeHTTP, config.AvailabilityProbeHTTPS: + return outcomeFromError(probeHTTP(probeCtx, target, timeout)) + default: + return OutcomeUnreachable, fmt.Errorf("unsupported availability protocol %q", target.Protocol) + } +} + +func outcomeFromError(err error) (Outcome, error) { + if err != nil { + return OutcomeUnreachable, err + } + return OutcomeReachable, nil +} + +func probeUDP(ctx context.Context, target config.AvailabilityTarget) (Outcome, error) { + host := target.ProbeAddress() + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return OutcomeUnreachable, fmt.Errorf("resolve UDP availability target: %w", err) + } + var selected net.IP + for _, address := range addresses { + if address.IP == nil || address.IP.IsUnspecified() || address.IP.IsMulticast() || address.IP.Equal(net.IPv4bcast) { + continue + } + selected = address.IP + break + } + if selected == nil { + return OutcomeUnreachable, fmt.Errorf("UDP availability target did not resolve to an allowed unicast address") + } + + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "udp", net.JoinHostPort(selected.String(), strconv.Itoa(target.Port))) + if err != nil { + return OutcomeUnreachable, fmt.Errorf("UDP probe dial failed: %w", err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + return OutcomeUnreachable, fmt.Errorf("set UDP probe deadline: %w", err) + } + } + payload := []byte(target.UDPRequest) + if len(payload) == 0 { + // A one-byte datagram gives the kernel an opportunity to surface an + // ICMP port-unreachable result in open-or-filtered mode. + payload = []byte{0} + } + if _, err := conn.Write(payload); err != nil { + return OutcomeUnreachable, fmt.Errorf("UDP probe write failed: %w", err) + } + + response := make([]byte, 4096) + n, err := conn.Read(response) + if err == nil { + if target.UDPExpected != "" && string(response[:n]) != target.UDPExpected { + return OutcomeUnreachable, fmt.Errorf("UDP response did not match the expected payload") + } + return OutcomeReachable, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + if target.UDPMode == config.AvailabilityUDPOpenOrFiltered && ctxErr == context.DeadlineExceeded { + return OutcomeIndeterminate, nil + } + return OutcomeUnreachable, ctxErr + } + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if target.UDPMode == config.AvailabilityUDPOpenOrFiltered { + return OutcomeIndeterminate, nil + } + return OutcomeUnreachable, fmt.Errorf("UDP probe timed out waiting for a response") + } + return OutcomeUnreachable, fmt.Errorf("UDP probe failed: %w", err) +} + +func probeICMP(ctx context.Context, target config.AvailabilityTarget) error { + host := target.ProbeAddress() + if host == "" { + return fmt.Errorf("icmp availability target host is required") + } + args := pingArgs(host, target.EffectiveTimeoutMillis()) + cmd := exec.CommandContext(ctx, "ping", args...) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + details := strings.TrimSpace(string(output)) + if details == "" { + return fmt.Errorf("icmp probe failed: %w", err) + } + // Units written before v6.1.0-rc.1 lack AmbientCapabilities=CAP_NET_RAW and + // in-place updates never rewrite the unit, so ping fails like this on every + // upgraded install (#1554). Point at the unit instead of echoing ping stderr. + if strings.Contains(details, "Operation not permitted") || strings.Contains(details, "cap_net_raw") { + return fmt.Errorf("icmp probe blocked. The Pulse service unit does not grant CAP_NET_RAW, so ping cannot open a socket. Re-run the Pulse installer to regenerate the unit, or add a systemd override with AmbientCapabilities=CAP_NET_RAW and CapabilityBoundingSet=CAP_NET_RAW, then restart the service") + } + if len(details) > 240 { + details = details[:240] + } + return fmt.Errorf("icmp probe failed: %s", details) +} + +func pingArgs(host string, timeoutMillis int) []string { + if timeoutMillis <= 0 { + timeoutMillis = config.DefaultAvailabilityTimeoutMillis + } + switch runtime.GOOS { + case "windows": + return []string{"-n", "1", "-w", strconv.Itoa(timeoutMillis), host} + case "darwin", "freebsd", "openbsd", "netbsd": + return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutMillis), host} + default: + timeoutSeconds := (timeoutMillis + 999) / 1000 + if timeoutSeconds <= 0 { + timeoutSeconds = 1 + } + return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutSeconds), host} + } +} + +func probeTCP(ctx context.Context, target config.AvailabilityTarget) error { + host := target.ProbeAddress() + if host == "" { + return fmt.Errorf("tcp availability target host is required") + } + addr := net.JoinHostPort(host, strconv.Itoa(target.Port)) + + dialer := net.Dialer{} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err == nil { + conn.Close() + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + + return probeTCPViaSystem(ctx, host, target.Port, target.EffectiveTimeoutMillis()) +} + +func probeTCPViaSystem(ctx context.Context, host string, port, timeoutMillis int) error { + timeoutSecs := (timeoutMillis + 999) / 1000 + if timeoutSecs < 1 { + timeoutSecs = 1 + } + portStr := strconv.Itoa(port) + + var args []string + if runtime.GOOS == "darwin" { + args = []string{"-z", "-G", strconv.Itoa(timeoutSecs), host, portStr} + } else { + args = []string{"-z", "-w", strconv.Itoa(timeoutSecs), host, portStr} + } + + cmd := exec.CommandContext(ctx, "nc", args...) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + details := strings.TrimSpace(string(output)) + if details == "" { + return fmt.Errorf("tcp probe failed: %w", err) + } + if len(details) > 240 { + details = details[:240] + } + return fmt.Errorf("tcp probe failed: %s", details) +} + +func probeHTTP(ctx context.Context, target config.AvailabilityTarget, timeout time.Duration) error { + u, err := target.HTTPURL() + if err != nil { + return err + } + opts := httpOutboundOptions() + u, err = securityutil.ValidateOutboundFetchURL(ctx, u.String(), opts) + if err != nil { + return fmt.Errorf("http availability target URL validation failed: %w", err) + } + client := securityutil.NewRestrictedOutboundHTTPClient(timeout, opts) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil) + if err != nil { + return fmt.Errorf("build http availability request: %w", err) + } + req.Header.Set("User-Agent", "Pulse availability probe") + resp, err := client.Do(req) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == http.StatusMethodNotAllowed { + return probeHTTPGet(ctx, client, u) + } + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("http probe returned %s", resp.Status) + } + return nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + + return fmt.Errorf("http probe failed: %w", err) +} + +func httpOutboundOptions() securityutil.RestrictedOutboundHTTPOptions { + return securityutil.RestrictedOutboundHTTPOptions{ + AllowedSchemes: []string{"http", "https"}, + AllowPrivateIPs: true, + AllowLoopback: true, + TLSConfig: tlsutil.UnverifiedPeerCertificateCaptureTLSConfig(), + } +} + +func probeHTTPGet(ctx context.Context, client *http.Client, u *url.URL) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return fmt.Errorf("build http availability fallback request: %w", err) + } + req.Header.Set("User-Agent", "Pulse availability probe") + resp, err := client.Do(req) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("http probe failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= http.StatusInternalServerError { + return fmt.Errorf("http probe returned %s", resp.Status) + } + return nil +} diff --git a/internal/availabilityprobe/probe_test.go b/internal/availabilityprobe/probe_test.go new file mode 100644 index 000000000..e405e2d0b --- /dev/null +++ b/internal/availabilityprobe/probe_test.go @@ -0,0 +1,15 @@ +package availabilityprobe + +import "testing" + +// Moved verbatim from internal/monitoring/availability_poller_test.go when the +// probe execution core was extracted into this package. +func TestAvailabilityHTTPOutboundOptionsUsesSharedPeerCertificateCapture(t *testing.T) { + tlsConfig := httpOutboundOptions().TLSConfig + if tlsConfig == nil || !tlsConfig.InsecureSkipVerify { + t.Fatal("availability TLS config must enter explicit peer-certificate capture mode") + } + if tlsConfig.VerifyPeerCertificate == nil { + t.Fatal("availability TLS config must reject missing or malformed peer certificates") + } +} diff --git a/internal/monitoring/availability_poller.go b/internal/monitoring/availability_poller.go index 7320d8e14..78e826740 100644 --- a/internal/monitoring/availability_poller.go +++ b/internal/monitoring/availability_poller.go @@ -4,21 +4,15 @@ import ( "context" "fmt" "net" - "net/http" - "net/url" - "os/exec" - "runtime" "sort" - "strconv" "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/availabilityprobe" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" - "github.com/rcourtman/pulse-go-rewrite/internal/securityutil" "github.com/rcourtman/pulse-go-rewrite/internal/storagehealth" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" ) // AvailabilityProbeStatus captures the last observed state of an agentless @@ -40,12 +34,14 @@ type AvailabilityProbeStatus struct { FailureThreshold int `json:"failureThreshold,omitempty"` } -type AvailabilityProbeOutcome string +// AvailabilityProbeOutcome and its values are aliases for the shared probe +// package so existing monitoring and API callers keep their spelling. +type AvailabilityProbeOutcome = availabilityprobe.Outcome const ( - AvailabilityProbeReachable AvailabilityProbeOutcome = "reachable" - AvailabilityProbeUnreachable AvailabilityProbeOutcome = "unreachable" - AvailabilityProbeIndeterminate AvailabilityProbeOutcome = "indeterminate" + AvailabilityProbeReachable = availabilityprobe.OutcomeReachable + AvailabilityProbeUnreachable = availabilityprobe.OutcomeUnreachable + AvailabilityProbeIndeterminate = availabilityprobe.OutcomeIndeterminate ) type availabilityPollProvider struct{} @@ -331,271 +327,18 @@ func (m *Monitor) setAvailabilityStatus(target config.AvailabilityTarget, checke m.mu.Unlock() } -// ProbeAvailabilityTarget executes one agentless availability check. +// ProbeAvailabilityTarget executes one agentless availability check. The probe +// execution core lives in internal/availabilityprobe so the host agent can run +// the same checks without importing the monitoring package; this wrapper keeps +// the historical monitoring entry point for existing callers. func ProbeAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) error { - _, err := ProbeAvailabilityTargetResult(ctx, target) - return err + return availabilityprobe.Run(ctx, target) } // ProbeAvailabilityTargetResult preserves UDP's open-or-filtered state rather // than incorrectly claiming that a silent UDP endpoint was proven reachable. func ProbeAvailabilityTargetResult(ctx context.Context, target config.AvailabilityTarget) (AvailabilityProbeOutcome, error) { - target = config.NormalizeAvailabilityTarget(target) - if err := target.Validate(); err != nil { - return AvailabilityProbeUnreachable, err - } - - timeout := time.Duration(target.EffectiveTimeoutMillis()) * time.Millisecond - if timeout <= 0 { - timeout = time.Duration(config.DefaultAvailabilityTimeoutMillis) * time.Millisecond - } - probeCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - switch target.Protocol { - case config.AvailabilityProbeICMP: - return outcomeFromProbeError(probeICMP(probeCtx, target)) - case config.AvailabilityProbeTCP: - return outcomeFromProbeError(probeTCP(probeCtx, target)) - case config.AvailabilityProbeUDP: - return probeUDP(probeCtx, target) - case config.AvailabilityProbeHTTP, config.AvailabilityProbeHTTPS: - return outcomeFromProbeError(probeHTTP(probeCtx, target, timeout)) - default: - return AvailabilityProbeUnreachable, fmt.Errorf("unsupported availability protocol %q", target.Protocol) - } -} - -func outcomeFromProbeError(err error) (AvailabilityProbeOutcome, error) { - if err != nil { - return AvailabilityProbeUnreachable, err - } - return AvailabilityProbeReachable, nil -} - -func probeUDP(ctx context.Context, target config.AvailabilityTarget) (AvailabilityProbeOutcome, error) { - host := target.ProbeAddress() - addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) - if err != nil { - return AvailabilityProbeUnreachable, fmt.Errorf("resolve UDP availability target: %w", err) - } - var selected net.IP - for _, address := range addresses { - if address.IP == nil || address.IP.IsUnspecified() || address.IP.IsMulticast() || address.IP.Equal(net.IPv4bcast) { - continue - } - selected = address.IP - break - } - if selected == nil { - return AvailabilityProbeUnreachable, fmt.Errorf("UDP availability target did not resolve to an allowed unicast address") - } - - dialer := net.Dialer{} - conn, err := dialer.DialContext(ctx, "udp", net.JoinHostPort(selected.String(), strconv.Itoa(target.Port))) - if err != nil { - return AvailabilityProbeUnreachable, fmt.Errorf("UDP probe dial failed: %w", err) - } - defer conn.Close() - if deadline, ok := ctx.Deadline(); ok { - if err := conn.SetDeadline(deadline); err != nil { - return AvailabilityProbeUnreachable, fmt.Errorf("set UDP probe deadline: %w", err) - } - } - payload := []byte(target.UDPRequest) - if len(payload) == 0 { - // A one-byte datagram gives the kernel an opportunity to surface an - // ICMP port-unreachable result in open-or-filtered mode. - payload = []byte{0} - } - if _, err := conn.Write(payload); err != nil { - return AvailabilityProbeUnreachable, fmt.Errorf("UDP probe write failed: %w", err) - } - - response := make([]byte, 4096) - n, err := conn.Read(response) - if err == nil { - if target.UDPExpected != "" && string(response[:n]) != target.UDPExpected { - return AvailabilityProbeUnreachable, fmt.Errorf("UDP response did not match the expected payload") - } - return AvailabilityProbeReachable, nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - if target.UDPMode == config.AvailabilityUDPOpenOrFiltered && ctxErr == context.DeadlineExceeded { - return AvailabilityProbeIndeterminate, nil - } - return AvailabilityProbeUnreachable, ctxErr - } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { - if target.UDPMode == config.AvailabilityUDPOpenOrFiltered { - return AvailabilityProbeIndeterminate, nil - } - return AvailabilityProbeUnreachable, fmt.Errorf("UDP probe timed out waiting for a response") - } - return AvailabilityProbeUnreachable, fmt.Errorf("UDP probe failed: %w", err) -} - -func probeICMP(ctx context.Context, target config.AvailabilityTarget) error { - host := target.ProbeAddress() - if host == "" { - return fmt.Errorf("icmp availability target host is required") - } - args := pingArgs(host, target.EffectiveTimeoutMillis()) - cmd := exec.CommandContext(ctx, "ping", args...) - output, err := cmd.CombinedOutput() - if err == nil { - return nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - details := strings.TrimSpace(string(output)) - if details == "" { - return fmt.Errorf("icmp probe failed: %w", err) - } - // Units written before v6.1.0-rc.1 lack AmbientCapabilities=CAP_NET_RAW and - // in-place updates never rewrite the unit, so ping fails like this on every - // upgraded install (#1554). Point at the unit instead of echoing ping stderr. - if strings.Contains(details, "Operation not permitted") || strings.Contains(details, "cap_net_raw") { - return fmt.Errorf("icmp probe blocked. The Pulse service unit does not grant CAP_NET_RAW, so ping cannot open a socket. Re-run the Pulse installer to regenerate the unit, or add a systemd override with AmbientCapabilities=CAP_NET_RAW and CapabilityBoundingSet=CAP_NET_RAW, then restart the service") - } - if len(details) > 240 { - details = details[:240] - } - return fmt.Errorf("icmp probe failed: %s", details) -} - -func pingArgs(host string, timeoutMillis int) []string { - if timeoutMillis <= 0 { - timeoutMillis = config.DefaultAvailabilityTimeoutMillis - } - switch runtime.GOOS { - case "windows": - return []string{"-n", "1", "-w", strconv.Itoa(timeoutMillis), host} - case "darwin", "freebsd", "openbsd", "netbsd": - return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutMillis), host} - default: - timeoutSeconds := (timeoutMillis + 999) / 1000 - if timeoutSeconds <= 0 { - timeoutSeconds = 1 - } - return []string{"-n", "-c", "1", "-W", strconv.Itoa(timeoutSeconds), host} - } -} - -func probeTCP(ctx context.Context, target config.AvailabilityTarget) error { - host := target.ProbeAddress() - if host == "" { - return fmt.Errorf("tcp availability target host is required") - } - addr := net.JoinHostPort(host, strconv.Itoa(target.Port)) - - dialer := net.Dialer{} - conn, err := dialer.DialContext(ctx, "tcp", addr) - if err == nil { - conn.Close() - return nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - - return probeTCPViaSystem(ctx, host, target.Port, target.EffectiveTimeoutMillis()) -} - -func probeTCPViaSystem(ctx context.Context, host string, port, timeoutMillis int) error { - timeoutSecs := (timeoutMillis + 999) / 1000 - if timeoutSecs < 1 { - timeoutSecs = 1 - } - portStr := strconv.Itoa(port) - - var args []string - if runtime.GOOS == "darwin" { - args = []string{"-z", "-G", strconv.Itoa(timeoutSecs), host, portStr} - } else { - args = []string{"-z", "-w", strconv.Itoa(timeoutSecs), host, portStr} - } - - cmd := exec.CommandContext(ctx, "nc", args...) - output, err := cmd.CombinedOutput() - if err == nil { - return nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - details := strings.TrimSpace(string(output)) - if details == "" { - return fmt.Errorf("tcp probe failed: %w", err) - } - if len(details) > 240 { - details = details[:240] - } - return fmt.Errorf("tcp probe failed: %s", details) -} - -func probeHTTP(ctx context.Context, target config.AvailabilityTarget, timeout time.Duration) error { - u, err := target.HTTPURL() - if err != nil { - return err - } - opts := availabilityHTTPOutboundOptions() - u, err = securityutil.ValidateOutboundFetchURL(ctx, u.String(), opts) - if err != nil { - return fmt.Errorf("http availability target URL validation failed: %w", err) - } - client := securityutil.NewRestrictedOutboundHTTPClient(timeout, opts) - req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil) - if err != nil { - return fmt.Errorf("build http availability request: %w", err) - } - req.Header.Set("User-Agent", "Pulse availability probe") - resp, err := client.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode == http.StatusMethodNotAllowed { - return probeHTTPGet(ctx, client, u) - } - if resp.StatusCode >= http.StatusInternalServerError { - return fmt.Errorf("http probe returned %s", resp.Status) - } - return nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - - return fmt.Errorf("http probe failed: %w", err) -} - -func availabilityHTTPOutboundOptions() securityutil.RestrictedOutboundHTTPOptions { - return securityutil.RestrictedOutboundHTTPOptions{ - AllowedSchemes: []string{"http", "https"}, - AllowPrivateIPs: true, - AllowLoopback: true, - TLSConfig: tlsutil.UnverifiedPeerCertificateCaptureTLSConfig(), - } -} - -func probeHTTPGet(ctx context.Context, client *http.Client, u *url.URL) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return fmt.Errorf("build http availability fallback request: %w", err) - } - req.Header.Set("User-Agent", "Pulse availability probe") - resp, err := client.Do(req) - if err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return ctxErr - } - return fmt.Errorf("http probe failed: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode >= http.StatusInternalServerError { - return fmt.Errorf("http probe returned %s", resp.Status) - } - return nil + return availabilityprobe.Result(ctx, target) } func availabilityStatusFromTarget(target config.AvailabilityTarget) AvailabilityProbeStatus { diff --git a/internal/monitoring/availability_poller_purehelpers_branchcov0722am_test.go b/internal/monitoring/availability_poller_purehelpers_branchcov0722am_test.go index 0af733d31..1058306b2 100644 --- a/internal/monitoring/availability_poller_purehelpers_branchcov0722am_test.go +++ b/internal/monitoring/availability_poller_purehelpers_branchcov0722am_test.go @@ -1,21 +1,20 @@ package monitoring import ( - "reflect" - "runtime" - "strconv" "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/config" ) // This file is a purpose-built branch-coverage test set (selected via -// `-run "^TestBranchcov0722"`) for three pure helpers in +// `-run "^TestBranchcov0722"`) for two pure helpers in // availability_poller.go that previously had 0.0% coverage: // // - availabilityConnectionKey(targetID string) string // - (availabilityPollProvider).ConnectionHealthKey(_ *Monitor, instanceName string) string -// - pingArgs(host string, timeoutMillis int) []string +// +// The third helper originally covered here, pingArgs(host string, timeoutMillis +// int) []string, moved with the probe execution core to +// internal/availabilityprobe; its cases live in +// internal/availabilityprobe/ping_args_branchcov0722am_test.go. // // Every arm of each function is exercised directly here: // @@ -25,9 +24,6 @@ import ( // gets the prefix appended a second time. // - ConnectionHealthKey: pure delegation to availabilityConnectionKey with a // nil *Monitor (the receiver ignores the monitor entirely). -// - pingArgs: the timeoutMillis <= 0 fallback to the config default, the -// runtime.GOOS switch (windows / darwin+BSD / default), and the default -// arm's ceiling arithmetic ((ms+999)/1000) plus the clamp-to-1 guard. // // Conventions match sibling in-package tests in this directory (see // monitoring_infra_keys_branchcov0716_test.go and the connected-infrastructure @@ -97,128 +93,3 @@ func TestBranchcov0722ConnectionHealthKey(t *testing.T) { }) } } - -func TestBranchcov0722PingArgs(t *testing.T) { - const host = "probe.example.test" - - t.Run("positive timeout reaches the platform arm verbatim", func(t *testing.T) { - got := pingArgs(host, 5000) - switch runtime.GOOS { - case "windows": - want := []string{"-n", "1", "-w", "5000", host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, 5000) on windows = %v, want %v", host, got, want) - } - case "darwin", "freebsd", "openbsd", "netbsd": - want := []string{"-n", "-c", "1", "-W", "5000", host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, 5000) on %s = %v, want %v", host, runtime.GOOS, got, want) - } - default: - // Default arm: (5000 + 999) / 1000 = 5 whole seconds. - want := []string{"-n", "-c", "1", "-W", "5", host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, 5000) on %s = %v, want %v", host, runtime.GOOS, got, want) - } - } - }) - - t.Run("zero or negative timeout falls back to config default", func(t *testing.T) { - defaultMillis := config.DefaultAvailabilityTimeoutMillis - for _, ms := range []int{0, -1, -99999} { - got := pingArgs(host, ms) - switch runtime.GOOS { - case "windows": - want := []string{"-n", "1", "-w", strconv.Itoa(defaultMillis), host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, %d) on windows = %v, want %v", host, ms, got, want) - } - case "darwin", "freebsd", "openbsd", "netbsd": - want := []string{"-n", "-c", "1", "-W", strconv.Itoa(defaultMillis), host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, %d) on %s = %v, want %v", host, ms, runtime.GOOS, got, want) - } - default: - secs := (defaultMillis + 999) / 1000 - want := []string{"-n", "-c", "1", "-W", strconv.Itoa(secs), host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, %d) on %s = %v, want %v", host, ms, runtime.GOOS, got, want) - } - } - } - }) - -} - -// TestBranchcov0722PingArgsDefaultArmCeilingArithmetic targets the -// non-Windows / non-BSD ("default") arm of the runtime.GOOS switch, which is -// the only arm that performs the (ms+999)/1000 ceiling division and the -// clamp-to-1 guard. pingArgs reads runtime.GOOS directly, so this arm can only -// be reached on platforms such as linux; on windows/darwin/BSD the test skips -// honestly rather than asserting behaviour it cannot reach. -func TestBranchcov0722PingArgsDefaultArmCeilingArithmetic(t *testing.T) { - switch runtime.GOOS { - case "windows", "darwin", "freebsd", "openbsd", "netbsd": - t.Skipf("ceiling arithmetic lives only in the default (linux-like) pingArgs arm; skipping on %s", runtime.GOOS) - } - - const host = "probe.example.test" - cases := []struct { - name string - timeoutMs int - wantSeconds int - }{ - // Ceiling division: any sub-second value rounds up to 1 second. - {"one millisecond rounds up to one second", 1, 1}, - {"just under a second rounds up to one second", 999, 1}, - // Exact second boundary stays put. - {"exact one thousand millis is one second", 1000, 1}, - // Fractional seconds round up to the next whole second. - {"one thousand one millis rounds up to two seconds", 1001, 2}, - {"fifteen hundred millis rounds up to two seconds", 1500, 2}, - {"five thousand nine hundred ninety nine rounds up to six seconds", 5999, 6}, - // NOTE: the `if timeoutSeconds <= 0 { timeoutSeconds = 1 }` clamp is - // unreachable here. The non-positive fallback guarantees timeoutMillis - // is at least DefaultAvailabilityTimeoutMillis (2000) before this arm, - // and any positive value yields (ms+999)/1000 >= 1. No table case can - // exercise the clamp; this is documented in the report, not "fixed". - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := pingArgs(host, tc.timeoutMs) - // Default arm shape: ["-n", "-c", "1", "-W", , host]. - want := []string{"-n", "-c", "1", "-W", strconv.Itoa(tc.wantSeconds), host} - if !reflect.DeepEqual(got, want) { - t.Fatalf("pingArgs(%q, %d) = %v, want %v", host, tc.timeoutMs, got, want) - } - }) - } -} - -// TestBranchcov0722PingArgsCloneIndependence asserts that the returned slice -// is freshly allocated on every call: mutating one result must not corrupt a -// previously returned or subsequently returned slice, and vice versa. (pingArgs -// takes only immutable inputs — a string and an int — so the relevant -// invariant is that distinct calls do not alias backing arrays.) -func TestBranchcov0722PingArgsCloneIndependence(t *testing.T) { - first := pingArgs("alpha", 1000) - second := pingArgs("beta", 2000) - - snapshot := append([]string(nil), first...) - for i := range second { - second[i] = "MUTATED" - } - if !reflect.DeepEqual(first, snapshot) { - t.Fatalf("mutating a pingArgs result corrupted a distinct result: first = %v, want %v", first, snapshot) - } - - third := pingArgs("gamma", 3000) - snapshotThird := append([]string(nil), third...) - for i := range first { - first[i] = "MUTATED" - } - if !reflect.DeepEqual(third, snapshotThird) { - t.Fatalf("mutating an earlier pingArgs result corrupted a later result: third = %v, want %v", third, snapshotThird) - } -} diff --git a/internal/monitoring/availability_poller_test.go b/internal/monitoring/availability_poller_test.go index 94612728c..6f2241c73 100644 --- a/internal/monitoring/availability_poller_test.go +++ b/internal/monitoring/availability_poller_test.go @@ -38,15 +38,8 @@ func TestProbeAvailabilityTargetHTTPFallsBackToGETWhenHeadNotAllowed(t *testing. } } -func TestAvailabilityHTTPOutboundOptionsUsesSharedPeerCertificateCapture(t *testing.T) { - tlsConfig := availabilityHTTPOutboundOptions().TLSConfig - if tlsConfig == nil || !tlsConfig.InsecureSkipVerify { - t.Fatal("availability TLS config must enter explicit peer-certificate capture mode") - } - if tlsConfig.VerifyPeerCertificate == nil { - t.Fatal("availability TLS config must reject missing or malformed peer certificates") - } -} +// TestAvailabilityHTTPOutboundOptionsUsesSharedPeerCertificateCapture moved to +// internal/availabilityprobe alongside the outbound options it asserts on. func TestProbeAvailabilityTargetHTTPTreatsServerErrorsAsUnavailable(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 5372696a0..4e6911e7a 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -30,17 +30,25 @@ var bannedSnapshotResourceAccessPatterns = []struct { } func TestAvailabilityTLSUsesSharedCertificateCaptureBoundary(t *testing.T) { - source, err := os.ReadFile("availability_poller.go") - if err != nil { - t.Fatalf("read availability_poller.go: %v", err) + // The probe execution core moved to internal/availabilityprobe so the host + // agent can share it; the TLS boundary guardrail follows the code and still + // covers the monitoring poller that schedules the probes. + const probeCore = "../availabilityprobe/probe.go" + sources := make(map[string]string, 2) + for _, path := range []string{"availability_poller.go", probeCore} { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(data) + sources[path] = text + if strings.Contains(text, "InsecureSkipVerify: true") { + t.Fatalf("%s must not define a local skip-verification TLS config", path) + } } - text := string(source) - if !strings.Contains(text, "tlsutil.UnverifiedPeerCertificateCaptureTLSConfig()") { + if !strings.Contains(sources[probeCore], "tlsutil.UnverifiedPeerCertificateCaptureTLSConfig()") { t.Fatal("availability probes must use the shared peer-certificate capture boundary") } - if strings.Contains(text, "InsecureSkipVerify: true") { - t.Fatal("availability probes must not define a local skip-verification TLS config") - } } type guardrailSupplementalProvider struct {