Initial commit

This commit is contained in:
nemunaire 2026-04-27 01:31:20 +07:00
commit 542ebdea34
40 changed files with 4592 additions and 0 deletions

400
checker/collect.go Normal file
View file

@ -0,0 +1,400 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync/atomic"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"golang.org/x/net/html"
)
// verboseLogging is enabled via the CHECKER_HTTP_VERBOSE environment variable;
// when off, per-probe logging is silenced to keep production logs clean.
var verboseLogging = os.Getenv("CHECKER_HTTP_VERBOSE") != ""
// Collect resolves the Target from CheckerOptions, runs the root
// collector synchronously (its output is the canonical HTTPData), then
// runs every registered Collector in parallel and merges their JSON
// payloads into HTTPData.Extensions under their Key().
func (p *httpProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
target, err := buildTarget(ctx, opts)
if err != nil {
return nil, err
}
rootOut, err := rootCollector{}.Collect(ctx, target)
if err != nil {
return nil, err
}
data, ok := rootOut.(*HTTPData)
if !ok {
return nil, fmt.Errorf("rootCollector returned %T, expected *HTTPData", rootOut)
}
registry.mu.Lock()
collectors := append([]Collector(nil), registry.collectors...)
registry.mu.Unlock()
if len(collectors) == 0 {
return data, nil
}
type result struct {
key string
raw json.RawMessage
err error
}
// Each collector may issue several probes (one per scheme × IP), so we
// budget it as runProbe does (timeout × (maxRedirects+1)) multiplied by
// a small factor for the fan-out. The deadline is shared so a single
// hung collector cannot keep the caller waiting longer than the
// slowest legitimate collector.
collectorBudget := target.Timeout * time.Duration(target.MaxRedirects+1) * 4
cctx, cancel := context.WithTimeout(ctx, collectorBudget)
defer cancel()
results := make(chan result, len(collectors))
for _, c := range collectors {
go func(c Collector) {
out, err := c.Collect(cctx, target)
if err != nil {
results <- result{key: c.Key(), err: err}
return
}
raw, mErr := json.Marshal(out)
results <- result{key: c.Key(), raw: raw, err: mErr}
}(c)
}
exts := make(map[string]json.RawMessage, len(collectors))
pending := len(collectors)
for pending > 0 {
select {
case r := <-results:
pending--
if r.err != nil {
if verboseLogging {
log.Printf("checker-http: collector %q failed: %v", r.key, r.err)
}
continue
}
exts[r.key] = r.raw
case <-cctx.Done():
if verboseLogging {
log.Printf("checker-http: %d collector(s) did not return before deadline (%v); abandoning", pending, cctx.Err())
}
pending = 0
}
}
if len(exts) > 0 {
data.Extensions = exts
}
return data, nil
}
// LoadExtension decodes a sub-observation written by a Collector into the
// caller-supplied typed value. Returns false (without error) when the
// extension is absent — most rules treat that as "no_data" rather than
// an error.
func LoadExtension[T any](data *HTTPData, key string) (*T, bool, error) {
raw, ok := data.Extensions[key]
if !ok || len(raw) == 0 {
return nil, false, nil
}
var v T
if err := json.Unmarshal(raw, &v); err != nil {
return nil, true, fmt.Errorf("decode extension %q: %w", key, err)
}
return &v, true, nil
}
// buildTarget centralises option parsing and IP discovery so every
// Collector receives a fully resolved Target.
func buildTarget(ctx context.Context, opts sdk.CheckerOptions) (Target, error) {
server, err := resolveServer(opts)
if err != nil {
return Target{}, err
}
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
if timeoutMs <= 0 {
timeoutMs = DefaultProbeTimeoutMs
}
maxRedirects := sdk.GetIntOption(opts, OptionMaxRedirects, DefaultMaxRedirects)
if maxRedirects < 0 {
maxRedirects = DefaultMaxRedirects
}
userAgent := DefaultUserAgent
if v, ok := sdk.GetOption[string](opts, OptionUserAgent); ok && v != "" {
userAgent = v
}
host, ips := addressesFromServer(server)
// abstract.Server only pins one A and one AAAA. Resolve the host to
// pick up any additional records the authoritative DNS exposes, so
// multi-IP deployments aren't silently under-probed. Failures are
// non-fatal; the pinned IPs remain.
seen := make(map[string]struct{}, len(ips)+4)
for _, ip := range ips {
seen[ip] = struct{}{}
}
ips = append(ips, discoverIPs(ctx, host, seen)...)
if len(ips) == 0 {
return Target{}, fmt.Errorf("abstract.Server has no A/AAAA records")
}
return Target{
Host: host,
IPs: ips,
Timeout: time.Duration(timeoutMs) * time.Millisecond,
MaxRedirects: maxRedirects,
UserAgent: userAgent,
}, nil
}
func runProbe(ctx context.Context, host, ip, scheme string, port uint16, timeout time.Duration, maxRedirects int, ua string, parseHTML bool) HTTPProbe {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
probe := HTTPProbe{
Scheme: scheme,
Host: host,
IP: ip,
Port: port,
Address: addr,
IsIPv6: strings.Contains(ip, ":"),
}
dialer := &net.Dialer{Timeout: timeout}
// tcpConnected is set the moment a dial succeeds, so we can
// distinguish pure-TCP failures from later TLS/HTTP errors without
// resorting to error-string matching.
var tcpConnected atomic.Bool
// Force every dial to the chosen IP, regardless of what hostname is
// in the URL; that way we can attribute results to a specific A/AAAA
// record and bypass local resolver oddities.
transport := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
tcpConnected.Store(true)
return conn, nil
},
TLSClientConfig: &tls.Config{
ServerName: host,
// Deep TLS posture is delegated to checker-tls. We still want
// HTTPS errors (expired cert, bad chain, ...) to surface as
// probe errors, so verification stays enabled.
},
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
DisableKeepAlives: true,
}
defer transport.CloseIdleConnections()
// Bound the whole probe (dial + TLS + headers + body across all
// redirect hops) by a single per-probe deadline derived from ctx, so
// a slow target can't pin a worker beyond the parent's lifetime and
// outer cancellation propagates to in-flight I/O.
probeBudget := timeout * time.Duration(maxRedirects+1)
probeCtx, cancel := context.WithTimeout(ctx, probeBudget)
defer cancel()
var redirectChain []RedirectStep
client := &http.Client{
Transport: transport,
// No client-level Timeout: probeCtx already bounds the request,
// and a separate http.Client.Timeout would race with it.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
prev := via[len(via)-1]
redirectChain = append(redirectChain, RedirectStep{
From: prev.URL.String(),
To: req.URL.String(),
Status: 0, // populated post-hoc below if available
})
// The transport's DialContext is pinned to the original
// (ip, port) and TLS ServerName is pinned to the original
// host. Following a redirect that changes host, scheme, or
// port would silently route the request to the wrong
// backend. Stop and return the 3xx so the caller can see
// the Location, but don't follow it on this probe.
if !strings.EqualFold(req.URL.Host, host) ||
!strings.EqualFold(req.URL.Scheme, scheme) {
return http.ErrUseLastResponse
}
if len(via) > maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
return nil
},
}
target := &url.URL{Scheme: scheme, Host: host, Path: "/"}
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, target.String(), nil)
if err != nil {
probe.Error = err.Error()
return probe
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
start := time.Now()
resp, err := client.Do(req)
probe.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
probe.Error = err.Error()
// The dialer wrapper sets tcpConnected the moment a TCP
// connection is established, so we can attribute the failure
// to a post-TCP layer (TLS, HTTP, redirect policy) without
// any error-string heuristics.
probe.TCPConnected = tcpConnected.Load()
probe.RedirectChain = redirectChain
return probe
}
defer resp.Body.Close()
probe.TCPConnected = true
probe.StatusCode = resp.StatusCode
if resp.Request != nil && resp.Request.URL != nil {
probe.FinalURL = resp.Request.URL.String()
}
// Per RFC 7230 §3.2.2, repeated headers (other than Set-Cookie) are
// semantically equivalent to a single header whose value is the
// comma-joined list; folding here preserves directives like a second
// CSP or HSTS header that would otherwise be dropped. Set-Cookie is
// excluded from the map since cookies are surfaced via resp.Cookies().
probe.Headers = make(map[string]string, len(resp.Header))
for k, v := range resp.Header {
if len(v) == 0 {
continue
}
lk := strings.ToLower(k)
if lk == "set-cookie" {
continue
}
probe.Headers[lk] = strings.Join(v, ", ")
}
for _, c := range resp.Cookies() {
probe.Cookies = append(probe.Cookies, CookieInfo{
Name: c.Name,
Domain: c.Domain,
Path: c.Path,
Secure: c.Secure,
HttpOnly: c.HttpOnly,
SameSite: sameSiteString(c.SameSite),
HasExpiry: !c.Expires.IsZero() || c.MaxAge > 0,
})
}
probe.RedirectChain = redirectChain
// Read one extra byte to detect whether we hit the cap. Anything
// beyond MaxBodyBytes is dropped, but the probe surfaces
// BodyTruncated so callers know SRI/HTML rules saw a partial view.
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes+1))
if err == nil {
if len(body) > MaxBodyBytes {
body = body[:MaxBodyBytes]
probe.BodyTruncated = true
}
probe.HTMLBytes = len(body)
if parseHTML && isHTMLContent(probe.Headers["content-type"]) {
probe.Resources = extractResources(body, host)
}
}
return probe
}
func sameSiteString(s http.SameSite) string {
switch s {
case http.SameSiteLaxMode:
return "Lax"
case http.SameSiteStrictMode:
return "Strict"
case http.SameSiteNoneMode:
return "None"
default:
return ""
}
}
func isHTMLContent(ct string) bool {
ct = strings.ToLower(ct)
return strings.Contains(ct, "text/html") || strings.Contains(ct, "application/xhtml")
}
// extractResources walks the HTML body and collects <script src=...>,
// <link href=... rel="stylesheet"|"preload"...> and inline-eligible <img>
// references, with a flag for whether the resource is cross-origin
// (different host than the page); SRI is only meaningful in that case.
func extractResources(body []byte, pageHost string) []HTMLResource {
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return nil
}
var out []HTMLResource
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.Data {
case "script":
if src, ok := attr(n, "src"); ok && src != "" {
out = append(out, mkResource("script", src, n, pageHost))
}
case "link":
rel, _ := attr(n, "rel")
if href, ok := attr(n, "href"); ok && href != "" && relIsAsset(rel) {
r := mkResource("link", href, n, pageHost)
r.Rel = rel
out = append(out, r)
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return out
}
func relIsAsset(rel string) bool {
rel = strings.ToLower(rel)
return strings.Contains(rel, "stylesheet") || strings.Contains(rel, "preload") || strings.Contains(rel, "modulepreload")
}
func mkResource(tag, ref string, n *html.Node, pageHost string) HTMLResource {
r := HTMLResource{Tag: tag, URL: ref}
if integ, ok := attr(n, "integrity"); ok && integ != "" {
r.Integrity = integ
}
if u, err := url.Parse(ref); err == nil && u.Host != "" && !strings.EqualFold(u.Host, pageHost) {
r.CrossOrigin = true
}
return r
}
func attr(n *html.Node, key string) (string, bool) {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val, true
}
}
return "", false
}

305
checker/collect_test.go Normal file
View file

@ -0,0 +1,305 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/net/html"
)
// splitHostPort parses an httptest server URL into ("ip", port).
func splitHostPort(t *testing.T, raw string) (string, uint16) {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("split host port %q: %v", u.Host, err)
}
p, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
t.Fatalf("port %q: %v", portStr, err)
}
return host, uint16(p)
}
func TestRunProbe_HTTPSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Type", "text/plain")
http.SetCookie(w, &http.Cookie{Name: "sid", Value: "v", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
_, _ = io.WriteString(w, "hello")
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip /* host=ip so default Host header matches */, ip, "http", port, 2*time.Second, 0, "test-ua", false)
if probe.Error != "" {
t.Fatalf("unexpected error: %q", probe.Error)
}
if !probe.TCPConnected || probe.StatusCode != 200 {
t.Fatalf("unexpected probe result: %+v", probe)
}
if probe.Headers["x-frame-options"] != "DENY" {
t.Errorf("missing x-frame-options header: %+v", probe.Headers)
}
if len(probe.Cookies) != 1 || probe.Cookies[0].Name != "sid" || !probe.Cookies[0].Secure || !probe.Cookies[0].HttpOnly || probe.Cookies[0].SameSite != "Lax" {
t.Errorf("unexpected cookies: %+v", probe.Cookies)
}
if probe.IsIPv6 {
t.Errorf("IPv4 address mis-detected as IPv6")
}
if probe.Address != net.JoinHostPort(ip, fmt.Sprintf("%d", port)) {
t.Errorf("address: %q", probe.Address)
}
}
func TestRunProbe_TCPConnectionRefused(t *testing.T) {
// Pick a port we know nothing listens on by binding then immediately closing.
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
_, portStr, _ := net.SplitHostPort(l.Addr().String())
p, _ := strconv.ParseUint(portStr, 10, 16)
_ = l.Close()
probe := runProbe(context.Background(), "127.0.0.1", "127.0.0.1", "http", uint16(p), 500*time.Millisecond, 0, "ua", false)
if probe.Error == "" {
t.Fatal("expected error from probing closed port")
}
if probe.TCPConnected {
t.Errorf("TCPConnected should be false on dial failure: %+v", probe)
}
if probe.StatusCode != 0 {
t.Errorf("StatusCode should be 0, got %d", probe.StatusCode)
}
}
func TestRunProbe_BodyTruncation(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
// Write more than MaxBodyBytes.
buf := strings.Repeat("a", MaxBodyBytes+4096)
_, _ = io.WriteString(w, buf)
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
if !probe.BodyTruncated {
t.Errorf("expected BodyTruncated=true, got probe=%+v", probe)
}
if probe.HTMLBytes != MaxBodyBytes {
t.Errorf("HTMLBytes = %d, want %d", probe.HTMLBytes, MaxBodyBytes)
}
}
func TestRunProbe_RedirectFollowedSameHost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/dst" {
w.WriteHeader(204)
return
}
http.Redirect(w, r, "/dst", http.StatusFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 5, "ua", false)
if probe.StatusCode != 204 {
t.Errorf("status: got %d, want 204; chain=%+v err=%q", probe.StatusCode, probe.RedirectChain, probe.Error)
}
if len(probe.RedirectChain) != 1 {
t.Errorf("redirect chain length: got %d, want 1: %+v", len(probe.RedirectChain), probe.RedirectChain)
}
}
func TestRunProbe_RedirectStoppedCrossHost(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Redirect to a different host: the probe must NOT follow.
http.Redirect(w, r, "https://elsewhere.invalid/", http.StatusMovedPermanently)
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 5, "ua", false)
if probe.StatusCode != http.StatusMovedPermanently {
t.Errorf("status: got %d, want 301", probe.StatusCode)
}
if len(probe.RedirectChain) != 1 {
t.Errorf("expected 1 recorded hop, got %d: %+v", len(probe.RedirectChain), probe.RedirectChain)
}
}
func TestRunProbe_HTMLResourceExtraction(t *testing.T) {
html := `<!doctype html><html><head>
<script src="https://cdn.example/lib.js" integrity="sha384-abc"></script>
<script src="/local.js"></script>
<link rel="stylesheet" href="https://cdn.example/style.css">
<link rel="icon" href="/favicon.ico">
</head><body></body></html>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, html)
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
if probe.Error != "" {
t.Fatalf("error: %q", probe.Error)
}
if len(probe.Resources) != 3 {
// Expect: cdn script, local script, cdn stylesheet. The icon link is ignored (rel=icon).
t.Fatalf("got %d resources, want 3: %+v", len(probe.Resources), probe.Resources)
}
var cdnScript, localScript, stylesheet bool
for _, r := range probe.Resources {
switch r.URL {
case "https://cdn.example/lib.js":
cdnScript = true
if !r.CrossOrigin || r.Integrity != "sha384-abc" {
t.Errorf("cdn script: %+v", r)
}
case "/local.js":
localScript = true
if r.CrossOrigin {
t.Errorf("local script flagged as cross-origin")
}
case "https://cdn.example/style.css":
stylesheet = true
if !r.CrossOrigin || r.Integrity != "" {
t.Errorf("stylesheet: %+v", r)
}
}
}
if !cdnScript || !localScript || !stylesheet {
t.Errorf("missing expected resources: cdnScript=%v local=%v stylesheet=%v", cdnScript, localScript, stylesheet)
}
}
func TestRunProbe_NonHTMLContentTypeSkipsExtraction(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `<script src="/x.js"></script>`)
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
if len(probe.Resources) != 0 {
t.Errorf("non-HTML content-type should skip parsing, got %+v", probe.Resources)
}
}
func TestRunProbe_ContextCancelled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
ip, port := splitHostPort(t, srv.URL)
ctx, cancel := context.WithCancel(context.Background())
cancel()
probe := runProbe(ctx, ip, ip, "http", port, 5*time.Second, 0, "ua", false)
if probe.Error == "" {
t.Errorf("expected error when context is already cancelled, got probe=%+v", probe)
}
}
func TestIsHTMLContent(t *testing.T) {
yes := []string{"text/html", "TEXT/HTML; charset=utf-8", "application/xhtml+xml"}
no := []string{"", "application/json", "text/plain", "image/png"}
for _, ct := range yes {
if !isHTMLContent(ct) {
t.Errorf("isHTMLContent(%q) = false, want true", ct)
}
}
for _, ct := range no {
if isHTMLContent(ct) {
t.Errorf("isHTMLContent(%q) = true, want false", ct)
}
}
}
func TestRelIsAsset(t *testing.T) {
yes := []string{"stylesheet", "preload", "modulepreload", "STYLESHEET", "preload stylesheet"}
no := []string{"", "icon", "alternate", "canonical"}
for _, r := range yes {
if !relIsAsset(r) {
t.Errorf("relIsAsset(%q) = false, want true", r)
}
}
for _, r := range no {
if relIsAsset(r) {
t.Errorf("relIsAsset(%q) = true, want false", r)
}
}
}
func TestExtractResources_EmptyAndMalformed(t *testing.T) {
// The HTML parser is forgiving: even garbage produces no resources rather than panicking.
if got := extractResources([]byte(""), "h"); got != nil {
t.Errorf("empty body: got %+v, want nil", got)
}
if got := extractResources([]byte("<<<not really html>>>"), "h"); got != nil {
t.Errorf("garbage: got %+v, want nil", got)
}
}
func TestExtractResources_SkipsScriptWithoutSrc(t *testing.T) {
body := `<html><body><script>alert(1)</script><script src=""></script></body></html>`
if got := extractResources([]byte(body), "h"); len(got) != 0 {
t.Errorf("inline/empty-src scripts should not produce resources: %+v", got)
}
}
func TestAttrCaseInsensitive(t *testing.T) {
doc, err := html.Parse(strings.NewReader(`<a HREF="x" Integrity="i"></a>`))
if err != nil {
t.Fatal(err)
}
var found *html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "a" {
found = n
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
if found == nil {
t.Fatal("anchor not found")
}
if v, ok := attr(found, "href"); !ok || v != "x" {
t.Errorf("href: got (%q,%v)", v, ok)
}
if v, ok := attr(found, "INTEGRITY"); !ok || v != "i" {
t.Errorf("INTEGRITY: got (%q,%v)", v, ok)
}
if _, ok := attr(found, "missing"); ok {
t.Errorf("missing attr should return ok=false")
}
}

36
checker/collector.go Normal file
View file

@ -0,0 +1,36 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"time"
)
// Target captures everything a Collector needs to probe one logical host.
// It is built once by the orchestrator from CheckerOptions and passed to
// every Collector, so individual collectors don't have to re-parse options
// or re-resolve IPs.
type Target struct {
Host string
IPs []string
Timeout time.Duration
MaxRedirects int
UserAgent string
}
// Collector contributes a typed observation about a Target. Each collector
// owns one slice of the work (root probe, well-known endpoints, CORS
// preflight, etc.) and writes its result under Key() in the final
// payload's Extensions map.
//
// The current orchestrator wires only the root collector and writes its
// result directly under ObservationKeyHTTP for backward compatibility.
// Additional collectors are introduced in step 4; they will populate
// HTTPData.Extensions[Key()] without disturbing existing rules.
type Collector interface {
Key() string
Collect(ctx context.Context, t Target) (any, error)
}

73
checker/collector_root.go Normal file
View file

@ -0,0 +1,73 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"log"
"sync"
"time"
)
// rootCollector probes the target host on HTTP/80 and HTTPS/443 for every
// known IP, captures headers/cookies/redirects on each, and parses the
// HTML body of the first successful HTTPS probe (so SRI-style rules have
// something to evaluate). This is the original behaviour of Collect()
// before the Collector interface was introduced.
type rootCollector struct{}
func (rootCollector) Key() string { return ObservationKeyHTTP }
func (rootCollector) Collect(ctx context.Context, t Target) (any, error) {
data := &HTTPData{
Domain: t.Host,
CollectedAt: time.Now(),
}
type job struct {
scheme string
port uint16
ip string
// parseHTML controls whether the HTML body is parsed and its
// references kept on the probe. Only the first HTTPS probe gets
// it, to keep payload size bounded.
parseHTML bool
}
var jobs []job
htmlPicked := false
for _, ip := range t.IPs {
jobs = append(jobs, job{scheme: "http", port: DefaultHTTPPort, ip: ip})
j := job{scheme: "https", port: DefaultHTTPSPort, ip: ip}
if !htmlPicked {
j.parseHTML = true
htmlPicked = true
}
jobs = append(jobs, j)
}
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProbes)
for _, j := range jobs {
wg.Add(1)
sem <- struct{}{}
go func(j job) {
defer wg.Done()
defer func() { <-sem }()
probe := runProbe(ctx, t.Host, j.ip, j.scheme, j.port, t.Timeout, t.MaxRedirects, t.UserAgent, j.parseHTML)
if verboseLogging {
log.Printf("checker-http: %s ip=%s status=%d redirects=%d err=%q",
j.scheme, j.ip, probe.StatusCode, len(probe.RedirectChain), probe.Error)
}
mu.Lock()
data.Probes = append(data.Probes, probe)
mu.Unlock()
}(j)
}
wg.Wait()
return data, nil
}

View file

@ -0,0 +1,99 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
)
// ObservationKeyWellKnown is the Extensions[] key under which
// wellknownCollector publishes its observation.
const ObservationKeyWellKnown = "wellknown"
// WellKnownData captures whether each well-known URI returned a usable
// document. It is intentionally narrow: per-URI presence and HTTP status
// are enough for the current rule set; deeper parsing (e.g. PGP-signed
// security.txt fields) is left to dedicated collectors when the need
// arises.
type WellKnownData struct {
URIs map[string]WellKnownProbe `json:"uris"`
}
// WellKnownProbe is a single (URI → outcome) entry.
type WellKnownProbe struct {
URL string `json:"url"`
StatusCode int `json:"status_code,omitempty"`
Bytes int `json:"bytes,omitempty"`
Error string `json:"error,omitempty"`
}
// wellknownCollector probes a small, fixed set of standardised URIs
// served at the apex of the host. Today it covers:
//
// - /.well-known/security.txt (RFC 9116) — security disclosure contact
// - /robots.txt (RFC 9309) — crawler directives
//
// It uses the first IP only because these documents are expected to be
// host-uniform: there is nothing to learn from probing every backend.
type wellknownCollector struct{}
func (wellknownCollector) Key() string { return ObservationKeyWellKnown }
func (wellknownCollector) Collect(ctx context.Context, t Target) (any, error) {
if len(t.IPs) == 0 {
return nil, fmt.Errorf("no IPs to probe")
}
addr := net.JoinHostPort(t.IPs[0], "443")
dialer := &net.Dialer{Timeout: t.Timeout}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, network, addr)
},
TLSClientConfig: &tls.Config{ServerName: t.Host},
TLSHandshakeTimeout: t.Timeout,
ResponseHeaderTimeout: t.Timeout,
DisableKeepAlives: true,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport}
uris := []string{"/.well-known/security.txt", "/robots.txt"}
out := WellKnownData{URIs: make(map[string]WellKnownProbe, len(uris))}
for _, path := range uris {
out.URIs[path] = fetchOne(ctx, client, t.Host, path, t.UserAgent)
}
return &out, nil
}
func fetchOne(ctx context.Context, client *http.Client, host, path, ua string) WellKnownProbe {
u := (&url.URL{Scheme: "https", Host: host, Path: path}).String()
probe := WellKnownProbe{URL: u}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
probe.Error = err.Error()
return probe
}
req.Header.Set("User-Agent", ua)
resp, err := client.Do(req)
if err != nil {
probe.Error = err.Error()
return probe
}
defer resp.Body.Close()
probe.StatusCode = resp.StatusCode
// Cap the read so a misconfigured server can't pull megabytes for a
// "did this exist?" probe.
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
probe.Bytes = len(body)
return probe
}
func init() { RegisterCollector(wellknownCollector{}) }

94
checker/definition.go Normal file
View file

@ -0,0 +1,94 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time via -ldflags.
var Version = "built-in"
func (p *httpProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "http",
Name: "HTTP / HTTPS",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Server"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyHTTP},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Per-request timeout (ms)",
Description: "Maximum time allowed for a single HTTP/HTTPS request.",
Default: float64(DefaultProbeTimeoutMs),
},
{
Id: OptionMaxRedirects,
Type: "number",
Label: "Max redirects to follow",
Description: "Stop following redirects after this many hops.",
Default: float64(DefaultMaxRedirects),
},
{
Id: OptionUserAgent,
Type: "string",
Label: "User-Agent",
Description: "User-Agent header sent with every request.",
Default: DefaultUserAgent,
},
{
Id: OptionRequireHTTPS,
Type: "bool",
Label: "Require HTTPS",
Description: "Plain HTTP must redirect to HTTPS.",
Default: true,
},
{
Id: OptionRequireHSTS,
Type: "bool",
Label: "Require HSTS",
Description: "HTTPS responses must include a Strict-Transport-Security header.",
Default: true,
},
{
Id: OptionMinHSTSMaxAgeDays,
Type: "number",
Label: "Min HSTS max-age (days)",
Description: "Minimum acceptable max-age value (in days) for HSTS.",
Default: float64(DefaultMinHSTSMaxAge),
},
{
Id: OptionRequireCSP,
Type: "bool",
Label: "Require Content-Security-Policy",
Description: "HTTPS responses must include a Content-Security-Policy header.",
Default: false,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionService,
Label: "Service",
AutoFill: sdk.AutoFillService,
Hide: true,
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 15 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
}

111
checker/header_rule.go Normal file
View file

@ -0,0 +1,111 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// HeaderRuleSpec declares a "presence + value validation" rule for one
// HTTP response header. It covers the most common shape of security
// header rule (one of Referrer-Policy, Permissions-Policy, COOP, COEP,
// CORP, X-Content-Type-Options, …) without forcing the author to write
// the load/iterate/build-state scaffolding.
//
// The DSL emits three CheckState codes derived from Code:
// - Code+".missing" when the header is absent
// - Code+".invalid" when Validate returns a non-OK status
// - Code+".ok" when Validate accepts the value
//
// Rules with richer semantics (HSTS quality thresholds, CSP directive
// inspection, cookie flag aggregation, legacy headers with reversed
// "absent is fine" semantics) keep implementing sdk.CheckRule directly.
type HeaderRuleSpec struct {
// Code is the rule's Name() and the prefix for every CheckState
// code it emits.
Code string
// Description is returned by Description().
Description string
// Header is the response header to inspect. Lookups go through the
// lowercased map populated by the collector, so casing is flexible.
Header string
// Required toggles the severity of an absent header: Warn when true,
// Info when false.
Required bool
// Validate, when set, inspects the trimmed header value. Return
// (StatusOK, msg) to accept the value (emits ".ok" with msg) or any
// other status to flag it (emits ".invalid" with msg). When nil,
// presence alone is treated as OK with a generic message.
Validate func(value string) (sdk.Status, string)
// FixHint, when set, is attached as Meta.fix on the ".missing"
// state.
FixHint string
}
// HeaderRule constructs a self-contained sdk.CheckRule from a spec.
// Intended to be wired in init() via RegisterRule.
func HeaderRule(spec HeaderRuleSpec) sdk.CheckRule {
return &headerRule{spec: spec}
}
type headerRule struct{ spec HeaderRuleSpec }
func (r *headerRule) Name() string { return r.spec.Code }
func (r *headerRule) Description() string { return r.spec.Description }
func (r *headerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
headerKey := strings.ToLower(r.spec.Header)
return EvalPerHTTPS(data, r.spec.Code, func(p HTTPProbe) sdk.CheckState {
v := strings.TrimSpace(p.Headers[headerKey])
if v == "" {
status := sdk.StatusWarn
if !r.spec.Required {
status = sdk.StatusInfo
}
st := sdk.CheckState{
Status: status,
Code: r.spec.Code + ".missing",
Subject: p.Address,
Message: r.spec.Header + " is not set.",
}
if r.spec.FixHint != "" {
st.Meta = map[string]any{"fix": r.spec.FixHint}
}
return st
}
if r.spec.Validate == nil {
return sdk.CheckState{
Status: sdk.StatusOK,
Code: r.spec.Code + ".ok",
Subject: p.Address,
Message: r.spec.Header + " is set.",
}
}
status, msg := r.spec.Validate(v)
suffix := ".invalid"
if status == sdk.StatusOK {
suffix = ".ok"
}
return sdk.CheckState{
Status: status,
Code: r.spec.Code + suffix,
Subject: p.Address,
Message: msg,
}
})
}

130
checker/headers.go Normal file
View file

@ -0,0 +1,130 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"strconv"
"strings"
)
// HSTSDirectives is the parsed form of a Strict-Transport-Security header
// (RFC 6797 §6.1).
type HSTSDirectives struct {
MaxAge int64
IncludeSub bool
Preload bool
}
// ParseHSTS pulls max-age, includeSubDomains and preload out of an HSTS
// value. Returns nil for an empty value so callers can distinguish "header
// absent" from "header present with max-age=0".
func ParseHSTS(v string) *HSTSDirectives {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
h := &HSTSDirectives{}
for _, part := range strings.Split(v, ";") {
part = strings.TrimSpace(part)
switch {
case strings.HasPrefix(strings.ToLower(part), "max-age="):
val := strings.Trim(part[len("max-age="):], "\"")
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
h.MaxAge = n
}
case strings.EqualFold(part, "includeSubDomains"):
h.IncludeSub = true
case strings.EqualFold(part, "preload"):
h.Preload = true
}
}
return h
}
// CSPDirectives is the parsed form of a Content-Security-Policy header
// (W3C CSP3). Directive names are lowercased; source tokens keep their
// original casing because keywords like 'unsafe-inline' must round-trip
// verbatim when reported back to the user.
type CSPDirectives struct {
Raw string
Directives map[string][]string
}
// ParseCSP splits a CSP header into its directive → sources map.
func ParseCSP(v string) *CSPDirectives {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
c := &CSPDirectives{Raw: v, Directives: map[string][]string{}}
for _, d := range strings.Split(v, ";") {
d = strings.TrimSpace(d)
if d == "" {
continue
}
fields := strings.Fields(d)
name := strings.ToLower(fields[0])
c.Directives[name] = fields[1:]
}
return c
}
// HasDirective reports whether the named directive is declared at all.
func (c *CSPDirectives) HasDirective(name string) bool {
if c == nil {
return false
}
_, ok := c.Directives[strings.ToLower(name)]
return ok
}
// HasSource reports whether the named directive lists the given source
// token (case-insensitive comparison; pass keywords with their quotes,
// e.g. "'unsafe-inline'").
func (c *CSPDirectives) HasSource(directive, source string) bool {
if c == nil {
return false
}
for _, s := range c.Directives[strings.ToLower(directive)] {
if strings.EqualFold(s, source) {
return true
}
}
return false
}
// HasUnsafe reports whether any directive uses 'unsafe-inline' or
// 'unsafe-eval' — the two keywords that nullify most of CSP's value.
func (c *CSPDirectives) HasUnsafe() bool {
if c == nil {
return false
}
for _, sources := range c.Directives {
for _, s := range sources {
ls := strings.ToLower(s)
if ls == "'unsafe-inline'" || ls == "'unsafe-eval'" {
return true
}
}
}
return false
}
// ParsedHeaders bundles the structured headers we parse repeatedly. Fields
// are nil when the underlying header is absent on the probe; rules can
// nil-check or rely on the typed accessors which already handle nil.
type ParsedHeaders struct {
HSTS *HSTSDirectives
CSP *CSPDirectives
}
// ParseHeaders builds a ParsedHeaders from a probe's raw header map.
// Header lookups use the lowercase keys produced by the collector.
func ParseHeaders(p HTTPProbe) ParsedHeaders {
return ParsedHeaders{
HSTS: ParseHSTS(p.Headers["strict-transport-security"]),
CSP: ParseCSP(p.Headers["content-security-policy"]),
}
}

254
checker/interactive.go Normal file
View file

@ -0,0 +1,254 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//go:build standalone
package checker
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
// RenderForm implements server.Interactive: the human-facing form
// exposed at GET /check when the checker runs as a standalone binary.
func (p *httpProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "Host name",
Placeholder: "www.example.com",
Required: true,
Description: "The HTTP/HTTPS server hostname to probe. A/AAAA records are looked up live.",
},
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Per-request timeout (ms)",
Description: "Maximum time allowed for a single HTTP/HTTPS request.",
Default: float64(DefaultProbeTimeoutMs),
},
{
Id: OptionMaxRedirects,
Type: "number",
Label: "Max redirects to follow",
Description: "Stop following redirects after this many hops.",
Default: float64(DefaultMaxRedirects),
},
{
Id: OptionUserAgent,
Type: "string",
Label: "User-Agent",
Description: "User-Agent header sent with every request.",
Default: DefaultUserAgent,
},
{
Id: OptionRequireHTTPS,
Type: "bool",
Label: "Require HTTPS",
Description: "Plain HTTP must redirect to HTTPS.",
Default: true,
},
{
Id: OptionRequireHSTS,
Type: "bool",
Label: "Require HSTS",
Description: "HTTPS responses must include a Strict-Transport-Security header.",
Default: true,
},
{
Id: OptionMinHSTSMaxAgeDays,
Type: "number",
Label: "Min HSTS max-age (days)",
Description: "Minimum acceptable max-age value (in days) for HSTS.",
Default: float64(DefaultMinHSTSMaxAge),
},
{
Id: OptionRequireCSP,
Type: "bool",
Label: "Require Content-Security-Policy",
Description: "HTTPS responses must include a Content-Security-Policy header.",
Default: false,
},
}
}
// ParseForm implements server.Interactive: resolves the submitted
// hostname into an abstract.Server payload and wraps it in the
// ServiceMessage shape that Collect expects.
func (p *httpProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return nil, errors.New("host name is required")
}
fqdn := dns.Fqdn(domain)
resolver, err := systemResolver()
if err != nil {
return nil, fmt.Errorf("resolver: %w", err)
}
server := &abstract.Server{}
if a, err := lookupA(resolver, fqdn); err != nil {
return nil, fmt.Errorf("A lookup for %s: %w", domain, err)
} else if a != nil {
server.A = a
}
if aaaa, err := lookupAAAA(resolver, fqdn); err != nil {
return nil, fmt.Errorf("AAAA lookup for %s: %w", domain, err)
} else if aaaa != nil {
server.AAAA = aaaa
}
if server.A == nil && server.AAAA == nil {
return nil, fmt.Errorf("no A/AAAA records found for %s", domain)
}
svcBody, err := json.Marshal(server)
if err != nil {
return nil, fmt.Errorf("marshal abstract.Server: %w", err)
}
opts := sdk.CheckerOptions{
OptionService: happydns.ServiceMessage{
ServiceMeta: happydns.ServiceMeta{
Type: "abstract.Server",
Domain: domain,
},
Service: svcBody,
},
}
if raw := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts[OptionProbeTimeoutMs] = v
}
if raw := strings.TrimSpace(r.FormValue(OptionMaxRedirects)); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, errors.New("max redirects must be a number")
}
opts[OptionMaxRedirects] = v
}
if v := strings.TrimSpace(r.FormValue(OptionUserAgent)); v != "" {
opts[OptionUserAgent] = v
}
if raw := strings.TrimSpace(r.FormValue(OptionMinHSTSMaxAgeDays)); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, errors.New("HSTS max-age must be a number")
}
opts[OptionMinHSTSMaxAgeDays] = v
}
opts[OptionRequireHTTPS] = parseInteractiveBool(r, OptionRequireHTTPS, true)
opts[OptionRequireHSTS] = parseInteractiveBool(r, OptionRequireHSTS, true)
opts[OptionRequireCSP] = parseInteractiveBool(r, OptionRequireCSP, false)
return opts, nil
}
// parseInteractiveBool reads a checkbox-style field. HTML forms omit
// unchecked checkboxes entirely, so a missing key means false if the
// form was submitted (detected via the required "domain" field).
func parseInteractiveBool(r *http.Request, key string, def bool) bool {
if _, ok := r.Form[key]; !ok {
if _, submitted := r.Form["domain"]; submitted {
return false
}
return def
}
v := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
switch v {
case "", "0", "false", "off", "no":
return false
default:
return true
}
}
// systemResolver picks a DNS server to send explicit A/AAAA queries to.
// Resolution order:
// 1. CHECKER_DNS_RESOLVER env var (host or host:port)
// 2. The OS resolver config when one exists (resolvConfPath)
// 3. 1.1.1.1:53 as a last-resort public fallback
func systemResolver() (string, error) {
if env := strings.TrimSpace(os.Getenv("CHECKER_DNS_RESOLVER")); env != "" {
if _, _, err := net.SplitHostPort(env); err != nil {
env = net.JoinHostPort(env, "53")
}
return env, nil
}
if path := resolvConfPath(); path != "" {
if cfg, err := dns.ClientConfigFromFile(path); err == nil && len(cfg.Servers) > 0 {
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
}
}
return net.JoinHostPort("1.1.1.1", "53"), nil
}
func resolvConfPath() string {
for _, p := range []string{"/etc/resolv.conf"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func dnsExchange(resolver, name string, qtype uint16) (*dns.Msg, error) {
msg := new(dns.Msg)
msg.SetQuestion(name, qtype)
msg.RecursionDesired = true
c := new(dns.Client)
in, _, err := c.Exchange(msg, resolver)
if err != nil {
return nil, err
}
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
}
return in, nil
}
func lookupA(resolver, fqdn string) (*dns.A, error) {
in, err := dnsExchange(resolver, fqdn, dns.TypeA)
if err != nil {
return nil, err
}
for _, rr := range in.Answer {
if a, ok := rr.(*dns.A); ok {
return a, nil
}
}
return nil, nil
}
func lookupAAAA(resolver, fqdn string) (*dns.AAAA, error) {
in, err := dnsExchange(resolver, fqdn, dns.TypeAAAA)
if err != nil {
return nil, err
}
for _, rr := range in.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
return aaaa, nil
}
}
return nil, nil
}

48
checker/iter.go Normal file
View file

@ -0,0 +1,48 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// EvalAggregateByScheme runs fn on the subset of probes matching scheme.
// If no probe was attempted, returns a single Unknown state with
// code+".no_probes". Otherwise it returns the per-probe states emitted by
// fn, falling back to a single OK state (code+".ok" with okMsg) when fn
// emitted nothing — the conventional "everything is fine" shape used by
// reachability and redirect rules.
func EvalAggregateByScheme(data *HTTPData, scheme, code, okMsg string, fn func(p HTTPProbe, emit func(sdk.CheckState))) []sdk.CheckState {
probes := probesByScheme(data.Probes, scheme)
if len(probes) == 0 {
return []sdk.CheckState{unknownState(code+".no_probes", "No probes were attempted.")}
}
var states []sdk.CheckState
emit := func(s sdk.CheckState) { states = append(states, s) }
for _, p := range probes {
fn(p, emit)
}
if len(states) == 0 {
return []sdk.CheckState{passState(code+".ok", okMsg)}
}
return states
}
// EvalPerHTTPS calls fn for each successful HTTPS probe and returns the
// concatenated states. If no HTTPS probe succeeded, returns a single
// Unknown state with code+".no_https".
//
// Use this for rules that emit one CheckState per probe — the most common
// shape. Rules that need access to all probes at once (aggregation,
// cross-probe comparisons) should call successfulHTTPSProbes directly.
func EvalPerHTTPS(data *HTTPData, code string, fn func(p HTTPProbe) sdk.CheckState) []sdk.CheckState {
probes := successfulHTTPSProbes(data.Probes)
if len(probes) == 0 {
return []sdk.CheckState{unknownState(code+".no_https", "No successful HTTPS probe to evaluate.")}
}
out := make([]sdk.CheckState, 0, len(probes))
for _, p := range probes {
out = append(out, fn(p))
}
return out
}

20
checker/provider.go Normal file
View file

@ -0,0 +1,20 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new HTTP/HTTPS observation provider.
func Provider() sdk.ObservationProvider {
return &httpProvider{}
}
type httpProvider struct{}
func (p *httpProvider) Key() sdk.ObservationKey {
return ObservationKeyHTTP
}

244
checker/provider_test.go Normal file
View file

@ -0,0 +1,244 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"encoding/json"
"net"
"sort"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
"github.com/miekg/dns"
)
func mkServer(t *testing.T, name string, ipv4, ipv6 string) *abstract.Server {
t.Helper()
s := &abstract.Server{}
if ipv4 != "" {
s.A = &dns.A{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP(ipv4),
}
}
if ipv6 != "" {
s.AAAA = &dns.AAAA{
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP(ipv6),
}
}
return s
}
func mkServiceMessage(t *testing.T, srv *abstract.Server) happydns.ServiceMessage {
t.Helper()
raw, err := json.Marshal(srv)
if err != nil {
t.Fatal(err)
}
return happydns.ServiceMessage{
ServiceMeta: happydns.ServiceMeta{Type: "abstract.Server"},
Service: raw,
}
}
func TestProvider_KeyAndDefinition(t *testing.T) {
p := Provider()
if p.Key() != ObservationKeyHTTP {
t.Errorf("Key() = %q, want %q", p.Key(), ObservationKeyHTTP)
}
dp, ok := p.(sdk.CheckerDefinitionProvider)
if !ok {
t.Fatal("provider does not implement CheckerDefinitionProvider")
}
def := dp.Definition()
if def == nil || def.ID != "http" {
t.Fatalf("unexpected definition: %+v", def)
}
if !def.Availability.ApplyToService {
t.Errorf("ApplyToService should be true")
}
if len(def.Availability.LimitToServices) != 1 || def.Availability.LimitToServices[0] != "abstract.Server" {
t.Errorf("LimitToServices: %+v", def.Availability.LimitToServices)
}
if len(def.Rules) == 0 {
t.Error("Rules slice empty")
}
if def.Interval == nil || def.Interval.Default <= 0 {
t.Error("Interval default not set")
}
// User options must include expected keys.
idx := map[string]bool{}
for _, o := range def.Options.UserOpts {
idx[o.Id] = true
}
for _, want := range []string{OptionProbeTimeoutMs, OptionMaxRedirects, OptionUserAgent, OptionRequireHTTPS, OptionRequireHSTS, OptionMinHSTSMaxAgeDays, OptionRequireCSP} {
if !idx[want] {
t.Errorf("UserOpts missing %q", want)
}
}
}
func TestResolveServer_Success(t *testing.T) {
srv := mkServer(t, "example.test.", "203.0.113.10", "")
opts := sdk.CheckerOptions{OptionService: mkServiceMessage(t, srv)}
got, err := resolveServer(opts)
if err != nil {
t.Fatalf("resolveServer: %v", err)
}
if got.A == nil || got.A.A.String() != "203.0.113.10" {
t.Errorf("unexpected server: %+v", got)
}
}
func TestResolveServer_MissingService(t *testing.T) {
if _, err := resolveServer(sdk.CheckerOptions{}); err == nil {
t.Fatal("expected error for missing service option")
}
}
func TestResolveServer_WrongType(t *testing.T) {
msg := happydns.ServiceMessage{
ServiceMeta: happydns.ServiceMeta{Type: "abstract.NotServer"},
Service: json.RawMessage(`{}`),
}
if _, err := resolveServer(sdk.CheckerOptions{OptionService: msg}); err == nil {
t.Fatal("expected error for wrong service type")
}
}
func TestResolveServer_BadJSON(t *testing.T) {
msg := happydns.ServiceMessage{
ServiceMeta: happydns.ServiceMeta{Type: "abstract.Server"},
Service: json.RawMessage(`{not json`),
}
if _, err := resolveServer(sdk.CheckerOptions{OptionService: msg}); err == nil {
t.Fatal("expected error for malformed service payload")
}
}
func TestDiscoverIPs_DedupesAndMerges(t *testing.T) {
// Stand up a loopback DNS server that returns multiple A and AAAA
// records, then point a custom resolver at it.
mux := dns.NewServeMux()
mux.HandleFunc("multi.test.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
switch r.Question[0].Qtype {
case dns.TypeA:
for _, ip := range []string{"203.0.113.10", "203.0.113.11"} {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "multi.test.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
A: net.ParseIP(ip),
})
}
case dns.TypeAAAA:
m.Answer = append(m.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: "multi.test.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60},
AAAA: net.ParseIP("2001:db8::a"),
})
}
_ = w.WriteMsg(m)
})
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &dns.Server{PacketConn: pc, Handler: mux}
go func() { _ = srv.ActivateAndServe() }()
defer srv.Shutdown()
prev := resolver
resolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, "udp", pc.LocalAddr().String())
},
}
defer func() { resolver = prev }()
seen := map[string]struct{}{"203.0.113.10": {}} // already pinned
got := discoverIPs(context.Background(), "multi.test", seen)
sort.Strings(got)
want := []string{"2001:db8::a", "203.0.113.11"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Errorf("ip[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestDiscoverIPs_LookupFailureIsNonFatal(t *testing.T) {
prev := resolver
resolver = &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
return nil, net.ErrClosed
},
}
defer func() { resolver = prev }()
if got := discoverIPs(context.Background(), "nope.test", map[string]struct{}{}); got != nil {
t.Errorf("expected nil on resolver failure, got %v", got)
}
}
func TestAddressesFromServer(t *testing.T) {
cases := []struct {
name string
srv *abstract.Server
wantHost string
wantIPs []string
}{
{
name: "v4 only",
srv: mkServer(t, "example.test.", "203.0.113.1", ""),
wantHost: "example.test",
wantIPs: []string{"203.0.113.1"},
},
{
name: "v6 only",
srv: mkServer(t, "v6.example.test.", "", "2001:db8::1"),
wantHost: "v6.example.test",
wantIPs: []string{"2001:db8::1"},
},
{
name: "dual stack",
srv: mkServer(t, "dual.example.test.", "203.0.113.2", "2001:db8::2"),
wantHost: "dual.example.test",
wantIPs: []string{"203.0.113.2", "2001:db8::2"},
},
{
name: "empty",
srv: &abstract.Server{},
wantHost: "",
wantIPs: nil,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
host, ips := addressesFromServer(c.srv)
if host != c.wantHost {
t.Errorf("host = %q, want %q", host, c.wantHost)
}
if len(ips) != len(c.wantIPs) {
t.Fatalf("ips = %+v, want %+v", ips, c.wantIPs)
}
for i, ip := range ips {
if ip != c.wantIPs[i] {
t.Errorf("ip[%d] = %q, want %q", i, ip, c.wantIPs[i])
}
}
})
}
}

50
checker/registry.go Normal file
View file

@ -0,0 +1,50 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"sort"
"sync"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// registry holds the rules and collectors that ship with the checker.
// Each rule/collector registers itself in an init() so that adding a new
// one is a single-file change — no central list to maintain.
var registry = struct {
mu sync.Mutex
rules []sdk.CheckRule
collectors []Collector
}{}
// RegisterRule appends a rule to the global registry. Intended to be
// called from init() in each rule file.
func RegisterRule(r sdk.CheckRule) {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.rules = append(registry.rules, r)
}
// RegisterCollector appends a collector to the global registry. Reserved
// for step 4; the orchestrator currently wires only rootCollector
// directly.
func RegisterCollector(c Collector) {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.collectors = append(registry.collectors, c)
}
// Rules returns every registered rule, sorted by Name() so the output is
// stable across init-order changes (which Go does not guarantee between
// files).
func Rules() []sdk.CheckRule {
registry.mu.Lock()
out := make([]sdk.CheckRule, len(registry.rules))
copy(out, registry.rules)
registry.mu.Unlock()
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}

58
checker/rules.go Normal file
View file

@ -0,0 +1,58 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// loadHTTPData fetches the HTTPData observation. On failure, returns a
// single error CheckState the caller should emit and bail out.
func loadHTTPData(ctx context.Context, obs sdk.ObservationGetter) (*HTTPData, *sdk.CheckState) {
var data HTTPData
if err := obs.Get(ctx, ObservationKeyHTTP, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load HTTP observation: %v", err),
Code: "http.observation_error",
}
}
return &data, nil
}
func passState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: msg}
}
func unknownState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: msg}
}
// probesByScheme returns the subset of probes for a given scheme.
func probesByScheme(probes []HTTPProbe, scheme string) []HTTPProbe {
var out []HTTPProbe
for _, p := range probes {
if p.Scheme == scheme {
out = append(out, p)
}
}
return out
}
// successfulHTTPSProbes returns HTTPS probes that completed an HTTP
// transaction (status code != 0). These are the probes whose headers we
// can meaningfully inspect.
func successfulHTTPSProbes(probes []HTTPProbe) []HTTPProbe {
var out []HTTPProbe
for _, p := range probes {
if p.Scheme == "https" && p.StatusCode != 0 {
out = append(out, p)
}
}
return out
}

70
checker/rules_cookies.go Normal file
View file

@ -0,0 +1,70 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() { RegisterRule(&cookieFlagsRule{}) }
// cookieFlagsRule audits Set-Cookie attributes on HTTPS responses: every
// cookie should be Secure and HttpOnly, and SameSite should be set.
type cookieFlagsRule struct{}
func (r *cookieFlagsRule) Name() string { return "http.cookie_flags" }
func (r *cookieFlagsRule) Description() string {
return "Verifies that cookies set over HTTPS use the Secure, HttpOnly and SameSite attributes."
}
func (r *cookieFlagsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
probes := successfulHTTPSProbes(data.Probes)
if len(probes) == 0 {
return []sdk.CheckState{unknownState("http.cookie_flags.no_https", "No successful HTTPS probe to evaluate.")}
}
var states []sdk.CheckState
totalCookies := 0
for _, p := range probes {
for _, c := range p.Cookies {
totalCookies++
var issues []string
if !c.Secure {
issues = append(issues, "missing Secure")
}
if !c.HttpOnly {
issues = append(issues, "missing HttpOnly")
}
if c.SameSite == "" {
issues = append(issues, "missing SameSite")
} else if strings.EqualFold(c.SameSite, "None") && !c.Secure {
issues = append(issues, "SameSite=None requires Secure")
}
if len(issues) > 0 {
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.cookie_flags.weak",
Subject: fmt.Sprintf("%s :: %s", p.Address, c.Name),
Message: fmt.Sprintf("Cookie %q on %s: %s", c.Name, p.Address, strings.Join(issues, ", ")),
})
}
}
}
if totalCookies == 0 {
return []sdk.CheckState{passState("http.cookie_flags.none", "No cookies were set on the inspected responses.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("http.cookie_flags.ok", fmt.Sprintf("All %d cookies have proper Secure/HttpOnly/SameSite flags.", totalCookies))}
}
return states
}

View file

@ -0,0 +1,85 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestCookieFlagsRule_NoHTTPS(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
states := runRule(t, &cookieFlagsRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
}
func TestCookieFlagsRule_NoCookies(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &cookieFlagsRule{}, data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.cookie_flags.none") {
t.Errorf("missing 'none' code: %+v", states)
}
}
func TestCookieFlagsRule_AllOK(t *testing.T) {
p := httpsProbe("a:443")
p.Cookies = []CookieInfo{
{Name: "sid", Secure: true, HttpOnly: true, SameSite: "Strict"},
{Name: "tok", Secure: true, HttpOnly: true, SameSite: "Lax"},
}
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.cookie_flags.ok") {
t.Errorf("missing ok code: %+v", states)
}
}
func TestCookieFlagsRule_Issues(t *testing.T) {
p := httpsProbe("a:443")
p.Cookies = []CookieInfo{
{Name: "no-secure", Secure: false, HttpOnly: true, SameSite: "Lax"},
{Name: "no-httponly", Secure: true, HttpOnly: false, SameSite: "Lax"},
{Name: "no-samesite", Secure: true, HttpOnly: true, SameSite: ""},
{Name: "none-without-secure", Secure: false, HttpOnly: true, SameSite: "None"},
}
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
if len(states) != len(p.Cookies) {
t.Fatalf("got %d states, want %d", len(states), len(p.Cookies))
}
mustStatus(t, states, sdk.StatusWarn)
// Check each diagnostic mentions the cookie name and a relevant phrase.
wantSubstr := map[string]string{
"no-secure": "missing Secure",
"no-httponly": "missing HttpOnly",
"no-samesite": "missing SameSite",
"none-without-secure": "SameSite=None requires Secure",
}
for _, st := range states {
matched := false
for name, phrase := range wantSubstr {
if strings.Contains(st.Message, name) && strings.Contains(st.Message, phrase) {
matched = true
break
}
}
if !matched {
t.Errorf("unexpected state message: %q", st.Message)
}
}
}
func TestCookieFlagsRule_SameSiteNoneCaseInsensitive(t *testing.T) {
p := httpsProbe("a:443")
p.Cookies = []CookieInfo{{Name: "x", Secure: false, HttpOnly: true, SameSite: "none"}}
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusWarn)
if !strings.Contains(states[0].Message, "SameSite=None requires Secure") {
t.Errorf("expected SameSite=None warning regardless of casing, got %q", states[0].Message)
}
}

View file

@ -0,0 +1,62 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() {
RegisterRule(&reachabilityRule{scheme: "http", code: "http.tcp_reachable"})
RegisterRule(&reachabilityRule{scheme: "https", code: "https.tcp_reachable"})
}
// reachabilityRule reports per-IP reachability for one scheme.
type reachabilityRule struct {
scheme string // "http" or "https"
code string
}
func (r *reachabilityRule) Name() string { return r.code }
func (r *reachabilityRule) Description() string {
return fmt.Sprintf("Verifies that every probed IP accepts a %s connection on the standard port.", r.scheme)
}
func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
okMsg := fmt.Sprintf("All %s probes responded successfully.", r.scheme)
return EvalAggregateByScheme(data, r.scheme, r.code, okMsg, func(p HTTPProbe, emit func(sdk.CheckState)) {
switch {
case !p.TCPConnected:
emit(sdk.CheckState{
Status: sdk.StatusCrit,
Code: r.code + ".unreachable",
Subject: p.Address,
Message: fmt.Sprintf("Cannot reach %s://%s on %s: %s", r.scheme, p.Host, p.Address, p.Error),
})
case p.StatusCode == 0:
emit(sdk.CheckState{
Status: sdk.StatusCrit,
Code: r.code + ".no_response",
Subject: p.Address,
Message: fmt.Sprintf("TCP open but no HTTP response from %s: %s", p.Address, p.Error),
})
case p.StatusCode >= 500:
emit(sdk.CheckState{
Status: sdk.StatusWarn,
Code: r.code + ".server_error",
Subject: p.Address,
Message: fmt.Sprintf("%s returned %d", p.Address, p.StatusCode),
})
}
})
}

View file

@ -0,0 +1,79 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestReachabilityRule_NoProbes(t *testing.T) {
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
states := runRule(t, r, &HTTPData{}, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "https.tcp_reachable.no_probes") {
t.Errorf("expected no_probes code: %+v", states)
}
}
func TestReachabilityRule_AllOK(t *testing.T) {
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443"), httpsProbe("b:443")}}
states := runRule(t, r, data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "https.tcp_reachable.ok") {
t.Errorf("expected ok code: %+v", states)
}
}
func TestReachabilityRule_Unreachable(t *testing.T) {
p := httpsProbe("a:443")
p.TCPConnected = false
p.StatusCode = 0
p.Error = "i/o timeout"
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusCrit)
if !hasCode(states, "https.tcp_reachable.unreachable") {
t.Errorf("expected unreachable: %+v", states)
}
}
func TestReachabilityRule_NoResponse(t *testing.T) {
p := httpsProbe("a:443")
p.StatusCode = 0
p.Error = "EOF"
// TCPConnected stays true (connection accepted, no HTTP response).
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusCrit)
if !hasCode(states, "https.tcp_reachable.no_response") {
t.Errorf("expected no_response: %+v", states)
}
}
func TestReachabilityRule_5xx(t *testing.T) {
p := httpsProbe("a:443")
p.StatusCode = 502
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "https.tcp_reachable.server_error") {
t.Errorf("expected server_error: %+v", states)
}
}
func TestReachabilityRule_FiltersByScheme(t *testing.T) {
// An HTTP failure should not surface in the HTTPS reachability rule.
pHTTPS := httpsProbe("a:443")
pHTTP := httpProbe("a:80")
pHTTP.TCPConnected = false
pHTTP.StatusCode = 0
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{pHTTPS, pHTTP}}, nil)
mustStatus(t, states, sdk.StatusOK)
}

69
checker/rules_redirect.go Normal file
View file

@ -0,0 +1,69 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
"net/url"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() { RegisterRule(&httpsRedirectRule{}) }
// httpsRedirectRule verifies that plain HTTP either redirects to HTTPS or
// fails (which is acceptable when HTTP is intentionally not served).
type httpsRedirectRule struct{}
func (r *httpsRedirectRule) Name() string { return "http.https_redirect" }
func (r *httpsRedirectRule) Description() string {
return "Plain HTTP responses must redirect to an HTTPS URL on the same host."
}
func (r *httpsRedirectRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireHTTPS, true)
const okMsg = "HTTP redirects to HTTPS on every reachable IP."
return EvalAggregateByScheme(data, "http", "http.https_redirect", okMsg, func(p HTTPProbe, emit func(sdk.CheckState)) {
if !p.TCPConnected || p.StatusCode == 0 {
// Reachability rule handles this; an HTTP server that is
// simply not running is fine for redirect-purposes.
return
}
final := p.FinalURL
if final == "" && len(p.RedirectChain) > 0 {
final = p.RedirectChain[len(p.RedirectChain)-1].To
}
isHTTPS := false
if u, err := url.Parse(final); err == nil {
isHTTPS = strings.EqualFold(u.Scheme, "https")
}
switch {
case isHTTPS:
// Good. Aggregated below as a single OK.
case require:
emit(sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.no_https_redirect",
Subject: p.Address,
Message: fmt.Sprintf("HTTP response on %s did not redirect to HTTPS (final URL: %s, status %d)", p.Address, final, p.StatusCode),
Meta: map[string]any{"fix": "Configure your web server to redirect every plain-HTTP request to https://."},
})
default:
emit(sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.plain_http_served",
Subject: p.Address,
Message: fmt.Sprintf("HTTP responded directly without redirect (status %d)", p.StatusCode),
})
}
})
}

View file

@ -0,0 +1,72 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestHTTPSRedirectRule_NoProbes(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &httpsRedirectRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
}
func TestHTTPSRedirectRule_OKViaFinalURL(t *testing.T) {
p := httpProbe("a:80")
p.StatusCode = 301
p.FinalURL = "https://example.test/"
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.https_redirect.ok") {
t.Errorf("expected redirect.ok: %+v", states)
}
}
func TestHTTPSRedirectRule_OKViaRedirectChain(t *testing.T) {
// FinalURL empty (non-following CheckRedirect path): fallback to last redirect step.
p := httpProbe("a:80")
p.StatusCode = 308
p.FinalURL = ""
p.RedirectChain = []RedirectStep{{From: "http://example.test/", To: "https://example.test/"}}
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
}
func TestHTTPSRedirectRule_NoRedirect_Required(t *testing.T) {
p := httpProbe("a:80")
p.StatusCode = 200
p.FinalURL = "http://example.test/"
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, sdk.CheckerOptions{OptionRequireHTTPS: true})
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.no_https_redirect") {
t.Errorf("expected no_https_redirect: %+v", states)
}
}
func TestHTTPSRedirectRule_NoRedirect_NotRequired(t *testing.T) {
p := httpProbe("a:80")
p.StatusCode = 200
p.FinalURL = "http://example.test/"
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, sdk.CheckerOptions{OptionRequireHTTPS: false})
mustStatus(t, states, sdk.StatusInfo)
if !hasCode(states, "http.plain_http_served") {
t.Errorf("expected plain_http_served: %+v", states)
}
}
func TestHTTPSRedirectRule_SkipsUnreachable(t *testing.T) {
// HTTP not running on this IP: reachability rule handles it; redirect rule must skip.
p := httpProbe("a:80")
p.TCPConnected = false
p.StatusCode = 0
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.https_redirect.ok") {
t.Errorf("unreachable HTTP should leave redirect rule with summary OK, got %+v", states)
}
}

View file

@ -0,0 +1,231 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() {
RegisterRule(&hstsRule{})
RegisterRule(&cspRule{})
RegisterRule(&xFrameOptionsRule{})
RegisterRule(&xXSSProtectionRule{})
}
// hstsRule checks the Strict-Transport-Security header on HTTPS responses.
type hstsRule struct{}
func (r *hstsRule) Name() string { return "http.hsts" }
func (r *hstsRule) Description() string {
return "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses."
}
func (r *hstsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireHSTS, true)
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
minSeconds := int64(minDays) * 86400
return EvalPerHTTPS(data, "http.hsts", func(p HTTPProbe) sdk.CheckState {
h := ParseHSTS(p.Headers["strict-transport-security"])
if h == nil {
status := sdk.StatusWarn
if !require {
status = sdk.StatusInfo
}
return sdk.CheckState{
Status: status,
Code: "http.hsts.missing",
Subject: p.Address,
Message: "Strict-Transport-Security header is missing.",
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
}
}
if h.MaxAge < minSeconds {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.hsts.short_max_age",
Subject: p.Address,
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.hsts.ok",
Subject: p.Address,
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
}
})
}
// cspRule checks for the presence of a Content-Security-Policy header.
type cspRule struct{}
func (r *cspRule) Name() string { return "http.csp" }
func (r *cspRule) Description() string {
return "Verifies the presence of a Content-Security-Policy header on HTTPS responses."
}
func (r *cspRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
require := sdk.GetBoolOption(opts, OptionRequireCSP, false)
return EvalPerHTTPS(data, "http.csp", func(p HTTPProbe) sdk.CheckState {
csp := ParseCSP(p.Headers["content-security-policy"])
if csp == nil {
status := sdk.StatusInfo
if require {
status = sdk.StatusWarn
}
return sdk.CheckState{
Status: status,
Code: "http.csp.missing",
Subject: p.Address,
Message: "Content-Security-Policy header is missing.",
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
}
}
if csp.HasUnsafe() {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.csp.unsafe",
Subject: p.Address,
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.csp.ok",
Subject: p.Address,
Message: "Content-Security-Policy is set.",
}
})
}
// xFrameOptionsRule checks X-Frame-Options (or frame-ancestors in CSP as
// an acceptable substitute).
type xFrameOptionsRule struct{}
func (r *xFrameOptionsRule) Name() string { return "http.x_frame_options" }
func (r *xFrameOptionsRule) Description() string {
return "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive."
}
func (r *xFrameOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return EvalPerHTTPS(data, "http.x_frame_options", func(p HTTPProbe) sdk.CheckState {
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
hasFrameAncestors := ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors")
switch {
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_frame_options.ok",
Subject: p.Address,
Message: "Clickjacking protection is in place.",
}
case xfo != "":
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.invalid",
Subject: p.Address,
Message: "X-Frame-Options has an unrecognised value: " + xfo,
}
default:
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.x_frame_options.missing",
Subject: p.Address,
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
}
}
})
}
func init() {
// Showcase: a rule expressed entirely as a HeaderRuleSpec. Compare
// with the hand-rolled rules above — the boilerplate vanishes once
// the only logic is "is this header present and well-formed?".
RegisterRule(HeaderRule(HeaderRuleSpec{
Code: "http.x_content_type_options",
Description: "Verifies that responses set X-Content-Type-Options: nosniff.",
Header: "X-Content-Type-Options",
Required: true,
FixHint: "Add `X-Content-Type-Options: nosniff` to all responses.",
Validate: func(v string) (sdk.Status, string) {
if strings.EqualFold(v, "nosniff") {
return sdk.StatusOK, "X-Content-Type-Options: nosniff is set."
}
return sdk.StatusWarn, "X-Content-Type-Options has an unexpected value: " + strings.ToLower(v)
},
}))
}
// xXSSProtectionRule checks the legacy X-XSS-Protection header. Modern
// browsers ignore it, but if present we want it to be sane.
type xXSSProtectionRule struct{}
func (r *xXSSProtectionRule) Name() string { return "http.x_xss_protection" }
func (r *xXSSProtectionRule) Description() string {
return "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement)."
}
func (r *xXSSProtectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return EvalPerHTTPS(data, "http.x_xss_protection", func(p HTTPProbe) sdk.CheckState {
v := strings.TrimSpace(p.Headers["x-xss-protection"])
switch {
case v == "":
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.absent",
Subject: p.Address,
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
}
case strings.HasPrefix(v, "0"):
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "http.x_xss_protection.disabled",
Subject: p.Address,
Message: "X-XSS-Protection is explicitly disabled (recommended).",
}
case strings.Contains(strings.ToLower(v), "mode=block"):
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
}
default:
return sdk.CheckState{
Status: sdk.StatusInfo,
Code: "http.x_xss_protection.enabled",
Subject: p.Address,
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
}
}
})
}

View file

@ -0,0 +1,223 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestParseHSTS(t *testing.T) {
cases := []struct {
name string
in string
maxAge int64
includeSub bool
preload bool
}{
{"empty", "", 0, false, false},
{"max-age only", "max-age=31536000", 31536000, false, false},
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false},
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true},
{"quoted max-age", `max-age="3600"`, 3600, false, false},
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true},
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false},
{"unparseable max-age", "max-age=not-a-number", 0, false, false},
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := ParseHSTS(c.in)
if c.in == "" {
if h != nil {
t.Errorf("ParseHSTS(%q) = %+v, want nil", c.in, h)
}
return
}
if h == nil {
t.Fatalf("ParseHSTS(%q) returned nil", c.in)
}
if h.MaxAge != c.maxAge || h.IncludeSub != c.includeSub || h.Preload != c.preload {
t.Errorf("ParseHSTS(%q) = (%d, %v, %v), want (%d, %v, %v)",
c.in, h.MaxAge, h.IncludeSub, h.Preload, c.maxAge, c.includeSub, c.preload)
}
})
}
}
func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
states := runRule(t, &hstsRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.hsts.no_https") {
t.Errorf("missing no_https code: %+v", states)
}
}
func TestHSTSRule_MissingRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: true})
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.missing") {
t.Errorf("missing 'http.hsts.missing': %+v", states)
}
}
func TestHSTSRule_MissingNotRequired(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: false})
mustStatus(t, states, sdk.StatusInfo)
}
func TestHSTSRule_ShortMaxAge(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=60"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &hstsRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.hsts.short_max_age") {
t.Errorf("missing short_max_age code: %+v", states)
}
}
func TestHSTSRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["strict-transport-security"] = "max-age=63072000; includeSubDomains; preload"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &hstsRule{}, data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.hsts.ok") {
t.Errorf("missing ok code: %+v", states)
}
}
func TestHSTSRule_LoadFailure(t *testing.T) {
states := (&hstsRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("expected single error state, got %+v", states)
}
}
func TestCSPRule_Missing(t *testing.T) {
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
// Default: not required → Info.
states := runRule(t, &cspRule{}, data, nil)
mustStatus(t, states, sdk.StatusInfo)
// Required → Warn.
states = runRule(t, &cspRule{}, data, sdk.CheckerOptions{OptionRequireCSP: true})
mustStatus(t, states, sdk.StatusWarn)
}
func TestCSPRule_Unsafe(t *testing.T) {
for _, csp := range []string{"default-src 'self'; script-src 'unsafe-inline'", "default-src 'unsafe-eval'"} {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = csp
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &cspRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.csp.unsafe") {
t.Errorf("csp=%q: missing unsafe code: %+v", csp, states)
}
}
}
func TestCSPRule_OK(t *testing.T) {
p := httpsProbe("a:443")
p.Headers["content-security-policy"] = "default-src 'self'"
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &cspRule{}, data, nil)
mustStatus(t, states, sdk.StatusOK)
}
func TestXFrameOptionsRule(t *testing.T) {
cases := []struct {
name string
xfo string
csp string
want sdk.Status
wantSub string
}{
{"DENY", "DENY", "", sdk.StatusOK, "http.x_frame_options.ok"},
{"SAMEORIGIN lower", "sameorigin", "", sdk.StatusOK, "http.x_frame_options.ok"},
{"frame-ancestors via CSP", "", "default-src 'self'; frame-ancestors 'none'", sdk.StatusOK, "http.x_frame_options.ok"},
{"invalid value", "ALLOWALL", "", sdk.StatusWarn, "http.x_frame_options.invalid"},
{"missing", "", "", sdk.StatusWarn, "http.x_frame_options.missing"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := httpsProbe("a:443")
if c.xfo != "" {
p.Headers["x-frame-options"] = c.xfo
}
if c.csp != "" {
p.Headers["content-security-policy"] = c.csp
}
data := &HTTPData{Probes: []HTTPProbe{p}}
states := runRule(t, &xFrameOptionsRule{}, data, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.wantSub) {
t.Errorf("missing code %q in %+v", c.wantSub, states)
}
})
}
}
func TestXContentTypeOptionsRule(t *testing.T) {
cases := []struct {
val string
want sdk.Status
code string
}{
{"nosniff", sdk.StatusOK, "http.x_content_type_options.ok"},
{"NoSniff", sdk.StatusOK, "http.x_content_type_options.ok"},
{"sniff", sdk.StatusWarn, "http.x_content_type_options.invalid"},
{"", sdk.StatusWarn, "http.x_content_type_options.missing"},
}
for _, c := range cases {
p := httpsProbe("a:443")
if c.val != "" {
p.Headers["x-content-type-options"] = c.val
}
states := runRule(t, ruleByName(t, "http.x_content_type_options"), &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.code) {
t.Errorf("val=%q: missing code %q in %+v", c.val, c.code, states)
}
}
}
func TestXXSSProtectionRule(t *testing.T) {
cases := []struct {
val string
want sdk.Status
code string
}{
{"", sdk.StatusInfo, "http.x_xss_protection.absent"},
{"0", sdk.StatusOK, "http.x_xss_protection.disabled"},
{"1; mode=block", sdk.StatusInfo, "http.x_xss_protection.enabled"},
}
for _, c := range cases {
p := httpsProbe("a:443")
if c.val != "" {
p.Headers["x-xss-protection"] = c.val
}
states := runRule(t, &xXSSProtectionRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, c.want)
if !hasCode(states, c.code) {
t.Errorf("val=%q: want code %q, got %+v", c.val, c.code, states)
}
}
}
func TestSecurityHeaders_NoHTTPS(t *testing.T) {
// Each header rule must emit Unknown when there are no successful HTTPS probes.
rules := []sdk.CheckRule{&hstsRule{}, &cspRule{}, &xFrameOptionsRule{}, ruleByName(t, "http.x_content_type_options"), &xXSSProtectionRule{}}
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
for _, r := range rules {
states := runRule(t, r, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
}
}

81
checker/rules_sri.go Normal file
View file

@ -0,0 +1,81 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() { RegisterRule(&sriRule{}) }
// sriRule reports cross-origin <script>/<link> tags that lack an
// integrity= attribute. Same-origin assets don't need SRI (the user
// already trusts the origin to deliver them).
type sriRule struct{}
func (r *sriRule) Name() string { return "http.sri" }
func (r *sriRule) Description() string {
return "Reports cross-origin script and stylesheet tags that are missing Subresource Integrity (integrity=) attributes."
}
func (r *sriRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
// Only the first HTTPS probe is parsed for HTML; that's the one we
// evaluate here.
var resources []HTMLResource
var subject string
for _, p := range data.Probes {
if p.Scheme == "https" && len(p.Resources) > 0 {
resources = p.Resources
subject = p.Address
break
}
}
if subject == "" {
return []sdk.CheckState{unknownState("http.sri.no_html", "No HTML body could be parsed for SRI evaluation.")}
}
var missing []HTMLResource
crossOriginTotal := 0
for _, res := range resources {
if !res.CrossOrigin {
continue
}
crossOriginTotal++
if res.Integrity == "" {
missing = append(missing, res)
}
}
if crossOriginTotal == 0 {
return []sdk.CheckState{passState("http.sri.no_cross_origin", "No cross-origin assets reference the page.")}
}
if len(missing) == 0 {
return []sdk.CheckState{passState("http.sri.ok", fmt.Sprintf("All %d cross-origin assets carry integrity attributes.", crossOriginTotal))}
}
var states []sdk.CheckState
for _, res := range missing {
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "http.sri.missing",
Subject: subject,
Message: fmt.Sprintf("<%s> from %s lacks integrity= attribute", res.Tag, res.URL),
Meta: map[string]any{
"tag": res.Tag,
"url": res.URL,
"fix": "Generate an SRI hash and add `integrity=\"sha384-...\" crossorigin=\"anonymous\"`.",
},
})
}
return states
}

