Initial commit
This commit is contained in:
commit
b9175054bc
19 changed files with 1433 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
checker-caa
|
||||||
|
checker-caa.so
|
||||||
|
AllCAAIdentifiersReport.csv
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
40
NOTICE
Normal 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
101
README.md
Normal 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
222
checker/ccadb.go
Normal 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
104
checker/ccadb_test.go
Normal 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
95
checker/collect.go
Normal 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
55
checker/definition.go
Normal 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
15
checker/provider.go
Normal 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
310
checker/rule.go
Normal 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
230
checker/rule_test.go
Normal 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
92
checker/tls_related.go
Normal 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
54
checker/types.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
23
main.go
Normal 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
19
plugin/plugin.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue