Initial commit

This commit is contained in:
nemunaire 2026-04-24 10:33:26 +07:00
commit 9a5bbb0e93
23 changed files with 1847 additions and 0 deletions

243
checker/collect.go Normal file
View file

@ -0,0 +1,243 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
tlscontract "git.happydns.org/checker-tls/contract"
)
// tlsaOwner matches the "_<port>._<proto>.<base>" TLSA owner-name pattern.
// The base group is whatever the happyDomain analyzer bucketed the TLSAs
// under; when empty, the TLSAs live directly under the zone apex.
var tlsaOwner = regexp.MustCompile(`^_(\d+)\._(tcp|udp)(?:\.(.*))?$`)
// tlsaOwnerName builds the canonical "_<port>._<proto>.<base>" owner name.
func tlsaOwnerName(port uint16, proto, base string) string {
return fmt.Sprintf("_%d._%s.%s", port, proto, base)
}
// starttlsKey is the "<port>/<proto>" lookup key used in OptionSTARTTLS.
func starttlsKey(port uint16, proto string) string {
return fmt.Sprintf("%d/%s", port, proto)
}
// serviceMessage mirrors the on-wire happydns.ServiceMessage shape, kept
// local so this module does not depend on happyDomain core. Same pattern
// as checker-caa/checker/collect.go.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// tlsasPayload mirrors the JSON shape of svcs.TLSAs (services/tlsa.go).
type tlsasPayload struct {
Records []tlsaRecord `json:"tlsa"`
}
// tlsaRecord decodes one dns.TLSA as serialized by miekg/dns. The Hdr.Name
// is how we learn which endpoint each record applies to; Certificate is
// already a lowercase-hex string as miekg/dns emits it.
type tlsaRecord struct {
Hdr struct {
Name string `json:"Name"`
} `json:"Hdr"`
Usage uint8 `json:"Usage"`
Selector uint8 `json:"Selector"`
MatchingType uint8 `json:"MatchingType"`
Certificate string `json:"Certificate"`
}
// defaultSTARTTLS maps common ports to the STARTTLS service name checker-tls
// expects. Endpoints not covered default to direct TLS; the user can override
// explicitly via the OptionSTARTTLS map.
var defaultSTARTTLS = map[uint16]string{
25: "smtp",
110: "pop3",
143: "imap",
389: "ldap",
587: "submission",
5222: "xmpp-client",
5269: "xmpp-server",
}
// Collect walks the bound TLSAs service, groups records by (port, proto,
// base), emits one tls.endpoint.v1 discovery entry per group so checker-tls
// probes each of them, and returns DANEData with the user's TLSA records.
// No TLSA matching happens here; that's the rule's job: it reads the TLS
// chain via obs.GetRelated on the next evaluation.
func (p *daneProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
svc, err := serviceFromOptions(opts)
if err != nil {
return nil, err
}
if svc.Type != serviceType {
return nil, fmt.Errorf("service is %q, expected %q", svc.Type, serviceType)
}
var pl tlsasPayload
if err := json.Unmarshal(svc.Service, &pl); err != nil {
return nil, fmt.Errorf("decode TLSAs service: %w", err)
}
apex, _ := sdk.GetOption[string](opts, OptionDomain)
apex = strings.TrimSuffix(apex, ".")
subdomain, _ := sdk.GetOption[string](opts, OptionSubdomain)
subdomain = strings.TrimSuffix(subdomain, ".")
// STARTTLS overrides: map of "port/proto" → service name.
var starttlsOverride map[string]string
if v, ok := opts[OptionSTARTTLS]; ok {
raw, _ := json.Marshal(v)
_ = json.Unmarshal(raw, &starttlsOverride)
}
// Group records by endpoint key.
type key struct {
Port uint16
Proto string
Base string // base host, fully-qualified without trailing dot
}
groups := map[key][]TLSARecord{}
for _, r := range pl.Records {
m := tlsaOwner.FindStringSubmatch(strings.TrimSuffix(r.Hdr.Name, "."))
if len(m) != 4 {
continue
}
port64, err := strconv.ParseUint(m[1], 10, 16)
if err != nil {
continue
}
base := m[3]
// Resolve base relative to the apex: TLSA owners in the service
// are typically stored relative to the service's subdomain
// bucket. Fall back to the apex when unspecified.
base = joinName(base, subdomain, apex)
k := key{Port: uint16(port64), Proto: m[2], Base: base}
groups[k] = append(groups[k], TLSARecord{
Usage: r.Usage,
Selector: r.Selector,
MatchingType: r.MatchingType,
Certificate: strings.ToLower(strings.TrimSpace(r.Certificate)),
})
}
// Deterministic output ordering keeps diffs quiet across runs.
keys := make([]key, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].Base != keys[j].Base {
return keys[i].Base < keys[j].Base
}
if keys[i].Port != keys[j].Port {
return keys[i].Port < keys[j].Port
}
return keys[i].Proto < keys[j].Proto
})
targets := make([]TargetResult, 0, len(keys))
for _, k := range keys {
starttls := defaultSTARTTLS[k.Port]
if v, ok := starttlsOverride[starttlsKey(k.Port, k.Proto)]; ok {
starttls = v
}
t := TargetResult{
Owner: tlsaOwnerName(k.Port, k.Proto, k.Base),
Host: k.Base,
Port: k.Port,
Proto: k.Proto,
STARTTLS: starttls,
Records: groups[k],
}
t.Ref = tlscontract.Ref(endpointFromTarget(t))
targets = append(targets, t)
}
return &DANEData{
Targets: targets,
CollectedAt: time.Now().UTC(),
}, nil
}
// endpointFromTarget builds the TLSEndpoint for a collected target.
func endpointFromTarget(t TargetResult) tlscontract.TLSEndpoint {
return tlscontract.TLSEndpoint{
Host: t.Host,
Port: t.Port,
SNI: t.Host,
STARTTLS: t.STARTTLS,
RequireSTARTTLS: t.STARTTLS != "" && t.Port != 25, // SMTP on 25 stays opportunistic
}
}
// DiscoverEntries publishes one tls.endpoint.v1 entry per target so
// checker-tls probes them in its next cycle. Implements sdk.DiscoveryPublisher.
func (p *daneProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*DANEData)
if !ok || d == nil {
return nil, nil
}
out := make([]sdk.DiscoveryEntry, 0, len(d.Targets))
for _, t := range d.Targets {
entry, err := tlscontract.NewEntry(endpointFromTarget(t))
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}
// serviceFromOptions extracts and decodes the happyDomain service payload.
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
v, ok := opts[OptionService]
if !ok {
return nil, fmt.Errorf("service option missing")
}
raw, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal service option: %w", err)
}
var svc serviceMessage
if err := json.Unmarshal(raw, &svc); err != nil {
return nil, fmt.Errorf("decode service option: %w", err)
}
return &svc, nil
}
// joinName resolves a possibly-relative TLSA base name against the service's
// subdomain bucket and the zone apex, returning a fully-qualified host name
// without trailing dot. An empty base means "the subdomain/apex itself".
func joinName(base, subdomain, apex string) string {
base = strings.TrimSuffix(base, ".")
// Absolute match to apex: return apex; otherwise treat as relative.
if base == "" {
if subdomain != "" {
return strings.TrimSuffix(subdomain+"."+apex, ".")
}
return apex
}
// If base already ends with apex (fully qualified), keep as-is.
if apex != "" && (base == apex || strings.HasSuffix(base, "."+apex)) {
return base
}
// Otherwise, base is relative to the subdomain bucket (or apex).
if subdomain != "" {
return strings.TrimSuffix(base+"."+subdomain+"."+apex, ".")
}
if apex != "" {
return base + "." + apex
}
return base
}

