Initial commit

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

3
.gitignore vendored Normal file
View file

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

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-caa .
FROM scratch
COPY --from=builder /checker-caa /checker-caa
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 -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 ./...
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
```

222
checker/ccadb.go Normal file
View file

@ -0,0 +1,222 @@
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 into the two lookup indexes on first
// call. Subsequent calls are no-ops. The CSV is shipped with the binary
// so parse failures indicate a bug or a corrupted build, not a runtime
// condition; tests assert the parse succeeds for the checked-in file.
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)
}
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)
}
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 certificate issuer to the CAA identifier
// domains the issuing CA publishes in its CPS. aki is the uppercase hex
// Authority Key Identifier of the leaf (i.e. the issuer's SKI); dn is
// the RFC 2253 subject DN of the issuer (leaf.Issuer.String() in Go).
//
// AKI takes precedence because CCADB keys by it. DN is a fallback for
// the rare rows where the SKI column is empty.
//
// Returns ok=false when neither key resolves. 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 splits CCADB's "Recognized CAA Domains" cell, which
// can hold a comma-separated list (e.g. DigiCert rows list ~20
// domains). Whitespace is trimmed, empties are dropped, and the result
// is lowercased 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 produces a canonical key from a subject DN so that DNs
// produced by Go's pkix.Name.String (comma-joined) compare equal to
// DNs produced by CCADB (semicolon-joined) when their RDN sets match.
//
// Rules:
// - split on ',' or ';';
// - trim each RDN;
// - uppercase the RDN type (left of '=') because RFC 4514 types are
// case-insensitive; values are left as-is;
// - sort the RDNs alphabetically so reordering does not break
// comparison.
//
// This is intentionally permissive; escaping differences between
// implementations are ignored. Good enough for CCADB fallbacks, and
// the common path is the AKI lookup 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")
}
}

95
checker/collect.go Normal file
View file

@ -0,0 +1,95 @@
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 minimal local copy of happydns.ServiceMessage
// matching the JSON wire shape, so this module does not depend on the
// happyDomain core repository. Same pattern as
// checker-ns-restrictions/checker/types.go.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// caaPolicyPayload mirrors the JSON shape of svcs.CAAPolicy: a single
// "caa" field holding a list of CAA records. Each record is decoded
// into a trimmed local type with just the fields the rule reads.
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 pulls the "service" option out of the options map,
// accepting both the in-process plugin path (native Go value) and the
// HTTP path (JSON-decoded map[string]any). Normalizing via a JSON
// round-trip keeps both paths working 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
}

55
checker/definition.go Normal file
View file

@ -0,0 +1,55 @@
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 is the package-level helper expected by the plugin
// entrypoint and by sdk.NewServer via CheckerDefinitionProvider.
func Definition() *sdk.CheckerDefinition {
return (&caaProvider{}).Definition()
}
// 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,
},
}
}

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
}

310
checker/rule.go Normal file
View file

@ -0,0 +1,310 @@
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 we
// made and the set of endpoints it showed up on. A single struct type
// (rather than an anonymous struct inside Evaluate) keeps it reachable
// from the helpers below.
type issuerAgg struct {
sample *tlsProbeView
severity string
code string
msg string
endpoints map[string]bool
}
// allowList captures the policy in a form the rule can intersect against.
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 only constrains wildcard certs; the rule's
// inputs are individual probe observations without a "is-wildcard"
// flag, so it is recorded but not yet enforced. Reserved for a
// future iteration.
disallowWildcardIssue bool
}
// buildAllowList walks the CAA records and builds the effective
// allow/deny sets per RFC 8659 §4.2 "issue" and §4.3 "issuewild".
// Parameters on the issuer value (after ';') are stripped; v1 of this
// checker compares base issuer domain names only.
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":
if value == "" || value == ";" {
al.disallowWildcardIssue = true
} else {
al.issueWildAll[issuerFromValue(value)] = true
}
}
}
return al
}
// issuerFromValue extracts the base issuer domain from a CAA value,
// dropping any ';'-prefixed parameters. Comparison is lowercase.
func issuerFromValue(v string) string {
if i := strings.IndexByte(v, ';'); i >= 0 {
v = v[:i]
}
return strings.ToLower(strings.TrimSpace(v))
}
// Evaluate runs the compliance rule.
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,
}
}
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
probes := parseAllTLSRelated(related)
if len(probes) == 0 {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: "No TLS probes have been observed for this target yet",
Code: CodeNoTLS,
}
}
al := buildAllowList(data.Records)
hasPolicy := len(al.issueAll) > 0 || al.disallowIssue
// 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 {
if al.disallowIssue {
issue(p, SeverityCrit, CodeIssuanceDisallowed,
fmt.Sprintf("CAA policy forbids issuance (issue \";\") but a certificate was observed on %s", 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, al.issueAll) {
issue(p, SeverityCrit, CodeNotAuthorized,
fmt.Sprintf("Certificate on %s issued by %s (CAA identifier %s) is not authorized by the zone's CAA issue records",
p.address(), issuerLabel(p), strings.Join(domains, ", ")))
continue
}
issue(p, "", "", "")
}
// Materialize issues in a deterministic order (keys sorted) so test
// output is stable and the "first" critical/warn message is not
// map-iteration-dependent.
keys := make([]string, 0, len(agg))
for k := range agg {
keys = append(keys, k)
}
sort.Strings(keys)
var critCount, warnCount, infoCount, okIssuerCount int
var firstCrit, firstWarn, firstInfo string
for _, k := range keys {
a := agg[k]
switch a.severity {
case SeverityCrit:
critCount++
if firstCrit == "" {
firstCrit = a.msg
}
case SeverityWarn:
warnCount++
if firstWarn == "" {
firstWarn = a.msg
}
case SeverityInfo:
infoCount++
if firstInfo == "" {
firstInfo = a.msg
}
default:
okIssuerCount++
}
}
meta := map[string]any{
"probes": len(probes),
"distinct_issuers": len(agg),
"authorized": okIssuerCount,
"unauthorized": critCount,
"info": infoCount,
"caa_records": len(data.Records),
}
switch {
case critCount > 0:
code := CodeNotAuthorized
if al.disallowIssue {
code = CodeIssuanceDisallowed
}
return sdk.CheckState{
Status: sdk.StatusCrit,
Message: fmt.Sprintf("%d issuer(s) violate the zone's CAA policy: %s", critCount, firstCrit),
Code: code,
Meta: meta,
}
case warnCount > 0:
return sdk.CheckState{
Status: sdk.StatusWarn,
Message: firstWarn,
Code: CodeNotAuthorized,
Meta: meta,
}
case infoCount > 0 && okIssuerCount == 0:
// Only info-level findings. When a policy exists this is a data
// gap (CCADB didn't know the issuer); without a policy it's the
// "publish CAA" nudge, which is fine; OK code.
code := CodeIssuerUnknown
if !hasPolicy {
code = CodeOK
}
return sdk.CheckState{
Status: sdk.StatusInfo,
Message: firstInfo,
Code: code,
Meta: meta,
}
default:
msg := fmt.Sprintf("%d TLS issuer(s) authorized by CAA policy", okIssuerCount)
if !hasPolicy {
msg = fmt.Sprintf("%d TLS issuer(s) observed; no CAA records published", okIssuerCount)
}
return sdk.CheckState{
Status: sdk.StatusOK,
Message: msg,
Code: CodeOK,
Meta: meta,
}
}
}
// severityRank turns a severity string into a comparable integer so
// the rule can pick the worst per-issuer status.
func severityRank(s string) int {
switch s {
case SeverityCrit:
return 3
case SeverityWarn:
return 2
case SeverityInfo:
return 1
default:
return 0
}
}
func severityToStatus(s string) sdk.Status {
switch s {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
// intersects reports whether any element of lhs is present in the set.
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 best human-readable name for an issuer from a
// probe: the Issuer CN if the TLS checker populated it, otherwise the
// full DN.
func issuerLabel(p *tlsProbeView) string {
if p.Issuer != "" {
return p.Issuer
}
if p.IssuerDN != "" {
return p.IssuerDN
}
return "unknown issuer"
}

230
checker/rule_test.go Normal file
View file

@ -0,0 +1,230 @@
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",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
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",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
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",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
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",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
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,
}
state := Rule().Evaluate(context.Background(), obs, nil)
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",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
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)
}
}
// 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")
}
}

92
checker/tls_related.go Normal file
View file

@ -0,0 +1,92 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsProbeView is the local, permissive view of a single checker-tls
// probe payload. We decode only what the CAA rule needs; unknown fields
// are ignored so the TLS checker can evolve its schema independently.
//
// The IssuerAKI / IssuerDN fields are the cross-checker contract the
// CAA rule depends on. They were added to checker-tls so each probe
// carries the issuer identity in a form that maps directly to the
// CCADB "CAA Identifiers" CSV.
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"`
}
// address returns "host:port" as a human-readable identifier for
// Issue.Endpoint when the upstream Endpoint field is missing.
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 single RelatedObservation into a list of
// probes. Two payload shapes are supported to match the dual-shape
// contract checker-xmpp already consumes:
//
// 1. {"probes": {"<ref>": <probe>, …}}: the current checker-tls
// format. When r.Ref is set and present in the map, only that
// entry is returned; otherwise all probes are returned so a rule
// operating at domain scope can still see them.
// 2. <probe>: a single top-level probe, kept for 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 the
// full set of probe views they carry. This is the input the rule works
// from; one entry per endpoint, not per observation.
func parseAllTLSRelated(related []sdk.RelatedObservation) []*tlsProbeView {
var out []*tlsProbeView
for _, r := range related {
out = append(out, parseTLSRelated(r)...)
}
return out
}

54
checker/types.go Normal file
View file

@ -0,0 +1,54 @@
// 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"
)
// CAAData is the payload written under ObservationKeyCAA. It is a thin
// view over the zone's CAA records; consumers (including our own rule)
// can walk Records to rebuild the allow list without re-parsing the
// service body.
type CAAData struct {
Domain string `json:"domain,omitempty"`
Records []CAARecord `json:"records,omitempty"`
RunAt string `json:"run_at,omitempty"`
}
// CAARecord is one entry of the zone's CAA policy, flattened to the
// three fields that matter for compliance evaluation.
type CAARecord struct {
Flag uint8 `json:"flag"`
Tag string `json:"tag"`
Value string `json:"value"`
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-caa
go 1.25.0
require git.happydns.org/checker-sdk-go v1.1.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

23
main.go Normal file
View file

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

19
plugin/plugin.go Normal file
View file

@ -0,0 +1,19 @@
// 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
return caa.Definition(), caa.Provider(), nil
}