mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
ca3e4b5709
Move the ICMP, TCP, HTTP and UDP probe core from the monitoring availability poller into internal/availabilityprobe so the host agent can execute the same checks without importing the monitoring package. Monitoring keeps type aliases and thin wrappers, so callers and the canonical guardrail contracts are unchanged. Pure refactor for the upcoming external probe feature. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
// 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
|
|
}
|