diff --git a/checker/evaluate.go b/checker/evaluate.go index f64b2f1..f4ccb2f 100644 --- a/checker/evaluate.go +++ b/checker/evaluate.go @@ -22,7 +22,6 @@ package checker import ( - "fmt" "time" ) @@ -51,44 +50,3 @@ type Metric struct { Labels map[string]string `json:"labels,omitempty"` Timestamp time.Time `json:"timestamp"` } - -// EvaluateResult holds the evaluation outcome for a single target. -type EvaluateResult struct { - Address string `json:"address"` - Status int `json:"status"` - Message string `json:"message"` - Code string `json:"code"` -} - -const ( - StatusUnknown = 0 - StatusOK = 1 - StatusWarn = 3 - StatusCrit = 4 -) - -// Evaluate checks the ping data against the given thresholds and returns one -// result per target. -func Evaluate(data *PingData, warningRTT, criticalRTT, warningPacketLoss, criticalPacketLoss float64) []EvaluateResult { - if len(data.Targets) == 0 { - return nil - } - - results := make([]EvaluateResult, 0, len(data.Targets)) - for _, target := range data.Targets { - status := StatusOK - if target.PacketLoss >= criticalPacketLoss || target.RTTAvg >= criticalRTT { - status = StatusCrit - } else if target.PacketLoss >= warningPacketLoss || target.RTTAvg >= warningRTT { - status = StatusWarn - } - - results = append(results, EvaluateResult{ - Address: target.Address, - Status: status, - Message: fmt.Sprintf("%.1fms avg, %.0f%% loss", target.RTTAvg, target.PacketLoss), - Code: "ping_result", - }) - } - return results -} diff --git a/checker/rule.go b/checker/rule.go index 650005a..cc69219 100644 --- a/checker/rule.go +++ b/checker/rule.go @@ -24,141 +24,60 @@ package checker import ( "context" "fmt" + "net" sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rule returns a new ping check rule for local evaluation. +// Rules returns the full list of CheckRules exposed by the ping checker. +// Each rule covers a single concern so callers see at a glance which +// aspects passed and which did not, instead of sharing a single monolithic +// rule result. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &reachabilityRule{}, + &packetLossRule{}, + &rttRule{}, + &ipv6ReachabilityRule{}, + } +} + +// Rule returns the primary rule (reachability) for backward compatibility +// with callers that expect a single rule; prefer Rules() which returns the +// full rule set. func Rule() sdk.CheckRule { - return &pingRule{} + return &reachabilityRule{} } -type pingRule struct{} - -func (r *pingRule) Name() string { return "ping_check" } -func (r *pingRule) Description() string { - return "Checks ICMP ping reachability, round-trip time, and packet loss" -} - -func (r *pingRule) ValidateOptions(opts sdk.CheckerOptions) error { - warningRTT := float64(100) - criticalRTT := float64(500) - warningPacketLoss := float64(10) - criticalPacketLoss := float64(50) - - if v, ok := opts["warningRTT"]; ok { - d, ok := v.(float64) - if !ok { - return fmt.Errorf("warningRTT must be a number") - } - if d <= 0 { - return fmt.Errorf("warningRTT must be positive") - } - warningRTT = d - } - if v, ok := opts["criticalRTT"]; ok { - d, ok := v.(float64) - if !ok { - return fmt.Errorf("criticalRTT must be a number") - } - if d <= 0 { - return fmt.Errorf("criticalRTT must be positive") - } - criticalRTT = d - } - if v, ok := opts["warningPacketLoss"]; ok { - d, ok := v.(float64) - if !ok { - return fmt.Errorf("warningPacketLoss must be a number") - } - if d < 0 || d > 100 { - return fmt.Errorf("warningPacketLoss must be between 0 and 100") - } - warningPacketLoss = d - } - if v, ok := opts["criticalPacketLoss"]; ok { - d, ok := v.(float64) - if !ok { - return fmt.Errorf("criticalPacketLoss must be a number") - } - if d < 0 || d > 100 { - return fmt.Errorf("criticalPacketLoss must be between 0 and 100") - } - criticalPacketLoss = d - } - if v, ok := opts["count"]; ok { - d, ok := v.(float64) - if !ok { - return fmt.Errorf("count must be a number") - } - if d < 1 || d > 20 { - return fmt.Errorf("count must be between 1 and 20") - } - } - - if criticalRTT <= warningRTT { - return fmt.Errorf("criticalRTT (%v) must be greater than warningRTT (%v)", criticalRTT, warningRTT) - } - if criticalPacketLoss <= warningPacketLoss { - return fmt.Errorf("criticalPacketLoss (%v) must be greater than warningPacketLoss (%v)", criticalPacketLoss, warningPacketLoss) - } - - return nil -} - -func (r *pingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { +// loadPingData fetches the ping observation. On error, returns a CheckState +// the caller should emit to short-circuit its rule. +func loadPingData(ctx context.Context, obs sdk.ObservationGetter) (*PingData, *sdk.CheckState) { var data PingData if err := obs.Get(ctx, ObservationKeyPing, &data); err != nil { - return []sdk.CheckState{{ + return nil, &sdk.CheckState{ Status: sdk.StatusError, - Message: fmt.Sprintf("Failed to get ping data: %v", err), - Code: "ping_error", - }} - } - - warningRTT := sdk.GetFloatOption(opts, "warningRTT", 100) - criticalRTT := sdk.GetFloatOption(opts, "criticalRTT", 500) - warningPacketLoss := sdk.GetFloatOption(opts, "warningPacketLoss", 10) - criticalPacketLoss := sdk.GetFloatOption(opts, "criticalPacketLoss", 50) - - results := Evaluate(&data, warningRTT, criticalRTT, warningPacketLoss, criticalPacketLoss) - if len(results) == 0 { - return []sdk.CheckState{{ - Status: sdk.StatusUnknown, - Message: "No targets to ping", - Code: "ping_no_targets", - }} - } - - targetByAddr := make(map[string]PingTargetResult, len(data.Targets)) - for _, t := range data.Targets { - targetByAddr[t.Address] = t - } - - out := make([]sdk.CheckState, 0, len(results)) - for _, r := range results { - var status sdk.Status - switch r.Status { - case StatusOK: - status = sdk.StatusOK - case StatusWarn: - status = sdk.StatusWarn - case StatusCrit: - status = sdk.StatusCrit - default: - status = sdk.StatusUnknown + Message: fmt.Sprintf("failed to load ping observation: %v", err), + Code: "ping.observation_error", } - - state := sdk.CheckState{ - Status: status, - Subject: r.Address, - Message: r.Message, - Code: r.Code, - } - if t, ok := targetByAddr[r.Address]; ok { - state.Meta = map[string]any{"target": t} - } - out = append(out, state) } - return out + return &data, nil +} + +// noTargetsState is returned when the observation has no targets at all. +func noTargetsState(code string) sdk.CheckState { + return sdk.CheckState{ + Status: sdk.StatusUnknown, + Message: "No targets to ping", + Code: code, + } +} + +// isIPv6 reports whether addr parses as an IPv6 address (excluding +// IPv4-mapped representations). +func isIPv6(addr string) bool { + ip := net.ParseIP(addr) + if ip == nil { + return false + } + return ip.To4() == nil } diff --git a/checker/rules_ipv6.go b/checker/rules_ipv6.go new file mode 100644 index 0000000..9373d01 --- /dev/null +++ b/checker/rules_ipv6.go @@ -0,0 +1,77 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ipv6ReachabilityRule verifies at least one IPv6 target replied to a probe. +// The rule is skipped (StatusUnknown) when no IPv6 target was pinged, which +// is expected for IPv4-only hosts. +type ipv6ReachabilityRule struct{} + +func (r *ipv6ReachabilityRule) Name() string { return "ping.ipv6_reachable" } +func (r *ipv6ReachabilityRule) Description() string { + return "Verifies that at least one IPv6 target replied to an ICMP probe." +} + +func (r *ipv6ReachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadPingData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + + var ipv6Total, ipv6Reachable int + for _, t := range data.Targets { + if !isIPv6(t.Address) { + continue + } + ipv6Total++ + if t.Received > 0 { + ipv6Reachable++ + } + } + + switch { + case ipv6Total == 0: + return []sdk.CheckState{{ + Status: sdk.StatusUnknown, + Message: "No IPv6 target pinged.", + Code: "ping.ipv6_reachable.skipped", + }} + case ipv6Reachable == 0: + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Message: "No IPv6 target replied to ICMP probes.", + Code: "ping.ipv6_reachable.unreachable", + }} + default: + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Message: "At least one IPv6 target is reachable.", + Code: "ping.ipv6_reachable.ok", + }} + } +} diff --git a/checker/rules_loss.go b/checker/rules_loss.go new file mode 100644 index 0000000..869cd94 --- /dev/null +++ b/checker/rules_loss.go @@ -0,0 +1,88 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// packetLossRule evaluates the observed packet-loss ratio of each target +// against the configured warning/critical thresholds. +type packetLossRule struct{} + +func (r *packetLossRule) Name() string { return "ping.packet_loss" } +func (r *packetLossRule) Description() string { + return "Flags targets whose packet-loss ratio crosses the warning or critical threshold." +} + +func (r *packetLossRule) ValidateOptions(opts sdk.CheckerOptions) error { + warn := sdk.GetFloatOption(opts, "warningPacketLoss", 10) + crit := sdk.GetFloatOption(opts, "criticalPacketLoss", 50) + if warn < 0 || warn > 100 { + return fmt.Errorf("warningPacketLoss must be between 0 and 100") + } + if crit < 0 || crit > 100 { + return fmt.Errorf("criticalPacketLoss must be between 0 and 100") + } + if crit <= warn { + return fmt.Errorf("criticalPacketLoss (%v) must be greater than warningPacketLoss (%v)", crit, warn) + } + return nil +} + +func (r *packetLossRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadPingData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Targets) == 0 { + return []sdk.CheckState{noTargetsState("ping.packet_loss.no_targets")} + } + + warn := sdk.GetFloatOption(opts, "warningPacketLoss", 10) + crit := sdk.GetFloatOption(opts, "criticalPacketLoss", 50) + + out := make([]sdk.CheckState, 0, len(data.Targets)) + for _, t := range data.Targets { + state := sdk.CheckState{ + Subject: t.Address, + Message: fmt.Sprintf("Packet loss %.0f%% (warn=%.0f%%, crit=%.0f%%).", t.PacketLoss, warn, crit), + Meta: map[string]any{"target": t}, + } + switch { + case t.PacketLoss >= crit: + state.Status = sdk.StatusCrit + state.Code = "ping.packet_loss.critical" + case t.PacketLoss >= warn: + state.Status = sdk.StatusWarn + state.Code = "ping.packet_loss.warning" + default: + state.Status = sdk.StatusOK + state.Code = "ping.packet_loss.ok" + } + out = append(out, state) + } + return out +} diff --git a/checker/rules_reachability.go b/checker/rules_reachability.go new file mode 100644 index 0000000..d8ede91 --- /dev/null +++ b/checker/rules_reachability.go @@ -0,0 +1,70 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// reachabilityRule verifies each target replied to at least one probe. +// A target with 100% packet loss is considered unreachable. +type reachabilityRule struct{} + +func (r *reachabilityRule) Name() string { return "ping.reachable" } +func (r *reachabilityRule) Description() string { + return "Verifies every target replied to at least one ICMP probe." +} + +func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadPingData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Targets) == 0 { + return []sdk.CheckState{noTargetsState("ping.reachable.no_targets")} + } + + out := make([]sdk.CheckState, 0, len(data.Targets)) + for _, t := range data.Targets { + if t.Received == 0 { + out = append(out, sdk.CheckState{ + Status: sdk.StatusCrit, + Subject: t.Address, + Message: fmt.Sprintf("Target unreachable (0/%d replies).", t.Sent), + Code: "ping.reachable.unreachable", + Meta: map[string]any{"target": t}, + }) + } else { + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, + Subject: t.Address, + Message: fmt.Sprintf("Target reachable (%d/%d replies).", t.Received, t.Sent), + Code: "ping.reachable.ok", + Meta: map[string]any{"target": t}, + }) + } + } + return out +} diff --git a/checker/rules_rtt.go b/checker/rules_rtt.go new file mode 100644 index 0000000..2b64110 --- /dev/null +++ b/checker/rules_rtt.go @@ -0,0 +1,100 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// rttRule evaluates the average round-trip time of each target against +// the configured warning/critical thresholds. Unreachable targets (no +// reply at all) are skipped; the reachability rule handles those. +type rttRule struct{} + +func (r *rttRule) Name() string { return "ping.rtt" } +func (r *rttRule) Description() string { + return "Flags targets whose average round-trip time crosses the warning or critical threshold." +} + +func (r *rttRule) ValidateOptions(opts sdk.CheckerOptions) error { + warn := sdk.GetFloatOption(opts, "warningRTT", 100) + crit := sdk.GetFloatOption(opts, "criticalRTT", 500) + if warn <= 0 { + return fmt.Errorf("warningRTT must be positive") + } + if crit <= 0 { + return fmt.Errorf("criticalRTT must be positive") + } + if crit <= warn { + return fmt.Errorf("criticalRTT (%v) must be greater than warningRTT (%v)", crit, warn) + } + return nil +} + +func (r *rttRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadPingData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Targets) == 0 { + return []sdk.CheckState{noTargetsState("ping.rtt.no_targets")} + } + + warn := sdk.GetFloatOption(opts, "warningRTT", 100) + crit := sdk.GetFloatOption(opts, "criticalRTT", 500) + + out := make([]sdk.CheckState, 0, len(data.Targets)) + for _, t := range data.Targets { + if t.Received == 0 { + out = append(out, sdk.CheckState{ + Status: sdk.StatusUnknown, + Subject: t.Address, + Message: "RTT not measurable (no replies).", + Code: "ping.rtt.no_replies", + Meta: map[string]any{"target": t}, + }) + continue + } + + state := sdk.CheckState{ + Subject: t.Address, + Message: fmt.Sprintf("Average RTT %.1fms (warn=%.0fms, crit=%.0fms).", t.RTTAvg, warn, crit), + Meta: map[string]any{"target": t}, + } + switch { + case t.RTTAvg >= crit: + state.Status = sdk.StatusCrit + state.Code = "ping.rtt.critical" + case t.RTTAvg >= warn: + state.Status = sdk.StatusWarn + state.Code = "ping.rtt.warning" + default: + state.Status = sdk.StatusOK + state.Code = "ping.rtt.ok" + } + out = append(out, state) + } + return out +}