Initial commit

This commit is contained in:
nemunaire 2026-04-23 01:52:25 +07:00
commit a2a7921cb8
20 changed files with 1868 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
checker-caa
checker-caa.so
AllCAAIdentifiersReport.csv

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go generate ./... && CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-caa .
FROM scratch
COPY --from=builder /checker-caa /checker-caa
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/checker-caa"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-caa
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go) checker/AllCAAIdentifiersReport.csv
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

40
NOTICE Normal file
View file

@ -0,0 +1,40 @@
checker-caa
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0
-------------------------------------------------------------------------------
CCADB CAA Identifiers data
-------------------------------------------------------------------------------
The file checker/AllCAAIdentifiersReport.csv is an unmodified snapshot of
the "CAA Identifiers (V2)" report published by the Common CA Database
(CCADB), used here to map observed certificate issuers to the CAA domain
identifiers the corresponding certificate authorities publish for DNS
CAA "issue" / "issuewild" records.
CCADB data is maintained by the CCADB participating root programs and
distributed at https://www.ccadb.org/resources. The data is made available
by CCADB without warranty; see https://www.ccadb.org/ for details.

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# checker-caa
CAA posture checker for happyDomain.
Validates that certificates observed by `checker-tls` were issued by a
CA actually authorized by the domain's `CAA` records. This checker
runs no network probes of its own: it reads the `svcs.CAAPolicy`
service body (already parsed by happyDomain from the zone's `CAA`
resource records) and the `tls_probes` observations published by
`checker-tls`, and cross-references them via the CCADB "CAA
Identifiers" mapping.
## How it works
1. The host runs this checker on a `svcs.CAAPolicy` service.
2. `Collect` unmarshals the service body into a list of
`(flag, tag, value)` entries. No network.
3. The `caa_compliance` rule:
- calls `obs.Get("caa_policy", …)` to load its own payload;
- calls `obs.GetRelated("tls_probes")` to pick up every TLS probe
produced on the target;
- resolves each observed issuer (keyed by `IssuerAKI` with an
`IssuerDN` fallback) against the embedded CCADB CSV to find the
CA's published CAA identifier domain(s);
- compares the observed identifiers against the `issue` /
`issuewild` allow list (or flags a `DisallowIssue` violation).
## Observation payload
This checker does not publish endpoints or add a new observation
schema. Under its own observation key `caa_policy` it returns a
pass-through view of the zone-side CAA records:
```json
{
"domain": "example.net",
"records": [
{ "flag": 0, "tag": "issue", "value": "letsencrypt.org" },
{ "flag": 0, "tag": "issuewild", "value": ";" }
],
"run_at": "2026-04-22T12:34:56Z"
}
```
## Rule outcomes
- `caa_ok`: every observed issuer is authorized by the zone's CAA
policy.
- `caa_no_tls`: no TLS probes related to this target have been
published yet. Reported as `UNKNOWN` (the same "eventual
consistency" steady state used by `checker-tls` when it has no
endpoints yet).
- `caa_not_authorized`: CCADB mapped an observed issuer to a domain
the CAA policy does not list. Reported `CRIT`.
- `caa_issuance_disallowed`: the policy contains `CAA 0 issue ";"`
(explicitly disallowing issuance) but a TLS cert was still observed.
Reported `CRIT`.
- `caa_issuer_unknown`: CCADB has no mapping for the observed issuer
(AKI + DN). Reported `INFO`; action is to file a CCADB update.
## Issuer -> CAA domain mapping (CCADB)
The file `checker/AllCAAIdentifiersReport.csv` is an unmodified
snapshot of the "CAA Identifiers (V2)" report from the Common CA
Database (https://www.ccadb.org/resources). It is embedded into the
binary via `//go:embed` but is **not committed to the repository**.
To fetch or refresh it, run:
```bash
go generate ./checker/
```
This downloads the current CSV from CCADB. No code changes are needed
to pick up a new snapshot: only a re-embed (recompile) is required
after the file is refreshed. Note that the download depends on CCADB
being reachable; `go build` itself has no network dependency.
The lookup key is:
1. `IssuerAKI` (uppercase hex of `leaf.AuthorityKeyId`), matched
against CCADB's `"Subject Key Identifier (Hex)"` column.
2. `IssuerDN` (Go's `leaf.Issuer.String()`), matched against CCADB's
`"Subject"` column after normalization (RDNs sorted by type,
whitespace trimmed, comma/semicolon separators collapsed).
## Options
| Id | Type | Default | Description |
|-----------|--------|---------|------------------------------------|
| `domain` | string | (auto) | Domain being checked (`AutoFill`). |
| `service` | (n/a) | (auto) | `svcs.CAAPolicy` service body. |
## Running
```bash
# Plugin (loaded by happyDomain at startup)
make plugin
# Standalone HTTP server
make && ./checker-caa -listen :8080
```

203
checker/ccadb.go Normal file
View file

@ -0,0 +1,203 @@
package checker
import (
"bytes"
_ "embed"
"encoding/csv"
"fmt"
"io"
"sort"
"strings"
"sync"
)
//go:generate wget -O AllCAAIdentifiersReport.csv https://ccadb.my.salesforce-sites.com/ccadb/AllCAAIdentifiersReportCSVV2
//go:embed AllCAAIdentifiersReport.csv
var ccadbCSV []byte
// ccadbIndex is the in-memory representation of AllCAAIdentifiersReport.csv.
// Two indexes are maintained because CCADB rows sometimes have an empty
// Subject Key Identifier column (very rare; a handful of legacy entries)
// and we want to still resolve those via Subject DN.
type ccadbIndex struct {
bySKI map[string][]string
byDN map[string][]string
}
var (
ccadbOnce sync.Once
ccadb *ccadbIndex
ccadbErr error
)
// loadCCADB parses the embedded CSV once. Failure means the binary
// itself is broken.
func loadCCADB() (*ccadbIndex, error) {
ccadbOnce.Do(func() {
ccadb, ccadbErr = parseCCADB(bytes.NewReader(ccadbCSV))
})
return ccadb, ccadbErr
}
// parseCCADB is exposed for testing with alternate CSV inputs.
func parseCCADB(r io.Reader) (*ccadbIndex, error) {
reader := csv.NewReader(r)
reader.FieldsPerRecord = -1 // some rows carry a trailing empty field
header, err := reader.Read()
if err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
idxSubject := -1
idxSKI := -1
idxDomains := -1
for i, h := range header {
switch strings.TrimSpace(h) {
case "Subject":
idxSubject = i
case "Subject Key Identifier (Hex)":
idxSKI = i
case "Recognized CAA Domains":
idxDomains = i
}
}
if idxSubject < 0 || idxSKI < 0 || idxDomains < 0 {
return nil, fmt.Errorf("unexpected CCADB header: %v", header)
}
minCols := max(idxSubject, idxSKI, idxDomains)
idx := &ccadbIndex{
bySKI: map[string][]string{},
byDN: map[string][]string{},
}
for {
row, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read row: %w", err)
}
if len(row) <= minCols {
continue
}
domains := splitCAADomains(row[idxDomains])
if len(domains) == 0 {
continue
}
if ski := strings.ToUpper(strings.TrimSpace(row[idxSKI])); ski != "" {
idx.bySKI[ski] = mergeDomains(idx.bySKI[ski], domains)
}
if dn := normalizeDN(row[idxSubject]); dn != "" {
idx.byDN[dn] = mergeDomains(idx.byDN[dn], domains)
}
}
return idx, nil
}
// Lookup resolves an observed issuer to its CAA identifier domains.
// AKI takes precedence; DN is the fallback for rows without an SKI.
// The returned slice is a fresh copy; callers may retain or mutate it.
func Lookup(aki, dn string) ([]string, bool) {
idx, err := loadCCADB()
if err != nil || idx == nil {
return nil, false
}
if aki != "" {
if d, ok := idx.bySKI[strings.ToUpper(strings.TrimSpace(aki))]; ok && len(d) > 0 {
return append([]string(nil), d...), true
}
}
if dn != "" {
if d, ok := idx.byDN[normalizeDN(dn)]; ok && len(d) > 0 {
return append([]string(nil), d...), true
}
}
return nil, false
}
// splitCAADomains lowercases because CAA identifiers are case-insensitive.
func splitCAADomains(raw string) []string {
var out []string
for d := range strings.SplitSeq(raw, ",") {
d = strings.TrimSpace(strings.ToLower(d))
if d != "" {
out = append(out, d)
}
}
return out
}
// mergeDomains appends new entries to an existing slice, de-duplicating.
// CCADB occasionally lists the same CA twice (cross-signs, re-issues);
// we don't want that to bloat the lookup result.
func mergeDomains(existing, add []string) []string {
if len(existing) == 0 {
return append([]string(nil), add...)
}
seen := map[string]bool{}
for _, d := range existing {
seen[d] = true
}
for _, d := range add {
if !seen[d] {
existing = append(existing, d)
seen[d] = true
}
}
return existing
}
// normalizeDN canonicalizes a subject DN so Go's comma-joined form
// compares equal to CCADB's semicolon-joined form for the same RDNs.
// Intentionally permissive: escaping differences are ignored; AKI is
// the common path anyway.
func normalizeDN(dn string) string {
if dn == "" {
return ""
}
fields := splitRDNs(dn)
for i, f := range fields {
f = strings.TrimSpace(f)
if eq := strings.IndexByte(f, '='); eq > 0 {
f = strings.ToUpper(f[:eq]) + "=" + strings.TrimSpace(f[eq+1:])
}
fields[i] = f
}
sort.Strings(fields)
return strings.Join(fields, ",")
}
// splitRDNs splits a DN string on either ',' or ';', respecting
// backslash escapes. Most RDN values in CCADB do not contain escaped
// separators, but a handful (paths in OU values) do.
func splitRDNs(dn string) []string {
var out []string
var cur strings.Builder
escape := false
for i := 0; i < len(dn); i++ {
c := dn[i]
if escape {
cur.WriteByte(c)
escape = false
continue
}
switch c {
case '\\':
cur.WriteByte(c)
escape = true
case ',', ';':
out = append(out, cur.String())
cur.Reset()
default:
cur.WriteByte(c)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}

104
checker/ccadb_test.go Normal file
View file

@ -0,0 +1,104 @@
package checker
import (
"slices"
"strings"
"testing"
)
// TestCCADBEmbedded asserts the shipped CSV parses cleanly. If this
// fails the build produced a broken binary, so fail loudly.
func TestCCADBEmbedded(t *testing.T) {
idx, err := loadCCADB()
if err != nil {
t.Fatalf("load embedded CCADB: %v", err)
}
if len(idx.bySKI) < 100 {
t.Errorf("expected >=100 SKI entries, got %d", len(idx.bySKI))
}
if len(idx.byDN) < 100 {
t.Errorf("expected >=100 DN entries, got %d", len(idx.byDN))
}
}
// TestLookup_LetsEncryptR10 exercises the AKI path against a well-known,
// currently-active intermediate.
func TestLookup_LetsEncryptR10(t *testing.T) {
domains, ok := Lookup("BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", "")
if !ok {
t.Fatal("expected Let's Encrypt R10 AKI to resolve")
}
if !slices.Contains(domains, "letsencrypt.org") {
t.Errorf("expected letsencrypt.org in domains, got %v", domains)
}
}
// TestLookup_CaseInsensitiveAKI ensures callers don't need to pre-
// uppercase the AKI.
func TestLookup_CaseInsensitiveAKI(t *testing.T) {
upper, ok := Lookup("BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8", "")
if !ok {
t.Skip("fixture row missing from embedded CCADB")
}
lower, ok := Lookup("bbbcc347a5e4bca9c6c3a4720c108da235e1c8e8", "")
if !ok {
t.Fatal("lowercase AKI should resolve too")
}
if strings.Join(upper, ",") != strings.Join(lower, ",") {
t.Errorf("upper %v != lower %v", upper, lower)
}
}
// TestLookup_DNFallback asserts the DN path works when AKI is empty.
// We use Go's pkix.Name.String-style comma DN and expect it to match
// CCADB's semicolon DN for the same subject.
func TestLookup_DNFallback(t *testing.T) {
// The ISRG Root X2 row uses SKI 7C4296AEDE4B483BFA92F89E8CCF6D8BA9723795
// and Subject "CN=ISRG Root X2; O=Internet Security Research Group; C=US".
// Go would render the same DN with commas, so normalizeDN should
// collapse both to the same key.
domains, ok := Lookup("", "CN=ISRG Root X2,O=Internet Security Research Group,C=US")
if !ok {
t.Fatal("expected ISRG Root X2 DN to resolve via byDN index")
}
if !slices.Contains(domains, "letsencrypt.org") {
t.Errorf("expected letsencrypt.org, got %v", domains)
}
}
// TestLookup_Unknown ensures false is returned cleanly.
func TestLookup_Unknown(t *testing.T) {
if _, ok := Lookup("0000000000000000000000000000000000000000", ""); ok {
t.Error("unknown AKI must not resolve")
}
if _, ok := Lookup("", "CN=This CA Does Not Exist"); ok {
t.Error("unknown DN must not resolve")
}
if _, ok := Lookup("", ""); ok {
t.Error("empty inputs must not resolve")
}
}
// TestNormalizeDN_SortsAndUppercases exercises the canonicalization
// used by the DN fallback. This is the part most likely to miscompare
// across CSV formatting variations.
func TestNormalizeDN_SortsAndUppercases(t *testing.T) {
a := normalizeDN("CN=Foo,O=Bar,C=US")
b := normalizeDN("c=US; cn=Foo; o=Bar")
if a != b {
t.Errorf("expected canonical equality:\n a=%q\n b=%q", a, b)
}
}
// TestSplitCAADomains handles the comma-separated cell format that
// DigiCert and similar CAs use.
func TestSplitCAADomains(t *testing.T) {
got := splitCAADomains("www.digicert.com, digicert.com, amazon.com")
want := []string{"www.digicert.com", "digicert.com", "amazon.com"}
if !slices.Equal(got, want) {
t.Errorf("splitCAADomains got %v want %v", got, want)
}
if splitCAADomains("") != nil {
t.Error("empty input should yield nil")
}
}

89
checker/collect.go Normal file
View file

@ -0,0 +1,89 @@
package checker
import (
"context"
"encoding/json"
"fmt"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// serviceType is the happyDomain service type string this checker binds to.
const serviceType = "svcs.CAAPolicy"
// serviceMessage is a local copy of happydns.ServiceMessage to avoid
// depending on the happyDomain core repository.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
type caaPolicyPayload struct {
Records []caaRecordPayload `json:"caa"`
}
// caaRecordPayload matches miekg/dns.CAA's JSON tags
// (Hdr/Flag/Tag/Value) closely enough to round-trip through the
// service body. We only keep Flag/Tag/Value; the Hdr is ignored.
type caaRecordPayload struct {
Flag uint8 `json:"Flag"`
Tag string `json:"Tag"`
Value string `json:"Value"`
}
// Collect reads the auto-filled service body, validates the type, and
// returns the CAA records flattened into CAAData. No network call.
func (p *caaProvider) 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 pol caaPolicyPayload
if err := json.Unmarshal(svc.Service, &pol); err != nil {
return nil, fmt.Errorf("decode CAA policy: %w", err)
}
records := make([]CAARecord, 0, len(pol.Records))
for _, r := range pol.Records {
records = append(records, CAARecord{Flag: r.Flag, Tag: r.Tag, Value: r.Value})
}
domain := svc.Domain
if domain == "" {
if v, _ := sdk.GetOption[string](opts, "domain"); v != "" {
domain = v
}
}
return &CAAData{
Domain: domain,
Records: records,
RunAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
// serviceFromOptions normalizes the "service" option via a JSON
// round-trip so the in-process plugin path (native Go value) and the
// HTTP path (decoded map[string]any) both work without importing the
// upstream type.
func serviceFromOptions(opts sdk.CheckerOptions) (*serviceMessage, error) {
v, ok := opts["service"]
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
}

49
checker/definition.go Normal file
View file

@ -0,0 +1,49 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version defaults to "built-in"; standalone and plugin builds override
// it via -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// Definition implements sdk.CheckerDefinitionProvider on the provider.
func (p *caaProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "caa",
Name: "CAA Compliance",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{serviceType},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyCAA},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "Domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: "service",
Label: "Service",
AutoFill: sdk.AutoFillService,
},
},
},
Rules: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 12 * time.Hour,
},
}
}

