Initial commit
This commit is contained in:
commit
7ca2fb60c6
24 changed files with 3098 additions and 0 deletions
294
checker/rules_consistency.go
Normal file
294
checker/rules_consistency.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// serialConsistencyRule checks that every authoritative NS returns the same
|
||||
// SOA serial.
|
||||
type serialConsistencyRule struct{}
|
||||
|
||||
func (r *serialConsistencyRule) Name() string { return "authoritative_consistency.serial_consistency" }
|
||||
func (r *serialConsistencyRule) Description() string {
|
||||
return "Verifies that every authoritative name server returns the same SOA serial (detects incomplete zone transfer)."
|
||||
}
|
||||
|
||||
func (r *serialConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadObservation(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.HasSOA {
|
||||
return []sdk.CheckState{notTestedState("authoritative_consistency.serial_consistency.skipped", "Zone does not declare a SOA record.")}
|
||||
}
|
||||
|
||||
findings := collectSerialDrift(data)
|
||||
if len(findings) == 0 {
|
||||
return []sdk.CheckState{passState("authoritative_consistency.serial_consistency.ok", "Every authoritative name server returns the same SOA serial.")}
|
||||
}
|
||||
return findingsToStates(findings)
|
||||
}
|
||||
|
||||
func collectSerialDrift(data *ObservationData) []Finding {
|
||||
bySerial := map[uint32][]string{}
|
||||
for _, ns := range data.Probed {
|
||||
r := data.Results[ns]
|
||||
if r == nil || !r.Authoritative || r.SOA == nil {
|
||||
continue
|
||||
}
|
||||
bySerial[r.Serial] = append(bySerial[r.Serial], ns)
|
||||
}
|
||||
if len(bySerial) < 2 {
|
||||
return nil
|
||||
}
|
||||
var pairs []string
|
||||
serials := make([]uint32, 0, len(bySerial))
|
||||
for s := range bySerial {
|
||||
serials = append(serials, s)
|
||||
}
|
||||
sort.Slice(serials, func(i, j int) bool { return serials[i] < serials[j] })
|
||||
for _, s := range serials {
|
||||
servers := bySerial[s]
|
||||
sort.Strings(servers)
|
||||
pairs = append(pairs, fmt.Sprintf("serial %d: %s", s, strings.Join(servers, ", ")))
|
||||
}
|
||||
return []Finding{{
|
||||
Code: CodeSerialDrift,
|
||||
Severity: SeverityCrit,
|
||||
Message: "SOA serial drift between authoritative servers: " + strings.Join(pairs, "; "),
|
||||
}}
|
||||
}
|
||||
|
||||
// serialVsSavedRule compares live serials with the one saved by happyDomain.
|
||||
type serialVsSavedRule struct{}
|
||||
|
||||
func (r *serialVsSavedRule) Name() string { return "authoritative_consistency.serial_vs_saved" }
|
||||
func (r *serialVsSavedRule) Description() string {
|
||||
return "Compares the live SOA serial with the one saved in happyDomain (detects un-pushed edits and out-of-band changes)."
|
||||
}
|
||||
|
||||
func (r *serialVsSavedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadObservation(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.HasSOA || data.DeclaredSerial == 0 {
|
||||
return []sdk.CheckState{notTestedState("authoritative_consistency.serial_vs_saved.skipped", "No saved serial to compare against.")}
|
||||
}
|
||||
warnOnStale := sdk.GetBoolOption(opts, "warnOnStaleSaved", true)
|
||||
|
||||
findings := collectSerialVsSaved(data, warnOnStale)
|
||||
if len(findings) == 0 {
|
||||
return []sdk.CheckState{passState("authoritative_consistency.serial_vs_saved.ok", fmt.Sprintf("Live serials match the saved value %d.", data.DeclaredSerial))}
|
||||
}
|
||||
return findingsToStates(findings)
|
||||
}
|
||||
|
||||
func collectSerialVsSaved(data *ObservationData, warn bool) []Finding {
|
||||
saved := data.DeclaredSerial
|
||||
if saved == 0 {
|
||||
return nil
|
||||
}
|
||||
var below, above []string
|
||||
for _, ns := range data.Probed {
|
||||
r := data.Results[ns]
|
||||
if r == nil || !r.Authoritative || r.SOA == nil {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case serialLess(r.Serial, saved):
|
||||
below = append(below, ns)
|
||||
case serialLess(saved, r.Serial):
|
||||
above = append(above, ns)
|
||||
}
|
||||
}
|
||||
var out []Finding
|
||||
if len(below) > 0 && warn {
|
||||
sort.Strings(below)
|
||||
out = append(out, Finding{
|
||||
Code: CodeSerialStaleVsSaved,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf(
|
||||
"saved serial %d is newer than live serial on %s; changes have not propagated yet or have not been applied to the provider",
|
||||
saved, strings.Join(below, ", "),
|
||||
),
|
||||
})
|
||||
}
|
||||
if len(above) > 0 {
|
||||
sort.Strings(above)
|
||||
out = append(out, Finding{
|
||||
Code: CodeSerialAheadOfSaved,
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf(
|
||||
"live serial on %s is ahead of the saved serial %d; the zone was modified outside happyDomain",
|
||||
strings.Join(above, ", "), saved,
|
||||
),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// soaFieldsConsistencyRule checks that every authoritative NS returns the
|
||||
// same SOA RDATA (MNAME/RNAME/refresh/retry/expire/minimum).
|
||||
type soaFieldsConsistencyRule struct{}
|
||||
|
||||
func (r *soaFieldsConsistencyRule) Name() string { return "authoritative_consistency.soa_fields_consistency" }
|
||||
func (r *soaFieldsConsistencyRule) Description() string {
|
||||
return "Verifies that every authoritative name server returns the same SOA RDATA (MNAME, RNAME, refresh, retry, expire, minimum)."
|
||||
}
|
||||
|
||||
func (r *soaFieldsConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadObservation(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.HasSOA {
|
||||
return []sdk.CheckState{notTestedState("authoritative_consistency.soa_fields_consistency.skipped", "Zone does not declare a SOA record.")}
|
||||
}
|
||||
findings := collectSOAFieldsDrift(data)
|
||||
if len(findings) == 0 {
|
||||
return []sdk.CheckState{passState("authoritative_consistency.soa_fields_consistency.ok", "Every authoritative name server returns the same SOA RDATA.")}
|
||||
}
|
||||
return findingsToStates(findings)
|
||||
}
|
||||
|
||||
func collectSOAFieldsDrift(data *ObservationData) []Finding {
|
||||
type soaSig struct {
|
||||
mname, rname string
|
||||
refresh, retry uint32
|
||||
expire, minimum, serial uint32
|
||||
}
|
||||
groups := map[soaSig][]string{}
|
||||
sig := func(s *dns.SOA) soaSig {
|
||||
return soaSig{
|
||||
mname: strings.ToLower(strings.TrimSuffix(s.Ns, ".")),
|
||||
rname: strings.ToLower(strings.TrimSuffix(s.Mbox, ".")),
|
||||
refresh: s.Refresh,
|
||||
retry: s.Retry,
|
||||
expire: s.Expire,
|
||||
minimum: s.Minttl,
|
||||
serial: s.Serial,
|
||||
}
|
||||
}
|
||||
for _, ns := range data.Probed {
|
||||
r := data.Results[ns]
|
||||
if r == nil || r.SOA == nil {
|
||||
continue
|
||||
}
|
||||
k := sig(r.SOA)
|
||||
k.serial = 0 // serial drift is reported separately; compare RDATA only
|
||||
groups[k] = append(groups[k], ns)
|
||||
}
|
||||
if len(groups) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
keys := make([]soaSig, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return len(groups[keys[i]]) > len(groups[keys[j]]) })
|
||||
for _, k := range keys {
|
||||
srv := groups[k]
|
||||
sort.Strings(srv)
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"mname=%s rname=%s refresh=%d retry=%d expire=%d minimum=%d → %s",
|
||||
k.mname, k.rname, k.refresh, k.retry, k.expire, k.minimum, strings.Join(srv, ", "),
|
||||
))
|
||||
}
|
||||
return []Finding{{
|
||||
Code: CodeSOAFieldsDrift,
|
||||
Severity: SeverityWarn,
|
||||
Message: "SOA fields differ between authoritative servers: " + strings.Join(lines, "; "),
|
||||
}}
|
||||
}
|
||||
|
||||
// nsRRsetConsistencyRule checks NS RRset agreement across authoritative
|
||||
// servers and compares the consensus with the declared list.
|
||||
type nsRRsetConsistencyRule struct{}
|
||||
|
||||
func (r *nsRRsetConsistencyRule) Name() string { return "authoritative_consistency.ns_rrset_consistency" }
|
||||
func (r *nsRRsetConsistencyRule) Description() string {
|
||||
return "Verifies every authoritative name server returns the same NS RRset, and that this RRset matches the NS declared in the service."
|
||||
}
|
||||
|
||||
func (r *nsRRsetConsistencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadObservation(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
findings := collectNSRRsetDrift(data)
|
||||
if len(findings) == 0 {
|
||||
return []sdk.CheckState{passState("authoritative_consistency.ns_rrset_consistency.ok", "NS RRset is consistent across authoritative servers and matches the declared list.")}
|
||||
}
|
||||
return findingsToStates(findings)
|
||||
}
|
||||
|
||||
func collectNSRRsetDrift(data *ObservationData) []Finding {
|
||||
groups := map[string][]string{}
|
||||
for _, ns := range data.Probed {
|
||||
r := data.Results[ns]
|
||||
if r == nil || !r.Authoritative || len(r.NSRRset) == 0 {
|
||||
continue
|
||||
}
|
||||
k := strings.Join(r.NSRRset, "|")
|
||||
groups[k] = append(groups[k], ns)
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
var findings []Finding
|
||||
if len(groups) > 1 {
|
||||
var lines []string
|
||||
keys := make([]string, 0, len(groups))
|
||||
for k := range groups {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool { return len(groups[keys[i]]) > len(groups[keys[j]]) })
|
||||
for _, k := range keys {
|
||||
srv := groups[k]
|
||||
sort.Strings(srv)
|
||||
lines = append(lines, fmt.Sprintf("NS RRset [%s] → %s", strings.ReplaceAll(k, "|", ", "), strings.Join(srv, ", ")))
|
||||
}
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeNSRRsetDrift,
|
||||
Severity: SeverityWarn,
|
||||
Message: "NS RRset differs between authoritative servers: " + strings.Join(lines, "; "),
|
||||
})
|
||||
}
|
||||
|
||||
if len(data.DeclaredNS) == 0 {
|
||||
return findings
|
||||
}
|
||||
var majority []string
|
||||
var majorityCount int
|
||||
for k, servers := range groups {
|
||||
if len(servers) > majorityCount {
|
||||
majority = strings.Split(k, "|")
|
||||
majorityCount = len(servers)
|
||||
}
|
||||
}
|
||||
if len(majority) == 0 {
|
||||
return findings
|
||||
}
|
||||
missing, extra := diffStringSets(data.DeclaredNS, majority)
|
||||
if len(missing) > 0 || len(extra) > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: CodeNSRRsetMismatchConfig,
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf(
|
||||
"NS RRset served by authoritative servers does not match declared service: missing=%v extra=%v",
|
||||
missing, extra,
|
||||
),
|
||||
})
|
||||
}
|
||||
return findings
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue