mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
340 lines
12 KiB
Go
340 lines
12 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"
|
|
)
|
|
|
|
// ProbeResult carries the reachability outcome plus HTTPS certificate posture
|
|
// when the target completed a TLS handshake.
|
|
type ProbeResult struct {
|
|
Outcome Outcome `json:"outcome"`
|
|
Certificate *tlsutil.CertificateObservation `json:"certificate,omitempty"`
|
|
}
|
|
|
|
// 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) {
|
|
result, err := DetailedResult(ctx, target)
|
|
return result.Outcome, err
|
|
}
|
|
|
|
// DetailedResult preserves the legacy reachability result while publishing
|
|
// certificate posture for HTTPS checks through the same probe execution path.
|
|
func DetailedResult(ctx context.Context, target config.AvailabilityTarget) (ProbeResult, error) {
|
|
target = config.NormalizeAvailabilityTarget(target)
|
|
if err := target.Validate(); err != nil {
|
|
return ProbeResult{Outcome: 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:
|
|
outcome, err := outcomeFromError(probeICMP(probeCtx, target))
|
|
return ProbeResult{Outcome: outcome}, err
|
|
case config.AvailabilityProbeTCP:
|
|
outcome, err := outcomeFromError(probeTCP(probeCtx, target))
|
|
return ProbeResult{Outcome: outcome}, err
|
|
case config.AvailabilityProbeUDP:
|
|
outcome, err := probeUDP(probeCtx, target)
|
|
return ProbeResult{Outcome: outcome}, err
|
|
case config.AvailabilityProbeHTTP, config.AvailabilityProbeHTTPS:
|
|
certificate, err := probeHTTP(probeCtx, target, timeout)
|
|
outcome, probeErr := outcomeFromError(err)
|
|
return ProbeResult{Outcome: outcome, Certificate: certificate}, probeErr
|
|
default:
|
|
return ProbeResult{Outcome: 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) (*tlsutil.CertificateObservation, error) {
|
|
u, err := target.HTTPURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
opts := httpOutboundOptions()
|
|
u, err = securityutil.ValidateOutboundFetchURL(ctx, u.String(), opts)
|
|
if err != nil {
|
|
return nil, 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 nil, 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()
|
|
certificate := certificateObservationFromResponse(resp)
|
|
if resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented {
|
|
return probeHTTPGet(ctx, client, u)
|
|
}
|
|
if resp.StatusCode >= http.StatusInternalServerError {
|
|
return certificate, fmt.Errorf("http probe returned %s", resp.Status)
|
|
}
|
|
return certificate, nil
|
|
}
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return nil, ctxErr
|
|
}
|
|
|
|
return nil, 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) (*tlsutil.CertificateObservation, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, 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 nil, ctxErr
|
|
}
|
|
return nil, fmt.Errorf("http probe failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
certificate := certificateObservationFromResponse(resp)
|
|
if resp.StatusCode >= http.StatusInternalServerError {
|
|
return certificate, fmt.Errorf("http probe returned %s", resp.Status)
|
|
}
|
|
return certificate, nil
|
|
}
|
|
|
|
func certificateObservationFromResponse(response *http.Response) *tlsutil.CertificateObservation {
|
|
if response == nil || response.TLS == nil {
|
|
return nil
|
|
}
|
|
serverName := ""
|
|
if response.Request != nil && response.Request.URL != nil {
|
|
serverName = response.Request.URL.Hostname()
|
|
}
|
|
return tlsutil.ObservePeerCertificate(response.TLS, serverName, time.Now().UTC())
|
|
}
|