73
checker/definition.go Normal file
View file

@ -0,0 +1,73 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
// Version defaults to "built-in"; standalone and plugin builds override it
// via -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// serviceType is the happyDomain service type this checker binds to.
const serviceType = "svcs.TLSAs"
// Definition is the package-level helper exposed to the plugin entrypoint.
func Definition() *sdk.CheckerDefinition {
return (&daneProvider{}).Definition()
}
// Definition satisfies sdk.CheckerDefinitionProvider.
func (p *daneProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "dane",
Name: "DANE / TLSA",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{serviceType},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyDANE},
HasHTMLReport: true,
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Probe timeout (ms)",
Description: "Forwarded to checker-tls for each DANE endpoint.",
Default: float64(tls.DefaultProbeTimeoutMs),
},
},
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionDomain,
Type: "string",
Label: "Domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
{
Id: OptionSubdomain,
Type: "string",
Label: "Subdomain",
AutoFill: sdk.AutoFillSubdomain,
},
{
Id: OptionService,
Label: "TLSAs service",
AutoFill: sdk.AutoFillService,
Hide: true,
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 6 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

167
checker/interactive.go Normal file
View file

@ -0,0 +1,167 @@
//go:build standalone
package checker
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
// tlsaLookup fetches TLSA records for owner via the system resolver.
// It is a package variable so tests can swap it for a fixture.
var tlsaLookup = lookupTLSA
// RenderForm lets a human run this checker standalone. The form only
// collects the endpoint coordinates; the expected TLSA records are read
// from DNS by ParseForm and the live certificate is fetched in-process by
// the SDK running checker-tls as a sibling (see RelatedProviders).
func (p *daneProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{Id: OptionDomain, Type: "string", Label: "Domain", Placeholder: "example.com", Required: true},
{Id: "port", Type: "uint", Label: "Port", Default: float64(443), Required: true},
{Id: "proto", Type: "string", Label: "Protocol", Choices: []string{"tcp", "udp"}, Default: "tcp"},
{
Id: "starttls",
Type: "string",
Label: "STARTTLS override",
Description: "Leave empty to auto-derive from port (25→smtp, 587→submission, 143→imap, …).",
},
{
Id: OptionProbeTimeoutMs,
Type: "uint",
Label: "Probe timeout (ms)",
Default: float64(tls.DefaultProbeTimeoutMs),
Description: "Forwarded to checker-tls for the live probe.",
},
}
}
// ParseForm turns the submitted endpoint into the same CheckerOptions
// shape happyDomain would feed Collect. The TLSA RRset expected by
// Collect is resolved live from DNS at _<port>._<proto>.<domain>; if
// nothing is published there, no validation is possible and the form is
// re-rendered with the error.
func (p *daneProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue(OptionDomain)), ".")
if domain == "" {
return nil, errors.New("domain is required")
}
portStr := strings.TrimSpace(r.FormValue("port"))
if portStr == "" {
return nil, errors.New("port is required")
}
port64, err := strconv.ParseUint(portStr, 10, 16)
if err != nil || port64 == 0 {
return nil, fmt.Errorf("invalid port %q: must be 1-65535", portStr)
}
port := uint16(port64)
proto := strings.TrimSpace(r.FormValue("proto"))
if proto == "" {
proto = "tcp"
}
if proto != "tcp" && proto != "udp" {
return nil, fmt.Errorf("invalid protocol %q: must be tcp or udp", proto)
}
owner := tlsaOwnerName(port, proto, domain)
records, err := tlsaLookup(owner)
if err != nil {
return nil, fmt.Errorf("TLSA lookup for %s: %w", owner, err)
}
if len(records) == 0 {
return nil, fmt.Errorf("no TLSA records found at %s", owner)
}
tlsaEntries := make([]map[string]any, 0, len(records))
for _, t := range records {
tlsaEntries = append(tlsaEntries, map[string]any{
"Hdr": map[string]any{"Name": owner},
"Usage": t.Usage,
"Selector": t.Selector,
"MatchingType": t.MatchingType,
"Certificate": strings.ToLower(t.Certificate),
})
}
body, err := json.Marshal(map[string]any{"tlsa": tlsaEntries})
if err != nil {
return nil, fmt.Errorf("marshal TLSAs service: %w", err)
}
opts := sdk.CheckerOptions{
OptionDomain: domain,
OptionService: serviceMessage{
Type: serviceType,
Domain: domain,
Service: body,
},
}
if s := strings.TrimSpace(r.FormValue("starttls")); s != "" {
opts[OptionSTARTTLS] = map[string]string{
starttlsKey(port, proto): s,
}
}
if v := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
opts[OptionProbeTimeoutMs] = float64(n)
}
}
return opts, nil
}
// RelatedProviders declares checker-tls as the sibling the SDK should run
// in-process during the interactive flow. The SDK harvests the discovery
// entries this checker publishes via DiscoverEntries and auto-fills
// checker-tls's OptionEndpoints (the option tagged
// sdk.AutoFillDiscoveryEntries in its definition), so the probe map the
// rule reads via GetRelated is populated with live data.
func (p *daneProvider) RelatedProviders() []sdk.ObservationProvider {
return []sdk.ObservationProvider{tls.Provider()}
}
// lookupTLSA queries the system resolver for TLSA records at owner.
// Falls back to 1.1.1.1 when /etc/resolv.conf is unreadable.
func lookupTLSA(owner string) ([]*dns.TLSA, error) {
resolver, err := interactiveResolver()
if err != nil {
return nil, err
}
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(owner), dns.TypeTLSA)
msg.RecursionDesired = true
msg.SetEdns0(4096, true)
c := new(dns.Client)
in, _, err := c.Exchange(msg, resolver)
if err != nil {
return nil, err
}
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
}
var out []*dns.TLSA
for _, rr := range in.Answer {
if t, ok := rr.(*dns.TLSA); ok {
out = append(out, t)
}
}
return out, nil
}
func interactiveResolver() (string, error) {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
return net.JoinHostPort("1.1.1.1", "53"), nil
}
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
}

144
checker/interactive_test.go Normal file
View file

@ -0,0 +1,144 @@
//go:build standalone
package checker
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/miekg/dns"
)
// stubTLSA returns a synthetic TLSA RR with the given fields, avoiding the
// textual-parse boilerplate of dns.NewRR.
func stubTLSA(owner string, usage, selector, matching uint8, cert string) *dns.TLSA {
return &dns.TLSA{
Hdr: dns.RR_Header{Name: dns.Fqdn(owner), Rrtype: dns.TypeTLSA, Class: dns.ClassINET, Ttl: 3600},
Usage: usage,
Selector: selector,
MatchingType: matching,
Certificate: cert,
}
}
func withStubLookup(t *testing.T, records []*dns.TLSA, err error) {
t.Helper()
prev := tlsaLookup
tlsaLookup = func(owner string) ([]*dns.TLSA, error) {
return records, err
}
t.Cleanup(func() { tlsaLookup = prev })
}
func postForm(values url.Values) *http.Request {
req := httptest.NewRequest("POST", "/check", strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.ParseForm()
return req
}
func TestParseForm_PopulatesServiceFromDNS(t *testing.T) {
withStubLookup(t, []*dns.TLSA{
stubTLSA("_443._tcp.example.com", 3, 1, 1, "DEADBEEF"),
stubTLSA("_443._tcp.example.com", 2, 0, 1, "cafebabe"),
}, nil)
p := &daneProvider{}
opts, err := p.ParseForm(postForm(url.Values{
"domain_name": {"example.com"},
"port": {"443"},
"proto": {"tcp"},
}))
if err != nil {
t.Fatalf("ParseForm: %v", err)
}
svc, ok := opts[OptionService].(serviceMessage)
if !ok {
t.Fatalf("service option has wrong type: %#v", opts[OptionService])
}
if svc.Type != serviceType {
t.Errorf("service type = %q, want %q", svc.Type, serviceType)
}
if svc.Domain != "example.com" {
t.Errorf("service domain = %q, want example.com", svc.Domain)
}
var body struct {
TLSA []struct {
Hdr struct {
Name string
}
Usage uint8
Selector uint8
MatchingType uint8
Certificate string
} `json:"tlsa"`
}
if err := json.Unmarshal(svc.Service, &body); err != nil {
t.Fatalf("decode service body: %v", err)
}
if len(body.TLSA) != 2 {
t.Fatalf("got %d TLSA entries, want 2", len(body.TLSA))
}
if body.TLSA[0].Certificate != "deadbeef" {
t.Errorf("expected lowercased cert, got %q", body.TLSA[0].Certificate)
}
if body.TLSA[0].Hdr.Name != "_443._tcp.example.com" {
t.Errorf("owner = %q, want _443._tcp.example.com", body.TLSA[0].Hdr.Name)
}
}
func TestParseForm_NoRecordsIsError(t *testing.T) {
withStubLookup(t, nil, nil)
p := &daneProvider{}
_, err := p.ParseForm(postForm(url.Values{
"domain_name": {"example.com"},
"port": {"443"},
"proto": {"tcp"},
}))
if err == nil {
t.Fatal("expected error when no TLSA records found, got nil")
}
if !strings.Contains(err.Error(), "no TLSA records") {
t.Errorf("unexpected error %v", err)
}
}
func TestParseForm_StartTLSOverride(t *testing.T) {
withStubLookup(t, []*dns.TLSA{stubTLSA("_25._tcp.mail.example.com", 3, 1, 1, "aa")}, nil)
p := &daneProvider{}
opts, err := p.ParseForm(postForm(url.Values{
"domain_name": {"mail.example.com"},
"port": {"25"},
"proto": {"tcp"},
"starttls": {"smtp"},
}))
if err != nil {
t.Fatalf("ParseForm: %v", err)
}
override, ok := opts[OptionSTARTTLS].(map[string]string)
if !ok {
t.Fatalf("starttls option type = %T", opts[OptionSTARTTLS])
}
if override["25/tcp"] != "smtp" {
t.Errorf("override[25/tcp] = %q, want smtp", override["25/tcp"])
}
}
func TestParseForm_InvalidPort(t *testing.T) {
p := &daneProvider{}
_, err := p.ParseForm(postForm(url.Values{
"domain_name": {"example.com"},
"port": {"0"},
"proto": {"tcp"},
}))
if err == nil {
t.Fatal("expected error for port 0")
}
}

14
checker/provider.go Normal file
View file

@ -0,0 +1,14 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// Provider returns a new DANE observation provider.
func Provider() sdk.ObservationProvider {
return &daneProvider{}
}
type daneProvider struct{}
func (p *daneProvider) Key() sdk.ObservationKey {
return ObservationKeyDANE
}

268
checker/report.go Normal file
View file

@ -0,0 +1,268 @@
package checker
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"sort"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
// GetHTMLReport implements sdk.CheckerHTMLReporter. The report opens with a
// diagnosis-first section that lists the most common DANE failure modes
// actually detected on the user's targets, each with a one-shot remediation
// snippet; a per-target table follows for reference.
func (p *daneProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data DANEData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("decode DANE data: %w", err)
}
probes := indexProbes(ctx.Related(tls.ObservationKeyTLSProbes))
rows := make([]reportRow, 0, len(data.Targets))
for _, t := range data.Targets {
probe := probes[t.Ref]
status, cls := targetStatus(t, probe)
leaf := "—"
if probe != nil && len(probe.Chain) > 0 {
leaf = probe.Chain[0].Subject
} else if probe != nil && probe.Error != "" {
leaf = "handshake error"
}
rows = append(rows, reportRow{
Owner: t.Owner,
Host: t.Host,
Port: t.Port,
Proto: t.Proto,
STARTTLS: t.STARTTLS,
RecordCount: len(t.Records),
StatusLabel: status,
StatusClass: cls,
Leaf: leaf,
})
}
view := reportView{
CollectedAt: data.CollectedAt.Format("2006-01-02 15:04 MST"),
TargetCount: len(data.Targets),
Diagnoses: diagnose(data, probes),
Rows: rows,
CSS: template.CSS(reportCSS),
}
var b bytes.Buffer
if err := reportTemplate.Execute(&b, view); err != nil {
return "", fmt.Errorf("render DANE report: %w", err)
}
return b.String(), nil
}
// reportView is the rendering payload passed to reportTemplate. Pre-computing
// the per-row status label/class and leaf string keeps the template free of
// branching beyond simple range/if.
type reportView struct {
CollectedAt string
TargetCount int
Diagnoses []diagnosis
Rows []reportRow
CSS template.CSS
}
type reportRow struct {
Owner string
Host string
Port uint16
Proto string
STARTTLS string
RecordCount int
StatusLabel string
StatusClass string
Leaf string
}
// diagnosis is a single actionable hint surfaced at the top of the report.
type diagnosis struct {
Severity string // crit | warn | info
Title string
Detail string
Fix string // ready-to-apply snippet (shell or zone fragment)
}
// diagnose scans every target and produces the minimum set of high-signal
// cards users need to act on. Priority ordering (most-common first):
//
// 1. no_match: TLSA records do not cover the live cert (post-rotation miss).
// 2. handshake_failed: endpoint unreachable or TLS broken, DANE can't be
// validated at all.
// 3. pkix_chain_invalid: usage 0/1 published but public chain is broken.
// 4. usage_3_matches_issuer: DANE-EE selector matches an intermediate
// the record is probably miscategorized (usage 2 was intended).
// 5. no_probe_yet: quiet informational to avoid false alarms on first run.
func diagnose(data DANEData, probes map[string]*tls.TLSProbe) []diagnosis {
var out []diagnosis
for _, t := range data.Targets {
probe := probes[t.Ref]
switch {
case probe == nil:
out = append(out, diagnosis{
Severity: SeverityInfo,
Title: fmt.Sprintf("Waiting for first TLS probe on %s:%d", t.Host, t.Port),
Detail: "checker-tls has not yet probed this endpoint. This is normal immediately after publishing a new TLSA record; status will clear on the next cycle.",
})
case !probeUsable(probe):
out = append(out, diagnosis{
Severity: SeverityCrit,
Title: fmt.Sprintf("Cannot reach %s:%d to validate DANE", t.Host, t.Port),
Detail: "TLS handshake failed, DANE publishes hashes for a certificate nobody can see. Either the service is down, the port is blocked, or STARTTLS negotiation is broken.",
Fix: handshakeFix(t),
})
default:
if summarizeMatches(t, probe).matched == 0 && len(t.Records) > 0 {
out = append(out, diagnosis{
Severity: SeverityCrit,
Title: fmt.Sprintf("No TLSA record matches the live certificate on %s:%d", t.Host, t.Port),
Detail: "This is the most common DANE outage cause: the certificate was rotated without rolling over the TLSA RRset, and validating resolvers are now rejecting the connection. Publish a TLSA record for the new certificate before removing the old one.",
Fix: proposedTLSA(t, probe),
})
}
if hasPKIXUsage(t) && (probe.ChainValid == nil || !*probe.ChainValid) {
out = append(out, diagnosis{
Severity: SeverityCrit,
Title: fmt.Sprintf("Usage 0/1 needs a publicly-trusted chain on %s:%d", t.Host, t.Port),
Detail: "TLSA usages 0 (PKIX-TA) and 1 (PKIX-EE) require the certificate chain to validate against system roots. Either re-issue through a publicly-trusted CA or switch to usage 2 / 3, which skip PKIX.",
})
}
if warn := suspiciousUsage(t, probe); warn != "" {
out = append(out, diagnosis{
Severity: SeverityWarn,
Title: fmt.Sprintf("Suspicious TLSA usage on %s:%d", t.Host, t.Port),
Detail: warn,
})
}
}
}
// Stable: crit first, then warn, then info; preserving encounter order
// within each group keeps the table and the cards aligned.
sort.SliceStable(out, func(i, j int) bool {
return sevRank(out[i].Severity) < sevRank(out[j].Severity)
})
return out
}
func sevRank(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
// hasPKIXUsage reports whether any TLSA record at this target demands PKIX
// validation (usage 0 or 1).
func hasPKIXUsage(t TargetResult) bool {
for _, r := range t.Records {
if r.Usage == UsagePKIXTA || r.Usage == UsagePKIXEE {
return true
}
}
return false
}
// proposedTLSA renders a ready-to-paste replacement RR using the most common
// DANE-EE + SPKI + SHA-256 triplet computed from the live leaf. This is the
// profile Let's Encrypt users are pushed towards because it survives any
// cert rotation that keeps the same key pair.
func proposedTLSA(t TargetResult, p *tls.TLSProbe) string {
if p == nil || len(p.Chain) == 0 {
return ""
}
return fmt.Sprintf("%s IN TLSA 3 1 1 %s", t.Owner, p.Chain[0].SPKISHA256)
}
// handshakeFix proposes a STARTTLS-aware first step when the probe failed.
func handshakeFix(t TargetResult) string {
if t.STARTTLS != "" {
return fmt.Sprintf("openssl s_client -connect %s:%d -starttls %s -servername %s", t.Host, t.Port, t.STARTTLS, t.Host)
}
return fmt.Sprintf("openssl s_client -connect %s:%d -servername %s", t.Host, t.Port, t.Host)
}
func targetStatus(t TargetResult, p *tls.TLSProbe) (label, class string) {
if p == nil {
return "Waiting for probe", "unknown"
}
if !probeUsable(p) {
return "Handshake failed", "crit"
}
if len(t.Records) == 0 {
return "No records", "info"
}
matched := summarizeMatches(t, p).matched
if matched == 0 {
return "No match", "crit"
}
return fmt.Sprintf("%d/%d match", matched, len(t.Records)), "ok"
}
var reportTemplate = template.Must(template.New("dane").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DANE report</title>
<style>{{.CSS}}</style>
</head>
<body><main>
<h1>DANE / TLSA</h1>
<p class="meta">Collected {{.CollectedAt}} · {{.TargetCount}} endpoint(s).</p>
{{with .Diagnoses}}<section class="diagnosis">
<h2>Action required</h2>
{{range .}}<article class="finding sev-{{.Severity}}">
<h3>{{.Title}}</h3>
<p>{{.Detail}}</p>
{{with .Fix}}<pre class="fix">{{.}}</pre>{{end}}
</article>
{{end}}</section>
{{end}}<section class="targets">
<h2>Endpoints</h2>
<table>
<thead><tr><th>Endpoint</th><th>Status</th><th>Records</th><th>Observed leaf</th></tr></thead>
<tbody>
{{range .Rows}}<tr class="status-{{.StatusClass}}">
<td><code>{{.Owner}}</code><br><small>{{.Proto}} {{.Host}}:{{.Port}}{{with .STARTTLS}} · STARTTLS {{.}}{{end}}</small></td>
<td>{{.StatusLabel}}</td>
<td>{{.RecordCount}}</td>
<td>{{.Leaf}}</td>
</tr>
{{end}}</tbody>
</table>
</section>
</main></body></html>`))
const reportCSS = `body{font-family:system-ui,sans-serif;margin:0;background:#fafbfc;color:#1b1f23;}
main{max-width:980px;margin:0 auto;padding:1.5rem;}
h1{margin:0 0 .25rem 0;}
.meta{color:#586069;margin:0 0 1.5rem 0;}
section{margin-bottom:2rem;}
h2{border-bottom:1px solid #e1e4e8;padding-bottom:.25rem;}
.finding{border-left:4px solid;padding:.75rem 1rem;margin:.75rem 0;background:#fff;border-radius:4px;}
.finding h3{margin:0 0 .25rem 0;font-size:1rem;}
.finding.sev-crit{border-color:#d73a49;}
.finding.sev-warn{border-color:#dbab09;}
.finding.sev-info{border-color:#0366d6;}
.fix{background:#1b1f23;color:#fafbfc;padding:.5rem .75rem;border-radius:4px;overflow-x:auto;font-size:.85rem;}
table{width:100%;border-collapse:collapse;background:#fff;}
th,td{padding:.5rem .75rem;border-bottom:1px solid #e1e4e8;text-align:left;vertical-align:top;}
tr.status-crit td:nth-child(2){color:#d73a49;font-weight:600;}
tr.status-ok td:nth-child(2){color:#22863a;font-weight:600;}
tr.status-unknown td:nth-child(2){color:#586069;}
code{font-size:.85rem;}
small{color:#586069;}`

245
checker/rule.go Normal file
View file

@ -0,0 +1,245 @@
package checker
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
// Rules returns the full list of CheckRules exposed by the DANE checker.
// Each rule covers exactly one concern so the UI can show per-concern
// status rather than a single monolithic rule that multiplexes many codes.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&hasRecordsRule{},
&probeAvailableRule{},
&handshakeOKRule{},
&recordsMatchChainRule{},
&pkixChainValidRule{},
&usageCoherentRule{},
}
}
// ruleContext bundles the data rules typically need: the checker's own
// observation plus the map of related TLS probes keyed by endpoint Ref.
type ruleContext struct {
data DANEData
probes map[string]*tls.TLSProbe
// warn is a non-fatal issue encountered while loading related probes
// (e.g. the cross-checker lineage was unreachable). Rules surface it
// as an error state so operators can spot misconfiguration.
warn string
// err is a fatal error loading the checker's own observation.
err error
}
// loadRuleContext fetches the DANE observation and the related TLS probes.
// Rules call this once and then filter on the fields they care about.
func loadRuleContext(ctx context.Context, obs sdk.ObservationGetter) *ruleContext {
rc := &ruleContext{}
if err := obs.Get(ctx, ObservationKeyDANE, &rc.data); err != nil {
rc.err = err
return rc
}
rc.probes, rc.warn = relatedTLSProbes(ctx, obs)
return rc
}
// observationErrorState is the canonical short-circuit state emitted when a
// rule cannot load the DANE observation at all.
func observationErrorState(err error) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read %s: %v", ObservationKeyDANE, err),
Code: "dane_observation_error",
}
}
// targetMeta builds the common Meta map for per-endpoint states.
func targetMeta(t TargetResult) map[string]any {
return map[string]any{
"host": t.Host,
"port": t.Port,
"proto": t.Proto,
"owner": t.Owner,
"starttls": t.STARTTLS,
"records": len(t.Records),
}
}
// targetSubject is the human-readable subject tag used on per-endpoint states.
func targetSubject(t TargetResult) string {
return fmt.Sprintf("%s:%d (%s)", t.Host, t.Port, t.Proto)
}
// probeUsable reports whether p carries a successfully-observed certificate
// chain. Rules that need to compare against the chain skip endpoints where
// this is false; the missing/failed cases are surfaced by probeAvailableRule
// and handshakeOKRule respectively, so other rules stay focused.
func probeUsable(p *tls.TLSProbe) bool {
return p != nil && p.Error == "" && len(p.Chain) > 0
}
// matchSummary aggregates per-target match outcomes so callers don't redo the
// per-record loop. firstUnmatchedIdx is -1 when every record matched.
type matchSummary struct {
matched, unmatched int
firstUnmatchedIdx int
firstUnmatchedReason string
}
// summarizeMatches walks t.Records once and reports how many matched p's
// chain, plus the first unmatched index and reason for messaging.
func summarizeMatches(t TargetResult, p *tls.TLSProbe) matchSummary {
s := matchSummary{firstUnmatchedIdx: -1}
if p == nil {
return s
}
for i, rec := range t.Records {
ok, reason := matchRecord(rec, p)
if ok {
s.matched++
continue
}
s.unmatched++
if s.firstUnmatchedIdx < 0 {
s.firstUnmatchedIdx = i
s.firstUnmatchedReason = reason
}
}
return s
}
// matchRecord returns true when rec matches some certificate at the chain
// slot implied by rec.Usage. reason explains the miss on a false return.
//
// Slot selection:
//
// - Usage 1 (PKIX-EE) and 3 (DANE-EE): leaf only.
// - Usage 0 (PKIX-TA) and 2 (DANE-TA): intermediates + the root the
// server presented (if any). We match against every non-leaf cert the
// server sent, because some deployments publish the root and some the
// intermediate; either is a valid TA reference for the connection's
// path.
func matchRecord(rec TLSARecord, p *tls.TLSProbe) (bool, string) {
if len(p.Chain) == 0 {
return false, "no certificates observed on the endpoint"
}
var slots []tls.CertInfo
switch rec.Usage {
case UsagePKIXEE, UsageDANEEE:
slots = p.Chain[:1]
case UsagePKIXTA, UsageDANETA:
if len(p.Chain) > 1 {
slots = p.Chain[1:]
} else {
// Self-signed / bundle with only a leaf: allow matching against
// the leaf as a degenerate TA so the user gets a hash comparison
// rather than a silent "no slot".
slots = p.Chain[:1]
}
default:
return false, fmt.Sprintf("unsupported TLSA usage %d", rec.Usage)
}
for _, c := range slots {
got, err := recordCandidate(rec, c)
if err != nil {
return false, err.Error()
}
if strings.EqualFold(got, rec.Certificate) {
return true, ""
}
}
return false, fmt.Sprintf("expected %s, got none matching in chain", truncHex(rec.Certificate))
}
// recordCandidate returns the hex value the TLSA record should match for
// the (selector, matching_type) pair against this certificate slot. For
// matching_type 0 (Full), both sides are compared as hex-encoded DER.
func recordCandidate(rec TLSARecord, c tls.CertInfo) (string, error) {
var source string
switch rec.Selector {
case SelectorCert:
switch rec.MatchingType {
case MatchingFull:
der, err := base64.StdEncoding.DecodeString(c.DERBase64)
if err != nil {
return "", fmt.Errorf("decode cert DER: %w", err)
}
source = hex.EncodeToString(der)
case MatchingSHA256:
source = c.CertSHA256
case MatchingSHA512:
source = c.CertSHA512
default:
return "", fmt.Errorf("unsupported matching type %d", rec.MatchingType)
}
case SelectorSPKI:
switch rec.MatchingType {
case MatchingFull:
spki, err := base64.StdEncoding.DecodeString(c.SPKIDERBase64)
if err != nil {
return "", fmt.Errorf("decode SPKI DER: %w", err)
}
source = hex.EncodeToString(spki)
case MatchingSHA256:
source = c.SPKISHA256
case MatchingSHA512:
source = c.SPKISHA512
default:
return "", fmt.Errorf("unsupported matching type %d", rec.MatchingType)
}
default:
return "", fmt.Errorf("unsupported selector %d", rec.Selector)
}
return source, nil
}
// parseTLSProbeMap decodes one related-observation payload into its constituent
// probes, keyed by endpoint Ref. Returns nil on decode error (caller skips).
func parseTLSProbeMap(data []byte) map[string]tls.TLSProbe {
var payload struct {
Probes map[string]tls.TLSProbe `json:"probes"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
return payload.Probes
}
// relatedTLSProbes indexes TLS probes fetched via GetRelated by endpoint Ref.
func relatedTLSProbes(ctx context.Context, obs sdk.ObservationGetter) (map[string]*tls.TLSProbe, string) {
related, err := obs.GetRelated(ctx, tls.ObservationKeyTLSProbes)
if err != nil {
return nil, "related TLS probes unavailable: " + err.Error()
}
return indexProbes(related), ""
}
// indexProbes flattens a slice of related TLS-probe observations into a probe
// map keyed by endpoint Ref. Shared by the rule path (relatedTLSProbes) and
// the report path (GetHTMLReport), which receive the same RelatedObservation
// type from different SDK entry points.
func indexProbes(related []sdk.RelatedObservation) map[string]*tls.TLSProbe {
out := map[string]*tls.TLSProbe{}
for _, ro := range related {
for k, v := range parseTLSProbeMap(ro.Data) {
out[k] = &v
}
}
return out
}
func truncHex(s string) string {
if len(s) > 12 {
return s[:12] + "…"
}
return s
}

View file

@ -0,0 +1,57 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// handshakeOKRule reports whether the TLS handshake succeeded on every
// endpoint that has been probed. A failing handshake means DANE cannot be
// validated regardless of what TLSA records are published.
type handshakeOKRule struct{}
func (r *handshakeOKRule) Name() string { return "dane.handshake_ok" }
func (r *handshakeOKRule) Description() string {
return "Verifies the TLS handshake succeeds on every DANE endpoint so the presented chain can be compared to TLSA records."
}
func (r *handshakeOKRule) 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 out []sdk.CheckState
tested := 0
for _, t := range rc.data.Targets {
probe := rc.probes[t.Ref]
if probe == nil {
continue // covered by probeAvailableRule
}
tested++
if !probeUsable(probe) {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_handshake_failed",
Subject: targetSubject(t),
Message: "TLS handshake failed, cannot validate DANE: " + probe.Error,
Meta: targetMeta(t),
})
}
}
if len(out) == 0 {
if tested == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dane_handshake_ok_skipped",
Message: "No probed endpoint to evaluate (waiting for checker-tls).",
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dane_handshake_ok",
Message: "TLS handshake succeeds on every probed endpoint.",
}}
}
return out
}

80
checker/rules_match.go Normal file
View file

@ -0,0 +1,80 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// recordsMatchChainRule is the core DANE check: for every endpoint whose
// handshake succeeded, at least one declared TLSA record must match the
// certificate chain presented by the server (RFC 6698 §2.1 OR semantics).
//
// This is the most common DANE outage vector, a certificate rotation
// without a matching TLSA rollover, so it deserves its own rule and its
// own per-endpoint states.
type recordsMatchChainRule struct{}
func (r *recordsMatchChainRule) Name() string { return "dane.records_match_chain" }
func (r *recordsMatchChainRule) Description() string {
return "Verifies that at least one TLSA record matches the certificate chain presented by each endpoint."
}
func (r *recordsMatchChainRule) 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 out []sdk.CheckState
tested := 0
for _, t := range rc.data.Targets {
probe := rc.probes[t.Ref]
if !probeUsable(probe) {
continue // covered by probeAvailableRule / handshakeOKRule
}
if len(t.Records) == 0 {
continue // covered by hasRecordsRule
}
tested++
subj := targetSubject(t)
meta := targetMeta(t)
s := summarizeMatches(t, probe)
meta["matched"] = s.matched
meta["unmatched"] = s.unmatched
if s.matched > 0 {
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Code: "dane_match_ok",
Subject: subj,
Message: fmt.Sprintf("%d/%d TLSA record(s) match the presented certificate chain.", s.matched, s.matched+s.unmatched),
Meta: meta,
})
continue
}
msg := "No TLSA record matches the presented certificate chain."
if s.firstUnmatchedReason != "" {
msg += " " + s.firstUnmatchedReason
}
meta["first_unmatched_index"] = s.firstUnmatchedIdx
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_no_match",
Subject: subj,
Message: msg,
Meta: meta,
})
}
if len(out) == 0 {
if tested == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dane_records_match_chain_skipped",
Message: "No usable probe/records pair to evaluate.",
}}
}
}
return out
}

61
checker/rules_pkix.go Normal file
View file

@ -0,0 +1,61 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// pkixChainValidRule reports whether endpoints that publish PKIX-dependent
// TLSA usages (0 or 1) also present a certificate chain that validates
// against the system trust store. DANE usages 2/3 are unaffected and
// skipped entirely by this rule.
type pkixChainValidRule struct{}
func (r *pkixChainValidRule) Name() string { return "dane.pkix_chain_valid" }
func (r *pkixChainValidRule) Description() string {
return "When TLSA usages 0 or 1 are published, verifies the certificate chain also validates against system trust roots."
}
func (r *pkixChainValidRule) 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 out []sdk.CheckState
tested := 0
for _, t := range rc.data.Targets {
probe := rc.probes[t.Ref]
if !probeUsable(probe) {
continue
}
if !hasPKIXUsage(t) {
continue
}
tested++
if probe.ChainValid == nil || !*probe.ChainValid {
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "dane_pkix_chain_invalid",
Subject: targetSubject(t),
Message: "Usage 0/1 requires a publicly-trusted chain, but the certificate chain did not validate against system roots.",
Meta: targetMeta(t),
})
}
}
if len(out) == 0 {
if tested == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dane_pkix_chain_valid_skipped",
Message: "No endpoint publishes PKIX-dependent TLSA usages (0/1).",
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dane_pkix_chain_valid_ok",
Message: "Every endpoint with PKIX-dependent usages presents a publicly-trusted chain.",
}}
}
return out
}

61
checker/rules_probe.go Normal file
View file

@ -0,0 +1,61 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// probeAvailableRule reports whether the downstream TLS checker has probed
// every endpoint we published. Absent probes are common immediately after a
// new TLSA record is published and should not flap the service red.
type probeAvailableRule struct{}
func (r *probeAvailableRule) Name() string { return "dane.probe_available" }
func (r *probeAvailableRule) Description() string {
return "Verifies a TLS probe is available for every DANE endpoint so the chain can be compared to TLSA records."
}
func (r *probeAvailableRule) 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.warn != "" {
return []sdk.CheckState{{
Status: sdk.StatusError,
Code: "dane_observation_warning",
Message: rc.warn,
}}
}
if len(rc.data.Targets) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dane_probe_available_skipped",
Message: "No DANE endpoints to probe.",
}}
}
out := make([]sdk.CheckState, 0, len(rc.data.Targets))
for _, t := range rc.data.Targets {
subj := targetSubject(t)
meta := targetMeta(t)
if rc.probes[t.Ref] == nil {
out = append(out, sdk.CheckState{
Status: sdk.StatusUnknown,
Code: "dane_no_probe",
Subject: subj,
Message: "No TLS probe available yet for this endpoint; re-evaluate after the next checker-tls cycle.",
Meta: meta,
})
continue
}
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Code: "dane_probe_available_ok",
Subject: subj,
Message: "TLS probe available for this endpoint.",
Meta: meta,
})
}
return out
}

36
checker/rules_records.go Normal file
View file

@ -0,0 +1,36 @@
package checker
import (
"context"
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)}
}
if len(rc.data.Targets) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dane_no_records",
Message: "No TLSA records declared on this service.",
}}
}
return []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)},
}}
}

86
checker/rules_usage.go Normal file
View file

@ -0,0 +1,86 @@
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
// usageCoherentRule flags TLSA records whose declared usage contradicts the
// chain slot their hash actually matches, typically a record published as
// usage 1 or 3 (end-entity) whose hash in fact matches an intermediate.
// That is almost always a publisher error: the intended usage was 0 or 2.
type usageCoherentRule struct{}
func (r *usageCoherentRule) Name() string { return "dane.usage_coherent" }
func (r *usageCoherentRule) Description() string {
return "Flags TLSA records whose declared usage does not match the chain slot they actually hash (e.g. usage 3 matching an intermediate)."
}
func (r *usageCoherentRule) 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 out []sdk.CheckState
tested := 0
for _, t := range rc.data.Targets {
probe := rc.probes[t.Ref]
if !probeUsable(probe) || len(probe.Chain) < 2 {
continue
}
tested++
warn := suspiciousUsage(t, probe)
if warn != "" {
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "dane_usage_incoherent",
Subject: targetSubject(t),
Message: warn,
Meta: targetMeta(t),
})
}
}
if len(out) == 0 {
if tested == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Code: "dane_usage_coherent_skipped",
Message: "No multi-cert chain probed yet; cannot assess usage coherence.",
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "dane_usage_coherent_ok",
Message: "End-entity TLSA records match end-entity certificates on every probed chain.",
}}
}
return out
}
// suspiciousUsage returns a human-readable hint when a record hash matches a
// chain slot that contradicts its declared usage (e.g. usage 3 whose hash
// actually matches the intermediate), almost always a publisher error. Used
// by both usageCoherentRule and the HTML report.
func suspiciousUsage(t TargetResult, p *tls.TLSProbe) string {
if p == nil || len(p.Chain) < 2 {
return ""
}
for _, r := range t.Records {
if r.Usage != UsageDANEEE && r.Usage != UsagePKIXEE {
continue
}
for _, c := range p.Chain[1:] {
cand, err := recordCandidate(r, c)
if err != nil {
continue
}
if strings.EqualFold(cand, r.Certificate) {
return "A record declared with usage 1/3 (end-entity) actually matches an intermediate certificate. It should probably use usage 0 or 2 (trust-anchor) instead."
}
}
}
return ""
}

106
checker/types.go Normal file
View file

@ -0,0 +1,106 @@
// Package checker implements the DANE/TLSA checker for happyDomain.
//
// This checker is bound to the svcs.TLSAs service. Collect takes the TLSA
// records the user published (or plans to publish) for the service, derives
// one TLS endpoint per distinct (port, proto, base name), and declares those
// endpoints as tls.endpoint.v1 discovery entries. checker-tls then probes
// them; on the next evaluation, this checker reads the related TLS probes
// via obs.GetRelated and verifies each TLSA record matches the certificate
// chain the probe observed.
//
// The user-visible contract matches what DANE deployers expect:
//
// - Usage 0 (PKIX-TA) / 1 (PKIX-EE): also require the PKIX chain to be
// publicly trusted.
// - Usage 2 (DANE-TA) / 3 (DANE-EE): trust the TLSA as the anchor; PKIX
// validity is informational.
// - Selector 0 (Cert) / 1 (SPKI) and matching types 0/1/2 (Full/SHA-256/
// SHA-512) are matched against the chain slot implied by the usage.
package checker
import "time"
// ObservationKeyDANE is the observation key this checker writes.
const ObservationKeyDANE = "dane_checks"
// Option ids on CheckerOptions.
const (
// OptionService is auto-filled by the happyDomain host with the
// svcs.TLSAs service payload this checker is bound to.
OptionService = "service"
// OptionDomain is auto-filled with the domain apex. TLSA owner names
// in the service are relative to this apex.
OptionDomain = "domain_name"
// OptionSubdomain is the optional sub-zone under which the TLSAs
// service lives (matches the svcs.TLSAs analyzer's subdomain bucket).
OptionSubdomain = "subdomain"
// OptionProbeTimeoutMs is how long each underlying TLS probe is allowed.
// Passed through to checker-tls verbatim via the discovery entry options.
OptionProbeTimeoutMs = "probeTimeoutMs"
// OptionSTARTTLS is an optional per-endpoint STARTTLS hint keyed by
// "<port>/<proto>" → RFC 6335 service name (e.g. "25/tcp" → "smtp",
// "587/tcp" → "submission"). Common ports auto-map via a built-in table.
OptionSTARTTLS = "starttls"
)
// Severity constants mirror checker-tls.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
)
// TLSA field enum constants (RFC 6698 §2.1).
const (
UsagePKIXTA uint8 = 0
UsagePKIXEE uint8 = 1
UsageDANETA uint8 = 2
UsageDANEEE uint8 = 3
SelectorCert uint8 = 0
SelectorSPKI uint8 = 1
MatchingFull uint8 = 0
MatchingSHA256 uint8 = 1
MatchingSHA512 uint8 = 2
)
// DANEData is the full payload the checker writes under ObservationKeyDANE.
type DANEData struct {
// Targets is one entry per (port, proto, basename) triplet extracted
// from the TLSAs service.
Targets []TargetResult `json:"targets"`
CollectedAt time.Time `json:"collected_at"`
}
// TargetResult groups all TLSA records declared on a single endpoint and
// carries enough context to render an actionable HTML row per endpoint.
type TargetResult struct {
// Owner is the fully qualified DANE owner name (_<port>._<proto>.<host>).
Owner string `json:"owner"`
// Host is the connection target (typically the base name the TLSA
// records live under, or its MX/SRV target when relevant).
Host string `json:"host"`
Port uint16 `json:"port"`
Proto string `json:"proto"`
STARTTLS string `json:"starttls,omitempty"`
// Ref ties this target to the tls.endpoint.v1 discovery entry the
// checker emitted, so the rule can pick the matching RelatedObservation.
Ref string `json:"ref"`
// Records are the TLSA records declared for this endpoint.
Records []TLSARecord `json:"records"`
}
// TLSARecord is a user-facing view of a single dns.TLSA record.
type TLSARecord struct {
Usage uint8 `json:"usage"`
Selector uint8 `json:"selector"`
MatchingType uint8 `json:"matching_type"`
Certificate string `json:"certificate"` // lowercase hex
}