Files
pulse/internal/monitoring/availability_poller.go
T
2026-07-09 21:29:45 +01:00

606 lines
18 KiB
Go

package monitoring
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"sort"
"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/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
// endpoint probe.
type AvailabilityProbeStatus struct {
TargetID string `json:"targetId"`
Name string `json:"name"`
TargetKind string `json:"targetKind,omitempty"`
Address string `json:"address"`
Protocol string `json:"protocol"`
Enabled bool `json:"enabled"`
Available bool `json:"available"`
LastChecked time.Time `json:"lastChecked,omitempty"`
LastSuccess time.Time `json:"lastSuccess,omitempty"`
LatencyMillis int64 `json:"latencyMillis,omitempty"`
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
LastError string `json:"lastError,omitempty"`
FailureThreshold int `json:"failureThreshold,omitempty"`
}
type availabilityPollProvider struct{}
func newAvailabilityPollProvider() PollProvider {
return availabilityPollProvider{}
}
func (availabilityPollProvider) Type() InstanceType {
return InstanceTypeAvailability
}
func (availabilityPollProvider) ListInstances(m *Monitor) []string {
targets := m.availabilityTargets()
names := make([]string, 0, len(targets))
for _, target := range targets {
if !target.Enabled {
continue
}
names = append(names, target.ID)
}
sort.Strings(names)
return names
}
func (availabilityPollProvider) BaseInterval(m *Monitor) time.Duration {
targets := m.availabilityTargets()
minInterval := time.Duration(config.DefaultAvailabilityPollIntervalSecs) * time.Second
for _, target := range targets {
if !target.Enabled {
continue
}
interval := time.Duration(target.EffectivePollIntervalSecs()) * time.Second
if interval > 0 && interval < minInterval {
minInterval = interval
}
}
return clampInterval(minInterval, 10*time.Second, time.Hour)
}
func (availabilityPollProvider) BuildPollTask(m *Monitor, instanceName string) (PollTask, error) {
target, ok := m.availabilityTargetByID(instanceName)
if !ok || !target.Enabled {
return PollTask{}, fmt.Errorf("availability target %q is not enabled", instanceName)
}
return PollTask{
InstanceName: target.ID,
InstanceType: string(InstanceTypeAvailability),
Run: func(ctx context.Context) {
m.pollAvailabilityTarget(ctx, target)
},
}, nil
}
func (availabilityPollProvider) DescribeInstances(m *Monitor) []PollProviderInstanceInfo {
targets := m.availabilityTargets()
infos := make([]PollProviderInstanceInfo, 0, len(targets))
for _, target := range targets {
if !target.Enabled {
continue
}
infos = append(infos, PollProviderInstanceInfo{
Name: target.ID,
DisplayName: target.DisplayName(),
Connection: availabilityConnectionKey(target.ID),
Metadata: map[string]string{
"address": target.Address,
"protocol": string(target.Protocol),
},
})
}
return infos
}
func (availabilityPollProvider) ConnectionStatuses(m *Monitor) map[string]bool {
statuses := m.AvailabilityStatusSnapshot()
out := make(map[string]bool, len(statuses))
for targetID, status := range statuses {
out[availabilityConnectionKey(targetID)] = status.Enabled && status.Available
}
return out
}
func (availabilityPollProvider) ConnectionHealthKey(_ *Monitor, instanceName string) string {
return availabilityConnectionKey(instanceName)
}
func (availabilityPollProvider) SupplementalSource() unifiedresources.DataSource {
return unifiedresources.SourceAvailability
}
func (availabilityPollProvider) SupplementalRecords(m *Monitor, orgID string) []unifiedresources.IngestRecord {
targets := m.availabilityTargets()
statuses := m.AvailabilityStatusSnapshot()
records := make([]unifiedresources.IngestRecord, 0, len(targets))
now := time.Now().UTC()
for _, target := range targets {
status := statuses[target.ID]
if status.TargetID == "" {
status = availabilityStatusFromTarget(target)
}
resource, identity := availabilityResourceFromTarget(target, status, orgID, now)
records = append(records, unifiedresources.IngestRecord{
SourceID: target.ID,
Resource: resource,
Identity: identity,
})
}
return records
}
func (m *Monitor) availabilityTargets() []config.AvailabilityTarget {
if m == nil || m.configPersist == nil {
return nil
}
targets, err := m.configPersist.LoadAvailabilityTargets()
if err != nil {
return nil
}
out := make([]config.AvailabilityTarget, 0, len(targets))
for _, target := range targets {
target = config.NormalizeAvailabilityTarget(target)
if strings.TrimSpace(target.ID) == "" {
continue
}
out = append(out, target)
}
sort.Slice(out, func(i, j int) bool {
left := strings.ToLower(out[i].DisplayName())
right := strings.ToLower(out[j].DisplayName())
if left == right {
return out[i].ID < out[j].ID
}
return left < right
})
return out
}
func (m *Monitor) availabilityTargetByID(id string) (config.AvailabilityTarget, bool) {
id = strings.TrimSpace(id)
if id == "" {
return config.AvailabilityTarget{}, false
}
for _, target := range m.availabilityTargets() {
if target.ID == id {
return target, true
}
}
return config.AvailabilityTarget{}, false
}
func (m *Monitor) AvailabilityStatusSnapshot() map[string]AvailabilityProbeStatus {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]AvailabilityProbeStatus, len(m.availabilityStatuses))
for id, status := range m.availabilityStatuses {
out[id] = status
}
return out
}
func (m *Monitor) RefreshAvailabilityTargets() {
if m == nil {
return
}
targets := m.availabilityTargets()
activeIDs := make(map[string]struct{}, len(targets))
now := time.Now()
for _, target := range targets {
activeIDs[target.ID] = struct{}{}
if m.taskQueue == nil {
continue
}
task := ScheduledTask{
InstanceName: target.ID,
InstanceType: InstanceTypeAvailability,
NextRun: now,
Interval: clampInterval(time.Duration(target.EffectivePollIntervalSecs())*time.Second, 10*time.Second, time.Hour),
}
if target.Enabled {
m.taskQueue.Upsert(task)
} else {
m.taskQueue.Remove(InstanceTypeAvailability, target.ID)
m.removeProviderConnectionHealth(InstanceTypeAvailability, target.ID)
}
}
removedIDs := make([]string, 0)
m.mu.Lock()
for id := range m.availabilityStatuses {
if _, ok := activeIDs[id]; !ok {
delete(m.availabilityStatuses, id)
removedIDs = append(removedIDs, id)
}
}
m.mu.Unlock()
for _, id := range removedIDs {
m.removeProviderConnectionHealth(InstanceTypeAvailability, id)
}
m.refreshInstanceInfoCacheFromProviders()
m.updateResourceStore(m.GetState())
}
func (m *Monitor) pollAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) {
target = config.NormalizeAvailabilityTarget(target)
start := time.Now()
err := ProbeAvailabilityTarget(ctx, target)
latency := time.Since(start)
checkedAt := time.Now().UTC()
m.setAvailabilityStatus(target, checkedAt, latency, err)
if err == nil {
if m.stalenessTracker != nil {
m.stalenessTracker.UpdateSuccess(InstanceTypeAvailability, target.ID, nil)
}
m.setProviderConnectionHealth(InstanceTypeAvailability, target.ID, true)
} else {
if m.stalenessTracker != nil {
m.stalenessTracker.UpdateSuccess(InstanceTypeAvailability, target.ID, []byte(err.Error()))
}
m.setProviderConnectionHealth(InstanceTypeAvailability, target.ID, false)
}
m.recordTaskResult(InstanceTypeAvailability, target.ID, nil)
m.updateResourceStore(m.GetState())
}
func (m *Monitor) setAvailabilityStatus(target config.AvailabilityTarget, checkedAt time.Time, latency time.Duration, probeErr error) {
if m == nil {
return
}
status := availabilityStatusFromTarget(target)
status.LastChecked = checkedAt
latencyMs := latency.Milliseconds()
if probeErr == nil && latencyMs == 0 {
latencyMs = 1
}
status.LatencyMillis = latencyMs
if probeErr == nil {
status.Available = true
status.LastSuccess = checkedAt
} else {
status.Available = false
status.LastError = probeErr.Error()
}
m.mu.Lock()
if m.availabilityStatuses == nil {
m.availabilityStatuses = make(map[string]AvailabilityProbeStatus)
}
if previous, ok := m.availabilityStatuses[target.ID]; ok {
status.LastSuccess = previous.LastSuccess
if probeErr == nil {
status.ConsecutiveFailures = 0
status.LastError = ""
status.LastSuccess = checkedAt
} else {
status.ConsecutiveFailures = previous.ConsecutiveFailures + 1
}
} else if probeErr != nil {
status.ConsecutiveFailures = 1
}
m.availabilityStatuses[target.ID] = status
m.mu.Unlock()
}
// ProbeAvailabilityTarget executes one agentless availability check.
func ProbeAvailabilityTarget(ctx context.Context, target config.AvailabilityTarget) error {
target = config.NormalizeAvailabilityTarget(target)
if err := target.Validate(); err != nil {
return 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 probeICMP(probeCtx, target)
case config.AvailabilityProbeTCP:
return probeTCP(probeCtx, target)
case config.AvailabilityProbeHTTP, config.AvailabilityProbeHTTPS:
return probeHTTP(probeCtx, target, timeout)
default:
return fmt.Errorf("unsupported availability protocol %q", target.Protocol)
}
}
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)
}
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
}
func availabilityStatusFromTarget(target config.AvailabilityTarget) AvailabilityProbeStatus {
return AvailabilityProbeStatus{
TargetID: target.ID,
Name: target.DisplayName(),
TargetKind: string(target.TargetKind),
Address: target.Address,
Protocol: string(target.Protocol),
Enabled: target.Enabled,
FailureThreshold: target.EffectiveFailureThreshold(),
}
}
func availabilityResourceFromTarget(target config.AvailabilityTarget, status AvailabilityProbeStatus, _ string, now time.Time) (unifiedresources.Resource, unifiedresources.ResourceIdentity) {
lastSeen := status.LastChecked
if lastSeen.IsZero() {
lastSeen = now
}
resourceStatus := availabilityResourceStatus(target, status)
data := &unifiedresources.AvailabilityData{
TargetID: target.ID,
LinkedResourceID: strings.TrimSpace(target.LinkedResourceID),
Name: target.DisplayName(),
TargetKind: string(target.TargetKind),
Address: target.Address,
Protocol: string(target.Protocol),
Port: target.Port,
Path: target.Path,
Enabled: target.Enabled,
Available: status.Available,
LastChecked: timePointerIfSet(status.LastChecked),
LastSuccess: timePointerIfSet(status.LastSuccess),
LatencyMillis: status.LatencyMillis,
ConsecutiveFailures: status.ConsecutiveFailures,
LastError: status.LastError,
FailureThreshold: target.EffectiveFailureThreshold(),
PollIntervalSeconds: target.EffectivePollIntervalSecs(),
TimeoutMillis: target.EffectiveTimeoutMillis(),
}
resource := unifiedresources.Resource{
Type: unifiedresources.ResourceTypeNetworkEndpoint,
Technology: string(target.Protocol),
Name: target.DisplayName(),
Status: resourceStatus,
LastSeen: lastSeen,
UpdatedAt: now,
Sources: []unifiedresources.DataSource{unifiedresources.SourceAvailability},
Tags: availabilityResourceTags(target),
Availability: data,
}
if incident := availabilityIncident(target, status, lastSeen); incident != nil {
resource.Incidents = []unifiedresources.ResourceIncident{*incident}
}
identity := unifiedresources.ResourceIdentity{}
if ip := net.ParseIP(target.ProbeAddress()); ip != nil {
identity.IPAddresses = []string{ip.String()}
} else if host := target.ProbeAddress(); host != "" {
identity.Hostnames = []string{host}
}
return resource, identity
}
func availabilityResourceTags(target config.AvailabilityTarget) []string {
tags := []string{"agentless"}
if target.TargetKind != "" {
tags = append(tags, string(target.TargetKind))
}
return tags
}
func availabilityResourceStatus(target config.AvailabilityTarget, status AvailabilityProbeStatus) unifiedresources.ResourceStatus {
if !target.Enabled {
return unifiedresources.StatusUnknown
}
if status.LastChecked.IsZero() {
return unifiedresources.StatusUnknown
}
if status.Available {
return unifiedresources.StatusOnline
}
if status.ConsecutiveFailures >= target.EffectiveFailureThreshold() {
return unifiedresources.StatusOffline
}
return unifiedresources.StatusWarning
}
func availabilityIncident(target config.AvailabilityTarget, status AvailabilityProbeStatus, startedAt time.Time) *unifiedresources.ResourceIncident {
if !target.Enabled || status.Available || status.LastChecked.IsZero() {
return nil
}
if status.ConsecutiveFailures < target.EffectiveFailureThreshold() {
return nil
}
summary := fmt.Sprintf("%s is unreachable by %s probe", target.DisplayName(), strings.ToUpper(string(target.Protocol)))
if status.LastError != "" {
summary = summary + ": " + status.LastError
}
return &unifiedresources.ResourceIncident{
Provider: string(unifiedresources.SourceAvailability),
NativeID: target.ID,
Code: "availability_unreachable",
Severity: storagehealth.RiskCritical,
Source: string(unifiedresources.SourceAvailability),
Summary: summary,
StartedAt: startedAt,
}
}
func availabilityConnectionKey(targetID string) string {
targetID = strings.TrimSpace(targetID)
if targetID == "" {
return ""
}
return "availability-" + targetID
}