78
checker/rules_sri_test.go Normal file
View file

@ -0,0 +1,78 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestSRIRule_NoHTML(t *testing.T) {
// A probe without Resources is treated as "no parsed body".
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
states := runRule(t, &sriRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.sri.no_html") {
t.Errorf("expected no_html: %+v", states)
}
}
func TestSRIRule_NoCrossOrigin(t *testing.T) {
p := httpsProbe("a:443")
p.Resources = []HTMLResource{
{Tag: "script", URL: "/local.js", CrossOrigin: false},
{Tag: "link", URL: "/style.css", CrossOrigin: false, Rel: "stylesheet"},
}
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.sri.no_cross_origin") {
t.Errorf("expected no_cross_origin: %+v", states)
}
}
func TestSRIRule_AllCovered(t *testing.T) {
p := httpsProbe("a:443")
p.Resources = []HTMLResource{
{Tag: "script", URL: "https://cdn.example/lib.js", CrossOrigin: true, Integrity: "sha384-abc"},
{Tag: "link", URL: "https://cdn.example/style.css", CrossOrigin: true, Integrity: "sha384-def"},
}
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.sri.ok") {
t.Errorf("expected ok: %+v", states)
}
}
func TestSRIRule_SomeMissing(t *testing.T) {
p := httpsProbe("a:443")
p.Resources = []HTMLResource{
{Tag: "script", URL: "https://cdn.example/lib.js", CrossOrigin: true},
{Tag: "link", URL: "https://cdn.example/style.css", CrossOrigin: true, Integrity: "sha384-def"},
{Tag: "script", URL: "/local.js", CrossOrigin: false},
}
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
if len(states) != 1 {
t.Fatalf("expected 1 missing-state, got %d: %+v", len(states), states)
}
mustStatus(t, states, sdk.StatusWarn)
if states[0].Code != "http.sri.missing" {
t.Errorf("unexpected code: %q", states[0].Code)
}
if states[0].Meta["url"] != "https://cdn.example/lib.js" {
t.Errorf("meta.url = %v, want lib.js", states[0].Meta["url"])
}
}
func TestSRIRule_PicksFirstHTTPSWithResources(t *testing.T) {
a := httpsProbe("a:443")
b := httpsProbe("b:443")
b.Resources = []HTMLResource{{Tag: "script", URL: "https://cdn/x.js", CrossOrigin: true, Integrity: "sha384-abc"}}
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{a, b}}, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.sri.ok") {
t.Errorf("expected ok with resources from second probe, got %+v", states)
}
}

128
checker/rules_test.go Normal file
View file

@ -0,0 +1,128 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"net/http"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestRulesShape(t *testing.T) {
rs := Rules()
if len(rs) == 0 {
t.Fatal("Rules() returned no rules")
}
seen := map[string]bool{}
for _, r := range rs {
name := r.Name()
if name == "" {
t.Errorf("rule with empty name: %T", r)
}
if r.Description() == "" {
t.Errorf("rule %q has empty description", name)
}
if seen[name] {
t.Errorf("duplicate rule name: %q", name)
}
seen[name] = true
}
// The two reachability rules must exist with distinct codes per scheme.
for _, want := range []string{"http.tcp_reachable", "https.tcp_reachable", "http.https_redirect", "http.hsts", "http.csp", "http.x_frame_options", "http.x_content_type_options", "http.x_xss_protection", "http.cookie_flags", "http.sri"} {
if !seen[want] {
t.Errorf("missing rule %q in Rules()", want)
}
}
}
func TestLoadHTTPDataFailure(t *testing.T) {
obs := &fakeObs{failGet: true}
data, errSt := loadHTTPData(context.Background(), obs)
if data != nil {
t.Fatal("expected nil data on failure")
}
if errSt == nil || errSt.Status != sdk.StatusError {
t.Fatalf("expected StatusError, got %+v", errSt)
}
if errSt.Code != "http.observation_error" {
t.Errorf("unexpected code: %q", errSt.Code)
}
}
func TestLoadHTTPDataSuccess(t *testing.T) {
want := &HTTPData{Domain: "example.test", Probes: []HTTPProbe{httpsProbe("203.0.113.1:443")}}
data, errSt := loadHTTPData(context.Background(), &fakeObs{data: want})
if errSt != nil {
t.Fatalf("unexpected error state: %+v", errSt)
}
if data == nil || data.Domain != "example.test" || len(data.Probes) != 1 {
t.Fatalf("unexpected data: %+v", data)
}
}
func TestProbesByScheme(t *testing.T) {
probes := []HTTPProbe{
httpProbe("a:80"),
httpsProbe("a:443"),
httpProbe("b:80"),
}
if got := probesByScheme(probes, "http"); len(got) != 2 {
t.Errorf("http: got %d, want 2", len(got))
}
if got := probesByScheme(probes, "https"); len(got) != 1 {
t.Errorf("https: got %d, want 1", len(got))
}
if got := probesByScheme(probes, "gopher"); got != nil {
t.Errorf("unknown scheme should return nil, got %+v", got)
}
}
func TestSuccessfulHTTPSProbes(t *testing.T) {
failed := httpsProbe("a:443")
failed.StatusCode = 0
failed.TCPConnected = false
probes := []HTTPProbe{
httpProbe("a:80"),
httpsProbe("a:443"),
failed,
}
got := successfulHTTPSProbes(probes)
if len(got) != 1 {
t.Fatalf("got %d successful HTTPS probes, want 1", len(got))
}
if got[0].Address != "a:443" || got[0].StatusCode != 200 {
t.Errorf("unexpected probe: %+v", got[0])
}
}
func TestPassAndUnknownStateBuilders(t *testing.T) {
if s := passState("c", "m"); s.Status != sdk.StatusOK || s.Code != "c" || s.Message != "m" {
t.Errorf("passState wrong: %+v", s)
}
if s := unknownState("c", "m"); s.Status != sdk.StatusUnknown || s.Code != "c" || s.Message != "m" {
t.Errorf("unknownState wrong: %+v", s)
}
}
func TestSameSiteString(t *testing.T) {
cases := []struct {
in http.SameSite
want string
}{
{http.SameSiteLaxMode, "Lax"},
{http.SameSiteStrictMode, "Strict"},
{http.SameSiteNoneMode, "None"},
{http.SameSiteDefaultMode, ""},
{http.SameSite(99), ""},
}
for _, c := range cases {
if got := sameSiteString(c.in); got != c.want {
t.Errorf("sameSiteString(%v) = %q, want %q", c.in, got, c.want)
}
}
}

