package checker import ( "context" "fmt" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // hasRecordsRule reports whether the TLSAs service declares any TLSA record // at all. Without records there is nothing for DANE to validate. type hasRecordsRule struct{} func (r *hasRecordsRule) Name() string { return "dane.has_records" } func (r *hasRecordsRule) Description() string { return "Verifies that at least one TLSA record is declared on the service." } func (r *hasRecordsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { rc := loadRuleContext(ctx, obs) if rc.err != nil { return []sdk.CheckState{observationErrorState(rc.err)} } var states []sdk.CheckState for _, inv := range rc.data.Invalid { states = append(states, sdk.CheckState{ Status: sdk.StatusError, Code: "dane_invalid_owner", Subject: inv.Owner, Message: fmt.Sprintf("TLSA record %q is unusable: %s", inv.Owner, inv.Reason), Meta: map[string]any{"owner": inv.Owner, "reason": inv.Reason}, }) } if len(rc.data.Targets) == 0 { if len(states) > 0 { // Records exist but none are usable; flag the aggregate too so // the UI doesn't only show per-record errors. owners := make([]string, 0, len(rc.data.Invalid)) for _, inv := range rc.data.Invalid { owners = append(owners, inv.Owner) } states = append(states, sdk.CheckState{ Status: sdk.StatusError, Code: "dane_no_usable_records", Message: fmt.Sprintf("No usable TLSA records (all %d declared records are malformed: %s).", len(rc.data.Invalid), strings.Join(owners, ", ")), }) return states } return []sdk.CheckState{{ Status: sdk.StatusUnknown, Code: "dane_no_records", Message: "No TLSA records declared on this service.", }} } states = append(states, sdk.CheckState{ Status: sdk.StatusOK, Code: "dane_has_records_ok", Message: "TLSA records are declared for all bound endpoints.", Meta: map[string]any{"endpoints": len(rc.data.Targets)}, }) return states } // dnssecValidatedRule reports whether the TLSA records this checker is // evaluating were fetched over a DNSSEC-validated path. Without DNSSEC, // DANE is a downgrade primitive: an on-path attacker can forge TLSA // answers and any "match" the rest of the rules report is meaningless. // The rule only emits when the collector recorded a validation status: // in managed mode the records come from the user's authoritative zone // config and DNSSEC posture is checked by a different checker. type dnssecValidatedRule struct{} func (r *dnssecValidatedRule) Name() string { return "dane.dnssec_validated" } func (r *dnssecValidatedRule) Description() string { return "Verifies the TLSA records were fetched via a DNSSEC-validating resolver (AD bit set)." } func (r *dnssecValidatedRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { rc := loadRuleContext(ctx, obs) if rc.err != nil { return []sdk.CheckState{observationErrorState(rc.err)} } if rc.data.DNSSECValidated == nil { return nil } if *rc.data.DNSSECValidated { return []sdk.CheckState{{ Status: sdk.StatusOK, Code: "dane_dnssec_validated", Message: "TLSA records were fetched over a DNSSEC-validated path (AD bit set).", }} } return []sdk.CheckState{{ Status: sdk.StatusError, Code: "dane_dnssec_unvalidated", Message: "TLSA records were fetched without DNSSEC validation (resolver did not set the AD bit). DANE matches are not trustworthy without DNSSEC.", }} }