125
checker/interactive.go Normal file
View file

@ -0,0 +1,125 @@
//go:build standalone
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"github.com/miekg/dns"
)
// dnsLookupTimeout caps a single CAA query so the standalone HTTP
// handler can't be hung by a slow or hostile resolver.
const dnsLookupTimeout = 5 * time.Second
func (p *caaProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
},
}
}
// ParseForm resolves CAA records via direct DNS. TLS probes are not
// gathered here; the rule reports StatusUnknown for the cross-check
// when used standalone.
func (p *caaProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
return nil, errors.New("domain is required")
}
domain = dns.Fqdn(domain)
bare := strings.TrimSuffix(domain, ".")
records, err := lookupCAA(r.Context(), domain)
if err != nil {
return nil, fmt.Errorf("CAA lookup for %s: %w", domain, err)
}
payload := caaPolicyPayload{Records: make([]caaRecordPayload, 0, len(records))}
for _, rec := range records {
payload.Records = append(payload.Records, caaRecordPayload{
Flag: rec.Flag, Tag: rec.Tag, Value: rec.Value,
})
}
svcBody, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal CAA payload: %w", err)
}
svc := serviceMessage{
Type: serviceType,
Domain: bare,
Service: svcBody,
}
return sdk.CheckerOptions{
"domain": bare,
"service": svc,
}, nil
}
// lookupCAA queries CAA records for fqdn using the system resolver.
// Per RFC 8659 §3, climbing the label tree only continues on empty
// NOERROR; NXDOMAIN terminates the walk.
func lookupCAA(ctx context.Context, fqdn string) ([]CAARecord, error) {
resolver := systemResolver()
c := &dns.Client{Timeout: dnsLookupTimeout}
for name := fqdn; name != "" && name != "."; {
msg := new(dns.Msg)
msg.SetQuestion(name, dns.TypeCAA)
msg.RecursionDesired = true
in, _, err := c.ExchangeContext(ctx, msg, resolver)
if err != nil {
return nil, err
}
if in.Rcode == dns.RcodeNameError {
return nil, nil
}
if in.Rcode != dns.RcodeSuccess {
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
}
var out []CAARecord
for _, rr := range in.Answer {
if caa, ok := rr.(*dns.CAA); ok {
out = append(out, CAARecord{Flag: caa.Flag, Tag: caa.Tag, Value: caa.Value})
}
}
if len(out) > 0 {
return out, nil
}
i := strings.IndexByte(name, '.')
if i < 0 || i >= len(name)-1 {
break
}
name = name[i+1:]
}
return nil, nil
}
// systemResolver returns the first nameserver in /etc/resolv.conf as a
// host:port string suitable for dns.Client.Exchange. Falls back to
// 1.1.1.1:53 when resolv.conf is missing, unreadable, or empty.
func systemResolver() string {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || len(cfg.Servers) == 0 {
return net.JoinHostPort("1.1.1.1", "53")
}
return net.JoinHostPort(cfg.Servers[0], cfg.Port)
}

15
checker/provider.go Normal file
View file

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

299
checker/rule.go Normal file
View file

@ -0,0 +1,299 @@
package checker
import (
"context"
"fmt"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the rule that cross-references TLS observations against
// the zone's CAA policy.
func Rule() sdk.CheckRule {
return &caaRule{}
}
type caaRule struct{}
func (r *caaRule) Name() string { return "caa_compliance" }
func (r *caaRule) Description() string {
return "Cross-references TLS certificates observed on the domain against its CAA policy, using CCADB to map each issuer to its published CAA identifier."
}
// issuerAgg collects, per distinct issuer, the worst observation and
// the endpoints it appeared on.
type issuerAgg struct {
sample *tlsProbeView
severity string
code string
msg string
endpoints map[string]bool
}
type allowList struct {
issueAll map[string]bool // CAA 0 issue "<domain>"
issueWildAll map[string]bool // CAA 0 issuewild "<domain>"
disallowIssue bool // CAA 0 issue ";"
disallowWildcardIssue bool // CAA 0 issuewild ";"
// Per RFC 8659 §4.3, presence of any "issuewild" record makes it
// fully override "issue" for wildcard certs.
hasIssueWild bool
// Unknown tags with the Issuer Critical bit set: RFC 8659 §4.1
// requires a conformant CA to refuse issuance, so we surface them.
unknownCritical []string
}
// caaFlagCritical is the Issuer Critical bit (RFC 8659 §4.1).
const caaFlagCritical = 0x80
// buildAllowList builds the effective allow/deny sets per RFC 8659
// §4.2 "issue" and §4.3 "issuewild". Parameters after ';' on the
// issuer value are stripped.
func buildAllowList(records []CAARecord) allowList {
al := allowList{
issueAll: map[string]bool{},
issueWildAll: map[string]bool{},
}
for _, rec := range records {
tag := strings.ToLower(strings.TrimSpace(rec.Tag))
value := strings.TrimSpace(rec.Value)
switch tag {
case "issue":
if value == "" || value == ";" {
al.disallowIssue = true
} else {
al.issueAll[issuerFromValue(value)] = true
}
case "issuewild":
al.hasIssueWild = true
if value == "" || value == ";" {
al.disallowWildcardIssue = true
} else {
al.issueWildAll[issuerFromValue(value)] = true
}
case "iodef",
"contactemail", "contactphone",
"issuemail", "issuevmc":
// Recognized property tags (RFC 8659, RFC 9495, CA/B BR);
// listed only to suppress the unknown-critical warning.
default:
if rec.Flag&caaFlagCritical != 0 {
name := tag
if name == "" {
name = "(empty)"
}
al.unknownCritical = append(al.unknownCritical, name)
}
}
}
return al
}
func issuerFromValue(v string) string {
if i := strings.IndexByte(v, ';'); i >= 0 {
v = v[:i]
}
return strings.ToLower(strings.TrimSpace(v))
}
func (r *caaRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data CAAData
if err := obs.Get(ctx, ObservationKeyCAA, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read caa_policy: %v", err),
Code: CodeObservationError,
}}
}
al := buildAllowList(data.Records)
hasPolicy := len(al.issueAll) > 0 || al.disallowIssue ||
len(al.issueWildAll) > 0 || al.disallowWildcardIssue
// Policy-level findings (e.g. an unknown tag with the Issuer Critical
// bit set) are intrinsic to the published CAA records and must be
// reported regardless of whether checker-tls has produced probes yet.
var policyStates []sdk.CheckState
if len(al.unknownCritical) > 0 {
tags := append([]string(nil), al.unknownCritical...)
sort.Strings(tags)
policyStates = append(policyStates, sdk.CheckState{
Status: sdk.StatusWarn,
Code: CodeUnknownCritical,
Subject: "policy",
Message: fmt.Sprintf("CAA policy contains unknown tag(s) marked critical: %s; conformant CAs must refuse issuance",
strings.Join(tags, ", ")),
})
}
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
probes := parseAllTLSRelated(related)
if len(probes) == 0 {
return append(policyStates, sdk.CheckState{
Status: sdk.StatusUnknown,
Message: "No TLS probes have been observed for this target yet",
Code: CodeNoTLS,
})
}
// Per-issuer bookkeeping: "crit" overrides "info" for the same AKI
// so a CA that repeatedly shows up as unauthorized isn't demoted to
// info just because one probe happened to be unresolvable.
agg := map[string]*issuerAgg{} // keyed by AKI+DN
issue := func(p *tlsProbeView, severity, code, msg string) {
k := p.IssuerAKI + "|" + p.IssuerDN
cur, ok := agg[k]
if !ok {
cur = &issuerAgg{sample: p, endpoints: map[string]bool{}}
agg[k] = cur
}
if severityRank(severity) >= severityRank(cur.severity) {
cur.severity = severity
cur.code = code
cur.msg = msg
}
if addr := p.address(); addr != "" {
cur.endpoints[addr] = true
}
}
for _, p := range probes {
// Per RFC 8659 §4.3, if any "issuewild" record is present, it
// fully overrides "issue" for wildcard certificates. Otherwise
// "issue" applies to both wildcard and non-wildcard.
wildcard := p.isWildcard()
useWild := wildcard && al.hasIssueWild
denied := al.disallowIssue
allow := al.issueAll
tag := "issue"
if useWild {
denied = al.disallowWildcardIssue
allow = al.issueWildAll
tag = "issuewild"
}
if denied {
issue(p, SeverityCrit, CodeIssuanceDisallowed,
fmt.Sprintf("CAA policy forbids issuance (%s \";\") but a certificate was observed on %s", tag, p.address()))
continue
}
domains, ok := Lookup(p.IssuerAKI, p.IssuerDN)
if !ok {
issue(p, SeverityInfo, CodeIssuerUnknown,
fmt.Sprintf("Observed issuer not found in CCADB (AKI=%q, DN=%q)", p.IssuerAKI, p.IssuerDN))
continue
}
// If the zone has no issue/issuewild records at all, compliance
// can't be violated (RFC 8659 §2.2: "in the absence of CAA
// records any CA may issue"). Still surface an informational
// nudge recommending the user lock issuance down.
if !hasPolicy {
issue(p, SeverityInfo, CodeOK,
fmt.Sprintf("No CAA records published; certificate on %s issued by %s (CAA identifier %s).",
p.address(), issuerLabel(p), strings.Join(domains, ", ")))
continue
}
if !intersects(domains, allow) {
kind := "Certificate"
if wildcard {
kind = "Wildcard certificate"
}
issue(p, SeverityCrit, CodeNotAuthorized,
fmt.Sprintf("%s on %s issued by %s (CAA identifier %s) is not authorized by the zone's CAA %s records",
kind, p.address(), issuerLabel(p), strings.Join(domains, ", "), tag))
continue
}
issue(p, "", "", "")
}
// Emit one CheckState per distinct issuer, keyed deterministically so
// state ordering does not depend on map iteration.
keys := make([]string, 0, len(agg))
for k := range agg {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]sdk.CheckState, 0, len(keys)+len(policyStates))
out = append(out, policyStates...)
for _, k := range keys {
a := agg[k]
subject := issuerLabel(a.sample)
endpoints := make([]string, 0, len(a.endpoints))
for ep := range a.endpoints {
endpoints = append(endpoints, ep)
}
sort.Strings(endpoints)
meta := map[string]any{"endpoints": endpoints}
switch a.severity {
case SeverityCrit:
out = append(out, sdk.CheckState{
Status: sdk.StatusCrit, Message: a.msg, Code: a.code,
Subject: subject, Meta: meta,
})
case SeverityWarn:
out = append(out, sdk.CheckState{
Status: sdk.StatusWarn, Message: a.msg, Code: a.code,
Subject: subject, Meta: meta,
})
case SeverityInfo:
out = append(out, sdk.CheckState{
Status: sdk.StatusInfo, Message: a.msg, Code: a.code,
Subject: subject, Meta: meta,
})
default:
msg := "Certificate authorized by CAA policy"
if !hasPolicy {
msg = "Certificate observed; no CAA records published"
}
out = append(out, sdk.CheckState{
Status: sdk.StatusOK, Message: msg, Code: CodeOK,
Subject: subject, Meta: meta,
})
}
}
return out
}
func severityRank(s string) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
default:
return 0
}
}
func intersects(lhs []string, set map[string]bool) bool {
for _, s := range lhs {
if set[strings.ToLower(s)] {
return true
}
}
return false
}
// issuerLabel picks the most readable issuer name available on a probe.
func issuerLabel(p *tlsProbeView) string {
if p.Issuer != "" {
return p.Issuer
}
if p.IssuerDN != "" {
return p.IssuerDN
}
return "unknown issuer"
}

559
checker/rule_test.go Normal file
View file

@ -0,0 +1,559 @@
package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObsGetter is a minimal ObservationGetter for tests: it serves a
// canned CAAData under ObservationKeyCAA and a canned list of related
// observations under TLSRelatedKey.
type stubObsGetter struct {
data CAAData
related []sdk.RelatedObservation
}
func (s *stubObsGetter) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != ObservationKeyCAA {
return nil
}
b, _ := json.Marshal(s.data)
return json.Unmarshal(b, dest)
}
func (s *stubObsGetter) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return s.related, nil
}
// mkTLSObs wraps a single probe into the {"probes": {<ref>: …}} shape
// checker-tls actually emits.
func mkTLSObs(t *testing.T, ref string, probe map[string]any) sdk.RelatedObservation {
t.Helper()
payload := map[string]any{
"probes": map[string]any{ref: probe},
}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal tls payload: %v", err)
}
return sdk.RelatedObservation{
CheckerID: "tls",
Key: TLSRelatedKey,
Data: b,
CollectedAt: time.Now(),
Ref: ref,
}
}
// TestRule_OK: CAA allows letsencrypt.org and the probe is from a
// Let's Encrypt intermediate. Expect StatusOK.
func TestRule_OK(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_dn": "CN=R10,O=Let's Encrypt,C=US",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeOK {
t.Errorf("expected code %q, got %q", CodeOK, state.Code)
}
}
// TestRule_NotAuthorized: CAA only allows digicert.com but the probe
// shows a Let's Encrypt cert. Expect StatusCrit / caa_not_authorized.
func TestRule_NotAuthorized(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "digicert.com"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNotAuthorized {
t.Errorf("expected code %q, got %q", CodeNotAuthorized, state.Code)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected message to mention letsencrypt.org, got %q", state.Message)
}
}
// TestRule_IssuanceDisallowed: CAA says `issue ";"` but a cert was
// observed. Expect StatusCrit / caa_issuance_disallowed regardless of
// the issuer.
func TestRule_IssuanceDisallowed(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: ";"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuanceDisallowed {
t.Errorf("expected code %q, got %q", CodeIssuanceDisallowed, state.Code)
}
}
// TestRule_IssuerUnknown: the observed AKI is not in CCADB. Expect
// StatusInfo / caa_issuer_unknown.
func TestRule_IssuerUnknown(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
"issuer_dn": "CN=Totally Made Up CA,O=Nope,C=XX",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuerUnknown {
t.Errorf("expected code %q, got %q", CodeIssuerUnknown, state.Code)
}
}
// TestRule_NoTLS: no related TLS observations yet. Steady state during
// the eventual-consistency window before checker-tls has produced data.
func TestRule_NoTLS(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: nil,
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusUnknown {
t.Fatalf("expected StatusUnknown, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNoTLS {
t.Errorf("expected code %q, got %q", CodeNoTLS, state.Code)
}
}
// TestRule_NoCAAPublished: valid TLS cert, but the zone has no CAA
// records. Rule should nudge the user (StatusInfo) with a suggestion
// to publish CAA.
func TestRule_NoCAAPublished(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{Domain: "example.com", Records: nil},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
state := states[0]
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected suggestion to mention letsencrypt.org, got %q", state.Message)
}
}
// findState returns the first state matching code, or nil.
func findState(states []sdk.CheckState, code string) *sdk.CheckState {
for i := range states {
if states[i].Code == code {
return &states[i]
}
}
return nil
}
// TestRule_UnknownCriticalTag: an unknown tag with the Issuer Critical
// bit (0x80) must surface a Warn / caa_unknown_critical state.
func TestRule_UnknownCriticalTag(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 128, Tag: "frobnicate", Value: "yes"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
st := findState(states, CodeUnknownCritical)
if st == nil {
t.Fatalf("expected %s state, got %+v", CodeUnknownCritical, states)
}
if st.Status != sdk.StatusWarn {
t.Errorf("expected StatusWarn, got %s", st.Status)
}
if !strings.Contains(st.Message, "frobnicate") {
t.Errorf("expected unknown tag name in message, got %q", st.Message)
}
}
// TestRule_UnknownCritical_NoTLS: the policy-level warning must fire
// even when checker-tls has not yet produced any probes (issue #1: the
// warning was previously gated on probe presence).
func TestRule_UnknownCritical_NoTLS(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 128, Tag: "frobnicate", Value: "yes"},
},
},
related: nil,
}
states := Rule().Evaluate(context.Background(), obs, nil)
if findState(states, CodeUnknownCritical) == nil {
t.Errorf("expected %s state with no TLS probes, got %+v", CodeUnknownCritical, states)
}
if findState(states, CodeNoTLS) == nil {
t.Errorf("expected %s state alongside the warning, got %+v", CodeNoTLS, states)
}
}
// TestRule_CriticalIodef: iodef is a recognized tag, so the critical
// bit on it must not produce an unknown-critical warning.
func TestRule_CriticalIodef(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 128, Tag: "iodef", Value: "mailto:sec@example.com"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if st := findState(states, CodeUnknownCritical); st != nil {
t.Errorf("did not expect unknown-critical for iodef, got %+v", st)
}
}
// TestRule_CriticalIssue: critical bit on the well-known "issue" tag
// is normal (CAs always understand it) and must not warn.
func TestRule_CriticalIssue(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 128, Tag: "issue", Value: "letsencrypt.org"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if st := findState(states, CodeUnknownCritical); st != nil {
t.Errorf("did not expect unknown-critical for issue, got %+v", st)
}
}
// TestRule_CriticalEmptyTag: a malformed record with the critical bit
// set and an empty tag is still surfaced (issue #3, previously
// silently dropped).
func TestRule_CriticalEmptyTag(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 128, Tag: "", Value: "garbage"},
},
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if findState(states, CodeUnknownCritical) == nil {
t.Errorf("expected %s for critical empty tag, got %+v", CodeUnknownCritical, states)
}
}
// TestRule_KnownExtraTagsCritical: tags registered outside the v1
// vocabulary (contactemail, contactphone, issuemail, issuevmc) should
// not trigger unknown-critical warnings even when marked critical.
func TestRule_KnownExtraTagsCritical(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 128, Tag: "contactemail", Value: "sec@example.com"},
{Flag: 128, Tag: "contactphone", Value: "+1-555-0100"},
{Flag: 128, Tag: "issuemail", Value: "letsencrypt.org"},
{Flag: 128, Tag: "issuevmc", Value: "letsencrypt.org"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if st := findState(states, CodeUnknownCritical); st != nil {
t.Errorf("did not expect unknown-critical for known extra tags, got %+v", st)
}
}
// TestBuildAllowList is a unit test for the policy parser. The ';'
// sentinel and parameter stripping are the two subtle bits worth
// covering directly.
func TestBuildAllowList(t *testing.T) {
al := buildAllowList([]CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issue", Value: "sectigo.com; account=12345"},
{Flag: 0, Tag: "issuewild", Value: ";"},
})
if !al.issueAll["letsencrypt.org"] {
t.Error("expected letsencrypt.org in issueAll")
}
if !al.issueAll["sectigo.com"] {
t.Errorf("expected sectigo.com (stripped) in issueAll, got %v", al.issueAll)
}
if al.disallowIssue {
t.Error("disallowIssue should be false; only issuewild was ';'")
}
if !al.disallowWildcardIssue {
t.Error("expected disallowWildcardIssue=true")
}
if !al.hasIssueWild {
t.Error("expected hasIssueWild=true")
}
}
// TestRule_WildcardDisallowed: zone allows letsencrypt.org via "issue"
// but explicitly forbids wildcard issuance via `issuewild ";"`. A
// wildcard cert should trip caa_issuance_disallowed even though the
// CA is otherwise authorized.
func TestRule_WildcardDisallowed(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issuewild", Value: ";"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
"dns_names": []string{"*.example.com", "example.com"},
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", states[0].Status, states[0].Message)
}
if states[0].Code != CodeIssuanceDisallowed {
t.Errorf("expected %q, got %q", CodeIssuanceDisallowed, states[0].Code)
}
if !strings.Contains(states[0].Message, "issuewild") {
t.Errorf("expected message to mention issuewild, got %q", states[0].Message)
}
}
// TestRule_WildcardOverridesIssue: when "issuewild" is present, it
// fully overrides "issue" for wildcard certs (RFC 8659 §4.3). The
// wildcard probe must be checked against issuewild only, even if the
// CA is allowed by "issue".
func TestRule_WildcardOverridesIssue(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issuewild", Value: "digicert.com"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
"dns_names": []string{"*.example.com"},
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit (LE not in issuewild), got %s: %s", states[0].Status, states[0].Message)
}
if states[0].Code != CodeNotAuthorized {
t.Errorf("expected %q, got %q", CodeNotAuthorized, states[0].Code)
}
if !strings.Contains(states[0].Message, "issuewild") {
t.Errorf("expected message to mention issuewild, got %q", states[0].Message)
}
}
// TestRule_WildcardFallsBackToIssue: with no "issuewild" records, a
// wildcard cert is governed by the "issue" allow list as if it were a
// regular cert.
func TestRule_WildcardFallsBackToIssue(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
"dns_names": []string{"*.example.com"},
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", states[0].Status, states[0].Message)
}
}
// TestRule_NonWildcardIgnoresIssueWild: a non-wildcard cert must be
// checked against "issue" even when "issuewild" is present and would
// disallow issuance.
func TestRule_NonWildcardIgnoresIssueWild(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issuewild", Value: ";"},
},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
"dns_names": []string{"www.example.com"},
}),
},
}
states := Rule().Evaluate(context.Background(), obs, nil)
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if states[0].Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", states[0].Status, states[0].Message)
}
}

