checker-alias/checker/rules_chain.go

273 lines
9 KiB
Go

package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type chainLoopRule struct{}
func (chainLoopRule) Name() string { return "chain_loop" }
func (chainLoopRule) Description() string {
return "Detects CNAME/DNAME cycles in the resolution chain."
}
func (chainLoopRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermLoop {
return okState(data.Owner, "no loop in the alias chain")
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("chain loops back to %s", data.ChainTerminated.Subject),
}, "Break the loop by pointing the last CNAME at an A/AAAA-bearing name.")}
}
type chainLengthRule struct{}
func (chainLengthRule) Name() string { return "chain_length" }
func (chainLengthRule) Description() string {
return "Flags alias chains longer than the configured maximum (most resolvers give up around 8-16 hops)."
}
func (chainLengthRule) Options() sdk.CheckerOptionsDocumentation {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "maxChainLength",
Type: "uint",
Label: "Maximum chain length",
Description: "Above this number of hops the chain is reported as critical.",
Default: float64(defaultMaxChainLength),
},
},
}
}
func (chainLengthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
maxLen := sdk.GetIntOption(opts, "maxChainLength", defaultMaxChainLength)
if data.ChainTerminated.Reason != TermTooLong {
return okState(data.Owner, fmt.Sprintf("chain has %d hop(s), within limit of %d", len(data.Chain), maxLen))
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("chain exceeds %d hops; many resolvers will give up", maxLen),
}, "Flatten intermediate CNAMEs so that the chain is at most a few hops long.")}
}
type chainQueryErrorRule struct{}
func (chainQueryErrorRule) Name() string { return "chain_query_error" }
func (chainQueryErrorRule) Description() string {
return "Flags DNS query failures encountered while walking the alias chain."
}
func (chainQueryErrorRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermQueryErr {
return okState(data.Owner, "all chain queries succeeded")
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("CNAME query for %s failed: %s", data.ChainTerminated.Subject, data.ChainTerminated.Detail),
}, "Check authoritative-server reachability and firewall rules; the alias is unusable while queries fail.")}
}
type chainRcodeRule struct{}
func (chainRcodeRule) Name() string { return "chain_rcode" }
func (chainRcodeRule) Description() string {
return "Flags NXDOMAIN/SERVFAIL/other rcodes encountered mid-chain or on the final target lookup."
}
func (chainRcodeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
var out []sdk.CheckState
if data.ChainTerminated.Reason == TermRcode {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: data.ChainTerminated.Subject,
Message: fmt.Sprintf("server answered %s mid-chain", data.ChainTerminated.Rcode),
}, "Ensure the zone publishes the expected record; NXDOMAIN/SERVFAIL mid-chain breaks the alias."))
}
if data.FinalRcode != "" && data.FinalRcode != "NOERROR" {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: data.FinalTarget,
Message: fmt.Sprintf("final A lookup for %s returned %s", data.FinalTarget, data.FinalRcode),
}, "Check the upstream zone's A/AAAA publication."))
}
if len(out) == 0 {
return okState(data.Owner, "all chain and final lookups returned NOERROR")
}
return out
}
type hopTTLRule struct{}
func (hopTTLRule) Name() string { return "hop_ttl" }
func (hopTTLRule) Description() string {
return "Flags chain hops whose TTL is below the configured minimum."
}
func (hopTTLRule) Options() sdk.CheckerOptionsDocumentation {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "minTargetTTL",
Type: "uint",
Label: "Minimum TTL (seconds)",
Description: "Hops with a TTL below this threshold are flagged as a warning.",
Default: float64(defaultMinTargetTTL),
},
},
}
}
func (hopTTLRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if len(data.Chain) == 0 {
return skipped("chain is empty")
}
minTTL := uint32(sdk.GetIntOption(opts, "minTargetTTL", defaultMinTargetTTL))
var out []sdk.CheckState
for _, h := range data.Chain {
if h.Kind == KindTarget || h.TTL == 0 {
continue
}
if h.TTL < minTTL {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusWarn,
Subject: h.Owner,
Message: fmt.Sprintf("hop %s → %s has TTL %ds (< %d)", h.Owner, h.Target, h.TTL, minTTL),
}, "Raise the CNAME TTL to improve cache efficiency (5-15 minutes is a common floor)."))
}
}
if len(out) == 0 {
return okState(data.Owner, fmt.Sprintf("all chain hops have TTL ≥ %ds", minTTL))
}
return out
}
type targetResolvableRule struct{}
func (targetResolvableRule) Name() string { return "target_resolvable" }
func (targetResolvableRule) Description() string {
return "Verifies that the final target of the alias chain publishes at least one A or AAAA record."
}
func (targetResolvableRule) Options() sdk.CheckerOptionsDocumentation {
return sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: "requireResolvableTarget",
Type: "bool",
Label: "Require resolvable target",
Description: "When enabled, a chain whose final target returns no A/AAAA is reported as critical (otherwise a warning).",
Default: defaultRequireResolvableTarget,
},
},
}
}
func (targetResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
if data.ChainTerminated.Reason != TermOK {
return skipped("chain did not terminate normally")
}
if len(data.FinalA) > 0 || len(data.FinalAAAA) > 0 {
return okState(data.FinalTarget, fmt.Sprintf("target %s resolves to %d address(es)", data.FinalTarget, len(data.FinalA)+len(data.FinalAAAA)))
}
status := sdk.StatusWarn
if sdk.GetBoolOption(opts, "requireResolvableTarget", defaultRequireResolvableTarget) {
status = sdk.StatusCrit
}
rcode := data.FinalRcode
if rcode == "" {
rcode = "no A/AAAA"
}
return []sdk.CheckState{withHint(sdk.CheckState{
Status: status,
Subject: data.FinalTarget,
Message: fmt.Sprintf("final target %s does not resolve to an address (%s)", data.FinalTarget, rcode),
}, "Point the alias at a name that publishes at least one A or AAAA record, or fix the upstream zone.")}
}
type multipleRecordsRule struct{}
func (multipleRecordsRule) Name() string { return "multiple_records" }
func (multipleRecordsRule) Description() string {
return "Flags owners that carry more than one CNAME/DNAME record; only one is legal per owner."
}
func (multipleRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errState := loadAlias(ctx, obs)
if errState != nil {
return errState
}
if !apexKnown(data) {
return skipped("apex lookup failed")
}
seen := map[string]int{}
for _, h := range data.Chain {
if h.Kind == KindCNAME || h.Kind == KindDNAME {
seen[h.Owner]++
}
}
var out []sdk.CheckState
for owner, n := range seen {
if n > 1 {
out = append(out, withHint(sdk.CheckState{
Status: sdk.StatusCrit,
Subject: owner,
Message: fmt.Sprintf("%s carries %d CNAME/DNAME records in the chain", owner, n),
}, "Keep a single CNAME per name; remove duplicates at the authoritative zone."))
}
}
if len(out) == 0 {
return okState(data.Owner, "every chain owner carries a single CNAME/DNAME")
}
return out
}