View file

@ -0,0 +1,64 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func init() { RegisterRule(&securityTxtRule{}) }
// securityTxtRule reports whether /.well-known/security.txt is published
// (RFC 9116). Absence is an Info, not a Warn: many sites legitimately
// have no security disclosure pipeline, but it is now the expected place
// for researchers to look first.
type securityTxtRule struct{}
func (r *securityTxtRule) Name() string { return "http.security_txt" }
func (r *securityTxtRule) Description() string {
return "Reports whether /.well-known/security.txt (RFC 9116) is published."
}
func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadHTTPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
wk, ok, err := LoadExtension[WellKnownData](data, ObservationKeyWellKnown)
if err != nil {
return []sdk.CheckState{{Status: sdk.StatusError, Code: "http.security_txt.decode_error", Message: err.Error()}}
}
if !ok {
return []sdk.CheckState{unknownState("http.security_txt.no_data", "Well-known collector did not run.")}
}
probe := wk.URIs["/.well-known/security.txt"]
switch {
case probe.StatusCode == 200 && probe.Bytes > 0:
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "http.security_txt.ok",
Subject: data.Domain,
Message: fmt.Sprintf("/.well-known/security.txt is published (%d bytes).", probe.Bytes),
}}
case probe.StatusCode == 200:
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "http.security_txt.empty",
Subject: data.Domain,
Message: "/.well-known/security.txt responded 200 but is empty.",
}}
default:
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Code: "http.security_txt.missing",
Subject: data.Domain,
Message: fmt.Sprintf("/.well-known/security.txt is not published (status %d).", probe.StatusCode),
Meta: map[string]any{"fix": "Publish /.well-known/security.txt per RFC 9116 (Contact:, Expires:, …)."},
}}
}
}