92
checker/tls_related.go Normal file
View file

@ -0,0 +1,92 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsProbeView is a permissive subset of checker-tls's probe payload;
// only fields the CAA rule needs are decoded so the TLS checker can
// evolve its schema independently.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
Type string `json:"type,omitempty"`
Issuer string `json:"issuer,omitempty"`
IssuerDN string `json:"issuer_dn,omitempty"`
IssuerAKI string `json:"issuer_aki,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
DNSNames []string `json:"dns_names,omitempty"`
Subject string `json:"subject,omitempty"`
}
// isWildcard reports whether the observed certificate covers at least
// one wildcard DNS name. Used to pick between the CAA "issue" and
// "issuewild" allow lists per RFC 8659 §4.3.
func (v *tlsProbeView) isWildcard() bool {
for _, n := range v.DNSNames {
if strings.HasPrefix(n, "*.") {
return true
}
}
return false
}
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.FormatUint(uint64(v.Port), 10))
}
return v.Host
}
// parseTLSRelated decodes a RelatedObservation into probes. Two
// payload shapes are accepted: the current {"probes": {ref: …}} map
// (filtered by r.Ref when set) and a bare top-level probe (back-compat).
// Returns nil when the payload is not a recognizable probe shape.
func parseTLSRelated(r sdk.RelatedObservation) []*tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if r.Ref != "" {
if p, ok := keyed.Probes[r.Ref]; ok {
cp := p
return []*tlsProbeView{&cp}
}
}
out := make([]*tlsProbeView, 0, len(keyed.Probes))
for _, p := range keyed.Probes {
cp := p
out = append(out, &cp)
}
return out
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
if v.Host == "" && v.IssuerAKI == "" && v.IssuerDN == "" {
return nil
}
return []*tlsProbeView{&v}
}
// parseAllTLSRelated flattens a slice of RelatedObservations into one
// entry per endpoint.
func parseAllTLSRelated(related []sdk.RelatedObservation) []*tlsProbeView {
var out []*tlsProbeView
for _, r := range related {
out = append(out, parseTLSRelated(r)...)
}
return out
}

50
checker/types.go Normal file
View file

@ -0,0 +1,50 @@
// Package checker implements the CAA compliance checker for happyDomain.
//
// It consumes observations published by checker-tls (the "tls_probes" key)
// and cross-references each observed certificate issuer against the CAA
// policy declared by the domain's svcs.CAAPolicy service. No network
// probes are performed here.
package checker
// ObservationKeyCAA is the observation key this checker writes. Its
// payload is a pass-through of the zone-side CAA records; the
// checker does not re-query DNS.
const ObservationKeyCAA = "caa_policy"
// TLSRelatedKey is the observation key this checker reads from other
// checkers via ObservationGetter.GetRelated. Matches the key
// published by checker-tls.
const TLSRelatedKey = "tls_probes"
// Severity values used in Issue.Severity (lowercase, ascii). Kept in
// sync with the other happyDomain checkers so aggregators can merge
// severities by string.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
)
// Rule code values surfaced by CheckState.Code.
const (
CodeOK = "caa_ok"
CodeNoTLS = "caa_no_tls"
CodeNotAuthorized = "caa_not_authorized"
CodeIssuanceDisallowed = "caa_issuance_disallowed"
CodeIssuerUnknown = "caa_issuer_unknown"
CodeObservationError = "caa_observation_error"
CodeUnknownCritical = "caa_unknown_critical"
)
// CAAData is the payload written under ObservationKeyCAA.
type CAAData struct {
Domain string `json:"domain,omitempty"`
Records []CAARecord `json:"records,omitempty"`
RunAt string `json:"run_at,omitempty"`
}
type CAARecord struct {
Flag uint8 `json:"flag"`
Tag string `json:"tag"`
Value string `json:"value"`
}

16
go.mod Normal file
View file

@ -0,0 +1,16 @@
module git.happydns.org/checker-caa
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.4.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

16
go.sum Normal file
View file

@ -0,0 +1,16 @@
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

23
main.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"flag"
"log"
caa "git.happydns.org/checker-caa/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
caa.Version = Version
srv := server.New(caa.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

20
plugin/plugin.go Normal file
View file

@ -0,0 +1,20 @@
// Command plugin is the happyDomain plugin entrypoint for the CAA checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
caa "git.happydns.org/checker-caa/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
caa.Version = Version
prvd := caa.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}