View file

@ -0,0 +1,96 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"encoding/json"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func wellKnownData(t *testing.T, probes map[string]WellKnownProbe) map[string]json.RawMessage {
t.Helper()
raw, err := json.Marshal(WellKnownData{URIs: probes})
if err != nil {
t.Fatalf("marshal: %v", err)
}
return map[string]json.RawMessage{ObservationKeyWellKnown: raw}
}
func TestSecurityTxtRule_OK(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {StatusCode: 200, Bytes: 128},
"/robots.txt": {StatusCode: 200, Bytes: 42},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusOK)
if !hasCode(states, "http.security_txt.ok") {
t.Errorf("expected ok, got %+v", states)
}
}
func TestSecurityTxtRule_Empty(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {StatusCode: 200, Bytes: 0},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusWarn)
if !hasCode(states, "http.security_txt.empty") {
t.Errorf("expected empty, got %+v", states)
}
}
func TestSecurityTxtRule_Missing(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: wellKnownData(t, map[string]WellKnownProbe{
"/.well-known/security.txt": {StatusCode: 404},
}),
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusInfo)
if !hasCode(states, "http.security_txt.missing") {
t.Errorf("expected missing, got %+v", states)
}
if states[0].Meta["fix"] == nil {
t.Errorf("expected fix hint in meta, got %+v", states[0].Meta)
}
}
func TestSecurityTxtRule_NoCollectorData(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
}
states := runRule(t, &securityTxtRule{}, data, nil)
mustStatus(t, states, sdk.StatusUnknown)
if !hasCode(states, "http.security_txt.no_data") {
t.Errorf("expected no_data, got %+v", states)
}
}
func TestSecurityTxtRule_DecodeError(t *testing.T) {
data := &HTTPData{
Domain: "example.test",
Probes: []HTTPProbe{httpsProbe("a:443")},
Extensions: map[string]json.RawMessage{
ObservationKeyWellKnown: json.RawMessage(`"not an object"`),
},
}
states := runRule(t, &securityTxtRule{}, data, nil)
if states[0].Status != sdk.StatusError || states[0].Code != "http.security_txt.decode_error" {
t.Errorf("expected decode_error, got %+v", states)
}
}

79
checker/service.go Normal file
View file

@ -0,0 +1,79 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"encoding/json"
"fmt"
"net"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
// resolver is the *net.Resolver used to discover additional A/AAAA records
// beyond what abstract.Server pins. Overridable in tests.
var resolver = net.DefaultResolver
// resolveServer extracts the *abstract.Server payload from the options.
func resolveServer(opts sdk.CheckerOptions) (*abstract.Server, error) {
svc, ok := sdk.GetOption[happydns.ServiceMessage](opts, OptionService)
if !ok {
return nil, fmt.Errorf("no service in options: did the host wire AutoFillService?")
}
if svc.Type != "abstract.Server" {
return nil, fmt.Errorf("service is %q, expected abstract.Server", svc.Type)
}
var server abstract.Server
if err := json.Unmarshal(svc.Service, &server); err != nil {
return nil, fmt.Errorf("unmarshal abstract.Server: %w", err)
}
return &server, nil
}
// addressesFromServer returns the (host, ips) tuple to probe.
func addressesFromServer(server *abstract.Server) (host string, ips []string) {
if server.A != nil && len(server.A.A) > 0 {
host = strings.TrimSuffix(server.A.Hdr.Name, ".")
ips = append(ips, server.A.A.String())
}
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
if host == "" {
host = strings.TrimSuffix(server.AAAA.Hdr.Name, ".")
}
ips = append(ips, server.AAAA.AAAA.String())
}
return
}
// discoverIPs resolves host through the system resolver and returns every
// A/AAAA address it knows about. abstract.Server only carries one pinned A
// and one pinned AAAA, so a domain backed by multiple records would only
// be partially probed without this.
//
// Failures are non-fatal: callers fall back to the pinned IPs from
// addressesFromServer. Returned IPs are deduped against `seen`.
func discoverIPs(ctx context.Context, host string, seen map[string]struct{}) []string {
if host == "" {
return nil
}
addrs, err := resolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil
}
var out []string
for _, ip := range addrs {
s := ip.String()
if _, dup := seen[s]; dup {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}

113
checker/testhelpers_test.go Normal file
View file

@ -0,0 +1,113 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
package checker
import (
"context"
"encoding/json"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// fakeObs is an in-memory ObservationGetter backed by a single HTTPData
// payload stored under ObservationKeyHTTP. A nil payload makes Get return
// an error, which lets tests cover the loadHTTPData failure branch.
type fakeObs struct {
data *HTTPData
failGet bool
}
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if f.failGet {
return errString("forced get failure")
}
if key != ObservationKeyHTTP {
return errString("unexpected key: " + key)
}
if f.data == nil {
return errString("no data")
}
raw, err := json.Marshal(f.data)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
type errString string
func (e errString) Error() string { return string(e) }
// runRule is a small wrapper that builds a fakeObs around the supplied
// HTTPData and evaluates the rule with the given options. It returns the
// states verbatim; assertion is left to the caller.
func runRule(t *testing.T, r sdk.CheckRule, data *HTTPData, opts sdk.CheckerOptions) []sdk.CheckState {
t.Helper()
if opts == nil {
opts = sdk.CheckerOptions{}
}
states := r.Evaluate(context.Background(), &fakeObs{data: data}, opts)
if len(states) == 0 {
t.Fatalf("rule %q returned no states (must always return at least one)", r.Name())
}
return states
}
func httpsProbe(addr string) HTTPProbe {
return HTTPProbe{
Scheme: "https",
Host: "example.test",
IP: "203.0.113.1",
Port: 443,
Address: addr,
TCPConnected: true,
StatusCode: 200,
Headers: map[string]string{},
}
}
func httpProbe(addr string) HTTPProbe {
p := httpsProbe(addr)
p.Scheme = "http"
p.Port = 80
return p
}
func mustStatus(t *testing.T, states []sdk.CheckState, want sdk.Status) {
t.Helper()
for _, s := range states {
if s.Status != want {
t.Fatalf("status: got %s (%q), want %s; full states: %+v", s.Status, s.Code, want, states)
}
}
}
// ruleByName looks a rule up in the global registry by Name(). It exists
// so tests can drive rules wired declaratively (HeaderRule and friends)
// without depending on a concrete type.
func ruleByName(t *testing.T, name string) sdk.CheckRule {
t.Helper()
for _, r := range Rules() {
if r.Name() == name {
return r
}
}
t.Fatalf("rule %q not found in registry", name)
return nil
}
func hasCode(states []sdk.CheckState, code string) bool {
for _, s := range states {
if s.Code == code {
return true
}
}
return false
}

125
checker/types.go Normal file
View file

@ -0,0 +1,125 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
// Package checker implements an HTTP/HTTPS server checker for happyDomain.
// It probes the abstract.Server it is attached to over HTTP (80) and HTTPS
// (443), captures response headers, cookies and the (parsed) HTML body, then
// evaluates a set of independent rules covering reachability, the HTTP→HTTPS
// upgrade path, modern transport-security headers (HSTS, CSP), application
// security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)
// and Subresource Integrity. Deep TLS / certificate analysis is intentionally
// delegated to checker-tls.
package checker
import (
"encoding/json"
"time"
)
const ObservationKeyHTTP = "http"
const (
OptionService = "service"
OptionProbeTimeoutMs = "probeTimeoutMs"
OptionMaxRedirects = "maxRedirects"
OptionUserAgent = "userAgent"
OptionRequireHTTPS = "requireHTTPS"
OptionRequireHSTS = "requireHSTS"
OptionMinHSTSMaxAgeDays = "minHSTSMaxAgeDays"
OptionRequireCSP = "requireCSP"
)
const (
DefaultHTTPPort uint16 = 80
DefaultHTTPSPort uint16 = 443
DefaultProbeTimeoutMs = 10000
DefaultMaxRedirects = 5
DefaultUserAgent = "happyDomain-checker-http/1.0"
DefaultMinHSTSMaxAge = 180 // days; 180d ≈ 15552000s, the commonly recommended minimum
MaxConcurrentProbes = 8
MaxBodyBytes = 1 << 20 // 1 MiB cap on HTML body to keep memory bounded
)
// HTTPData is the full collected payload written under ObservationKeyHTTP.
//
// Probes/Domain/CollectedAt come from the root collector and are kept at
// the top level for backward compatibility with the rules that have
// always read them directly.
//
// Extensions holds the JSON-encoded outputs of every additional Collector
// registered via RegisterCollector, keyed by Collector.Key(). Rules
// access them via LoadExtension[T] to get a typed view.
type HTTPData struct {
Domain string `json:"domain,omitempty"`
Probes []HTTPProbe `json:"probes"`
CollectedAt time.Time `json:"collected_at"`
Extensions map[string]json.RawMessage `json:"extensions,omitempty"`
}
// HTTPProbe is the outcome of a single (scheme, ip, port) probe.
type HTTPProbe struct {
Scheme string `json:"scheme"` // "http" or "https"
Host string `json:"host"`
IP string `json:"ip,omitempty"`
Port uint16 `json:"port"`
Address string `json:"address"`
IsIPv6 bool `json:"ipv6,omitempty"`
TCPConnected bool `json:"tcp_connected"`
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
Error string `json:"error,omitempty"`
// Final response after following redirects (if any).
StatusCode int `json:"status_code,omitempty"`
FinalURL string `json:"final_url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Cookies []CookieInfo `json:"cookies,omitempty"`
RedirectChain []RedirectStep `json:"redirect_chain,omitempty"`
// Parsed HTML resource references (only populated for the primary
// HTTPS probe, to keep payloads small).
Resources []HTMLResource `json:"resources,omitempty"`
HTMLBytes int `json:"html_bytes,omitempty"`
BodyTruncated bool `json:"body_truncated,omitempty"`
}
// RedirectStep records one hop of a redirect chain.
type RedirectStep struct {
From string `json:"from"`
To string `json:"to"`
Status int `json:"status"`
}
// CookieInfo summarises one Set-Cookie header.
type CookieInfo struct {
Name string `json:"name"`
Domain string `json:"domain,omitempty"`
Path string `json:"path,omitempty"`
Secure bool `json:"secure"`
HttpOnly bool `json:"http_only"`
SameSite string `json:"same_site,omitempty"` // "Strict", "Lax", "None", or ""
HasExpiry bool `json:"has_expiry,omitempty"`
}
// HTMLResource is a <script src=...> or <link href=...> reference extracted
// from the HTML body, used to evaluate Subresource Integrity coverage.
type HTMLResource struct {
Tag string `json:"tag"` // "script" or "link"
URL string `json:"url"`
CrossOrigin bool `json:"cross_origin"`
Integrity string `json:"integrity,omitempty"`
Rel string `json:"rel,omitempty"`
}
// Severity levels used internally by rules.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
SeverityOK = "ok"
)