Initial commit

CalDAV and CardDAV checkers sharing a single Go module. Discovery follows
RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal,
home-set, collections and a minimal REPORT query on top of go-webdav.
Common shape in internal/dav/; CalDAV adds a scheduling rule.

Surfaces its context URL (and each secure-SRV target) as TLS endpoints via
the EndpointDiscoverer interface, so the dedicated TLS checker can pick
them up without re-parsing observations.

HTML report foregrounds common misconfigs (well-known returning 200,
missing SRV, plaintext-only SRV, missing DAV capability, skipped auth
phase) as action-item callouts before the full phase breakdown.
This commit is contained in:
nemunaire 2026-04-19 13:44:10 +07:00
commit 7eb0dbddc7
39 changed files with 3324 additions and 0 deletions

39
internal/dav/client.go Normal file
View file

@ -0,0 +1,39 @@
package dav
import (
"net/http"
"time"
)
// NewHTTPClient returns an http.Client with a sane default transport for
// probing DAV servers. TLS certificate validation uses Go's default rules —
// dedicated TLS correctness belongs in a separate checker.
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
}
}
// basicAuthRoundTripper injects HTTP Basic credentials on every request so
// callers can pass the same client through go-webdav's own API without losing
// auth on internal redirects.
type basicAuthRoundTripper struct {
user, pass string
next http.RoundTripper
}
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.SetBasicAuth(b.user, b.pass)
return b.next.RoundTrip(req)
}
// WithBasicAuth clones c and attaches Basic credentials to the transport.
func WithBasicAuth(c *http.Client, user, pass string) *http.Client {
nc := *c
base := c.Transport
if base == nil {
base = http.DefaultTransport
}
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base}
return &nc
}

219
internal/dav/discover.go Normal file
View file

@ -0,0 +1,219 @@
package dav
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// Discover resolves the DAV context URL for domain following RFC 6764:
// /.well-known/{caldav,carddav} first (cheap and works for the common case),
// then SRV/TXT. An explicit override shortcuts everything.
//
// The returned Observation.Discovery is fully populated with whatever was
// learned along the way, even if every step fails — the report leans on the
// captured evidence to tell the user which leg of the discovery broke.
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
res := DiscoveryResult{}
if explicitURL != "" {
res.ContextURL = explicitURL
res.Source = "explicit"
return res
}
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
// it even if SRV below might have worked, to surface the mistake.
wellKnown := "https://" + domain + kind.WellKnownPath()
res.WellKnownURL = wellKnown
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
res.WellKnownCode = code
res.WellKnownChain = chain
if err != nil {
res.WellKnownError = err.Error()
} else if ctxURL != "" {
res.ContextURL = ctxURL
res.Source = "well-known"
}
// 2. SRV + TXT fallback (also informational even when well-known worked).
discoverSRV(ctx, kind, domain, &res)
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
target := res.SecureSRV[0]
path := res.TXTPath
if path == "" {
path = "/"
}
res.ContextURL = srvURL(target, path, true)
res.Source = "srv-txt"
}
if res.ContextURL == "" && res.Error == "" {
res.Error = "could not resolve a context URL via /.well-known or SRV"
}
return res
}
// followWellKnown issues a GET against path and follows up to 5 redirects
// manually so we can capture the redirect chain. The well-known endpoint
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we
// want to flag, and 404 means the site-owner forgot to set it up.
func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
chain = make([]string, 0, 5)
cur := u
for i := 0; i < 5; i++ {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil)
if reqErr != nil {
return "", chain, firstCode, reqErr
}
// Use a no-redirect client snapshot so we observe each hop.
c := *client
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, doErr := c.Do(req)
if doErr != nil {
return "", chain, firstCode, doErr
}
resp.Body.Close()
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
// We track the *first* response code because the rule cares about
// whether /.well-known itself redirected. A chain like 301→200 is
// correct; a chain starting with 200 is the misconfig we flag.
if i == 0 {
firstCode = resp.StatusCode
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
return "", chain, firstCode, errors.New("redirect with empty Location header")
}
next, parseErr := resolveLocation(cur, loc)
if parseErr != nil {
return "", chain, firstCode, parseErr
}
cur = next
continue
}
if resp.StatusCode == http.StatusOK {
return cur, chain, firstCode, nil
}
return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return "", chain, firstCode, errors.New("too many redirects")
}
func resolveLocation(base, loc string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", err
}
locURL, err := url.Parse(loc)
if err != nil {
return "", err
}
return baseURL.ResolveReference(locURL).String(), nil
}
func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) {
resolver := net.DefaultResolver
type srvResult struct {
records []SRVRecord
err error
}
secureCh := make(chan srvResult, 1)
plainCh := make(chan srvResult, 1)
go func() {
r, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
secureCh <- srvResult{r, err}
}()
go func() {
r, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
plainCh <- srvResult{r, err}
}()
secureRes := <-secureCh
if secureRes.err != nil && !isNoSuchHost(secureRes.err) {
res.SRVError = secureRes.err.Error()
}
res.SecureSRV = secureRes.records
plainRes := <-plainCh
if plainRes.err != nil && !isNoSuchHost(plainRes.err) && res.SRVError == "" {
res.SRVError = plainRes.err.Error()
}
res.PlaintextSRV = plainRes.records
// Pull the TXT path hint from whichever SRV target we plan to use.
var txtName string
if len(res.SecureSRV) > 0 {
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
} else if len(res.PlaintextSRV) > 0 {
txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target)
}
if txtName != "" {
txts, err := resolver.LookupTXT(ctx, txtName)
if err != nil && !isNoSuchHost(err) {
res.TXTError = err.Error()
}
for _, t := range txts {
if strings.HasPrefix(t, "path=") {
res.TXTPath = strings.TrimPrefix(t, "path=")
break
}
}
}
}
func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) {
_, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name)
if err != nil {
return nil, err
}
out := make([]SRVRecord, 0, len(addrs))
for _, a := range addrs {
out = append(out, SRVRecord{
Target: trimTrailingDot(a.Target),
Port: a.Port,
Priority: a.Priority,
Weight: a.Weight,
})
}
return out, nil
}
func srvURL(r SRVRecord, path string, secure bool) string {
scheme := "https"
defaultPort := uint16(443)
if !secure {
scheme = "http"
defaultPort = 80
}
host := r.Target
if r.Port != defaultPort {
host = fmt.Sprintf("%s:%d", r.Target, r.Port)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return scheme + "://" + host + path
}
func trimTrailingDot(s string) string {
return strings.TrimSuffix(s, ".")
}
func isNoSuchHost(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
return false
}

View file

@ -0,0 +1,112 @@
package dav
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestDiscover_wellKnownRedirect walks the happy path: /.well-known/caldav
// returns a 301 to the real context URL.
func TestDiscover_wellKnownRedirect(t *testing.T) {
var hits []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, r.URL.Path)
if r.URL.Path == "/.well-known/caldav" {
http.Redirect(w, r, "/dav/", http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
// Route "example.test/.well-known/caldav" through the test server.
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
if res.Source != "well-known" {
t.Errorf("source = %q, want well-known", res.Source)
}
if !strings.HasSuffix(res.ContextURL, "/dav/") {
t.Errorf("context URL = %q", res.ContextURL)
}
if res.WellKnownCode != 301 {
t.Errorf("expected 301 captured, got %d", res.WellKnownCode)
}
if len(res.WellKnownChain) < 1 {
t.Error("expected redirect chain to be recorded")
}
}
// TestDiscover_wellKnownReturns200 reproduces the most common misconfig: the
// server returns 200 on /.well-known/caldav instead of redirecting. Discover
// must still set ContextURL (to the well-known URL) but WellKnownCode=200 so
// the rule can emit the warning callout.
func TestDiscover_wellKnownReturns200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCardDAV, "example.test", "")
if res.WellKnownCode != 200 {
t.Errorf("well-known code = %d, want 200", res.WellKnownCode)
}
if res.ContextURL == "" {
t.Error("expected ContextURL to fall back to the well-known URL")
}
}
func TestDiscover_explicitOverride(t *testing.T) {
res := Discover(context.Background(), http.DefaultClient, KindCalDAV, "example.test", "https://custom.example/dav/")
if res.Source != "explicit" {
t.Errorf("source: %q", res.Source)
}
if res.ContextURL != "https://custom.example/dav/" {
t.Errorf("ctx: %q", res.ContextURL)
}
if res.WellKnownURL != "" {
t.Errorf("should not have probed well-known, got %q", res.WellKnownURL)
}
}
func TestDiscover_redirectLoop(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Always redirect to itself → triggers "too many redirects".
http.Redirect(w, r, r.URL.Path, http.StatusFound)
}))
t.Cleanup(srv.Close)
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
if res.WellKnownError == "" {
t.Error("expected well-known error, got none")
}
}
// rewriteTransport rewrites any request URL's host to point at base so we can
// exercise Discover() without setting up DNS. It preserves the original path.
type rewriteTransport struct {
base string
next http.RoundTripper
}
func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
baseURL, _ := url.Parse(r.base)
req.URL.Scheme = baseURL.Scheme
req.URL.Host = baseURL.Host
next := r.next
if next == nil {
next = http.DefaultTransport
}
return next.RoundTrip(req)
}

97
internal/dav/endpoints.go Normal file
View file

@ -0,0 +1,97 @@
package dav
import (
"log"
"net/url"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to
// downstream checkers (notably checker-tls) from a completed Observation.
//
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint,
// so we emit a single tls.endpoint.v1 entry for the resolved context URL's
// host:port. If the endpoint was reached via SRV, we also surface each SRV
// target as its own entry — those are the names operators actually need
// certificates on, and they may differ from the queried domain.
//
// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike
// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target
// split: clients negotiate TLS for the hostname they connect to). We fill
// the field unconditionally so consumers can rely on it being set.
func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
if obs == nil || obs.Discovery.ContextURL == "" {
return nil
}
var out []sdk.DiscoveryEntry
seen := map[string]struct{}{}
add := func(host string, port uint16) {
if host == "" || port == 0 {
return
}
key := host + ":" + strconv.Itoa(int(port))
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: host,
Port: port,
SNI: host,
})
if err != nil {
log.Printf("checker-dav: contract.NewEntry(%s:%d): %v", host, port, err)
return
}
out = append(out, entry)
}
// Primary endpoint: the resolved context URL.
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
add(host, port)
}
// Secondary endpoints: every TLS SRV target. Clients may connect to any
// of them per weight/priority, and all of them need a valid certificate.
for _, r := range obs.Discovery.SecureSRV {
port := r.Port
if port == 0 {
port = 443
}
add(r.Target, port)
}
return out
}
// hostPortFromURL extracts the (host, port) pair from an absolute URL. The
// port defaults to 443 for https and 80 for http. Returns ok=false for
// malformed URLs so callers can silently skip them.
func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
u, err := url.Parse(raw)
if err != nil {
return "", 0, false
}
host = u.Hostname()
if host == "" {
return "", 0, false
}
if p := u.Port(); p != "" {
n, convErr := strconv.ParseUint(p, 10, 16)
if convErr != nil {
return "", 0, false
}
return host, uint16(n), true
}
switch u.Scheme {
case "https":
return host, 443, true
case "http":
return host, 80, true
}
return "", 0, false
}

View file

@ -0,0 +1,98 @@
package dav
import (
"testing"
tlsct "git.happydns.org/checker-tls/contract"
)
// parseAll decodes DiscoverEntries output via the TLS contract. Malformed
// entries fail the test so we notice drift quickly.
func parseAll(t *testing.T, obs *Observation) []tlsct.TLSEndpoint {
t.Helper()
entries := DiscoverEntries(obs)
eps, warnings := tlsct.ParseEntries(entries)
if len(warnings) != 0 {
t.Fatalf("unexpected decode warnings: %v", warnings)
}
out := make([]tlsct.TLSEndpoint, len(eps))
for i, e := range eps {
if e.Ref == "" {
t.Errorf("entry %d has empty Ref", i)
}
out[i] = e.Endpoint
}
return out
}
func TestDiscoverEntries_contextURLOnly(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"},
}
got := parseAll(t, obs)
if len(got) != 1 {
t.Fatalf("got %d endpoints, want 1: %+v", len(got), got)
}
if got[0].Host != "dav.example.com" || got[0].Port != 443 {
t.Errorf("unexpected endpoint: %+v", got[0])
}
// Direct TLS — no STARTTLS upgrade.
if got[0].STARTTLS != "" {
t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS)
}
// SNI must be set unconditionally, even when it is equal to Host.
if got[0].SNI != "dav.example.com" {
t.Errorf("SNI = %q, want dav.example.com", got[0].SNI)
}
}
func TestDiscoverEntries_nonDefaultPort(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"},
}
got := parseAll(t, obs)
if len(got) != 1 || got[0].Port != 8443 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestDiscoverEntries_srvTargets(t *testing.T) {
// SRV pointing to a different name than the domain → we must surface
// the SRV target too, because that's the hostname the cert needs to
// cover.
obs := &Observation{
Discovery: DiscoveryResult{
ContextURL: "https://dav.example.com/caldav/",
SecureSRV: []SRVRecord{
{Target: "dav-backend-1.example.net", Port: 443},
{Target: "dav-backend-2.example.net", Port: 443},
{Target: "dav.example.com", Port: 443}, // duplicate of context → deduped
},
},
}
got := parseAll(t, obs)
if len(got) != 3 {
t.Fatalf("expected 3 unique endpoints, got %d: %+v", len(got), got)
}
hosts := map[string]bool{}
for _, e := range got {
hosts[e.Host] = true
if e.SNI != e.Host {
t.Errorf("endpoint %+v: SNI=%q, want %q (equal to Host)", e, e.SNI, e.Host)
}
}
for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} {
if !hosts[want] {
t.Errorf("missing host %q in %+v", want, got)
}
}
}
func TestDiscoverEntries_emptyOnNoContextURL(t *testing.T) {
if got := DiscoverEntries(&Observation{}); got != nil {
t.Errorf("expected nil, got %+v", got)
}
if got := DiscoverEntries(nil); got != nil {
t.Errorf("expected nil for nil obs, got %+v", got)
}
}

101
internal/dav/options.go Normal file
View file

@ -0,0 +1,101 @@
package dav
import (
"context"
"fmt"
"net/http"
"strings"
)
// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV
// headers. A missing DAV: header, or one that does not contain the kind's
// required capability, is not treated as a transport error here — the caller
// rule decides severity from the parsed values.
func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
res := OptionsResult{}
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil)
if err != nil {
res.Error = err.Error()
return res, err
}
resp, err := client.Do(req)
if err != nil {
res.Error = err.Error()
return res, err
}
defer resp.Body.Close()
res.StatusCode = resp.StatusCode
res.Server = resp.Header.Get("Server")
res.DAVClasses = parseCSVHeader(resp.Header.Values("Dav"))
res.AllowMethods = parseCSVHeader(resp.Header.Values("Allow"))
for _, h := range resp.Header.Values("Www-Authenticate") {
if scheme := authScheme(h); scheme != "" {
res.AuthSchemes = appendUnique(res.AuthSchemes, scheme)
}
}
if res.StatusCode >= 400 {
res.Error = fmt.Sprintf("OPTIONS returned HTTP %d", res.StatusCode)
}
return res, nil
}
// HasCapability returns true when the OPTIONS response advertised cap in the
// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1.
func (o OptionsResult) HasCapability(cap string) bool {
for _, c := range o.DAVClasses {
if strings.EqualFold(c, cap) {
return true
}
}
return false
}
// AllowsMethod returns true when the OPTIONS response's Allow: listed m.
func (o OptionsResult) AllowsMethod(m string) bool {
for _, a := range o.AllowMethods {
if strings.EqualFold(a, m) {
return true
}
}
return false
}
// parseCSVHeader splits one or more header values on commas, trims, and drops
// empties. Multiple headers of the same name (net/http preserves them) are
// merged.
func parseCSVHeader(values []string) []string {
var out []string
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
}
return out
}
// authScheme returns the scheme token from a WWW-Authenticate header value
// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed.
func authScheme(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if i := strings.IndexAny(h, " \t"); i > 0 {
return h[:i]
}
return h
}
func appendUnique(s []string, v string) []string {
for _, x := range s {
if strings.EqualFold(x, v) {
return s
}
}
return append(s, v)
}

View file

@ -0,0 +1,129 @@
package dav
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func UserOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "username",
Type: "string",
Label: "Username",
Description: "Optional. Supplying credentials unlocks authenticated checks (principal, home-set, collections, report probe).",
},
{
Id: "password",
Type: "string",
Label: "Password or token",
Description: "Optional. Paired with the username for HTTP Basic authentication.",
Secret: true,
},
{
Id: "context_url",
Type: "string",
Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.",
Placeholder: "https://dav.example.com/caldav/",
},
}
}
func DomainOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
},
}
}
func RunOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}
// InteractiveForm returns the fields shown on the standalone /check page.
// Discovery (well-known + SRV) happens inside Collect, so the human only
// needs to provide a domain plus the same optional knobs exposed to
// happyDomain users.
func InteractiveForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain_name",
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
},
{
Id: "username",
Type: "string",
Label: "Username",
Description: "Optional. Supplying credentials unlocks authenticated checks.",
},
{
Id: "password",
Type: "string",
Label: "Password or token",
Description: "Optional. Paired with the username for HTTP Basic auth.",
Secret: true,
},
{
Id: "context_url",
Type: "string",
Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery.",
Placeholder: "https://dav.example.com/caldav/",
},
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}
// ParseInteractiveForm turns the submitted /check form into CheckerOptions.
// Collect already handles discovery, so there is no extra lookup to do
// here beyond validating the inputs.
func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain_name"))
if domain == "" {
return nil, errors.New("domain name is required")
}
opts := sdk.CheckerOptions{"domain_name": domain}
if v := strings.TrimSpace(r.FormValue("username")); v != "" {
opts["username"] = v
}
if v := r.FormValue("password"); v != "" {
opts["password"] = v
}
if v := strings.TrimSpace(r.FormValue("context_url")); v != "" {
opts["context_url"] = v
}
if v := strings.TrimSpace(r.FormValue("timeout_seconds")); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout_seconds"] = f
}
return opts, nil
}

View file

@ -0,0 +1,115 @@
package dav
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestProbeOptions_parsesHeaders(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodOptions {
t.Fatalf("expected OPTIONS, got %s", r.Method)
}
w.Header().Set("DAV", "1, 2, calendar-access, calendar-schedule")
w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT")
w.Header().Set("Server", "TestSrv/1.0")
w.Header().Add("WWW-Authenticate", `Basic realm="test"`)
w.Header().Add("WWW-Authenticate", `Digest realm="test", nonce="abc"`)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("ProbeOptions: %v", err)
}
if !res.HasCapability("calendar-access") {
t.Errorf("expected calendar-access in %v", res.DAVClasses)
}
if !res.HasCapability("CALENDAR-SCHEDULE") {
t.Errorf("case-insensitive match failed for calendar-schedule")
}
if !res.AllowsMethod("REPORT") || !res.AllowsMethod("PROPFIND") {
t.Errorf("expected REPORT and PROPFIND in %v", res.AllowMethods)
}
if len(res.AuthSchemes) != 2 {
t.Errorf("expected 2 auth schemes, got %v", res.AuthSchemes)
}
if res.Server != "TestSrv/1.0" {
t.Errorf("Server header: %q", res.Server)
}
}
func TestProbeOptions_missingDAVHeader(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("unexpected transport error: %v", err)
}
if res.HasCapability("calendar-access") {
t.Error("expected capability absent")
}
if len(res.DAVClasses) != 0 {
t.Errorf("expected empty DAV classes, got %v", res.DAVClasses)
}
}
func TestProbeOptions_errorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL)
if err != nil {
t.Fatalf("transport err: %v", err)
}
if res.StatusCode != 503 {
t.Errorf("status: %d", res.StatusCode)
}
if res.Error == "" {
t.Error("expected Error to be set for 503")
}
}
func TestParseCSVHeader_mergeAndTrim(t *testing.T) {
got := parseCSVHeader([]string{"1, 2 ,calendar-access", " calendar-schedule"})
want := []string{"1", "2", "calendar-access", "calendar-schedule"}
if !equalSlices(got, want) {
t.Errorf("got %v want %v", got, want)
}
}
func TestAuthScheme(t *testing.T) {
cases := map[string]string{
`Basic realm="x"`: "Basic",
"Bearer": "Bearer",
`Digest realm="r", nonce="n"`: "Digest",
"": "",
" ": "",
}
for in, want := range cases {
if got := authScheme(in); got != want {
t.Errorf("authScheme(%q) = %q, want %q", in, got, want)
}
}
}
func equalSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !strings.EqualFold(a[i], b[i]) {
return false
}
}
return true
}

159
internal/dav/principal.go Normal file
View file

@ -0,0 +1,159 @@
package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// FindPrincipal requires credentials on client; a 401/403 from the server
// bubbles up as the returned error.
func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
hrefs, err := propFind(ctx, client, contextURL, "0", body)
if err != nil {
return "", err
}
for _, href := range hrefs.principalHref() {
return resolveReference(contextURL, href), nil
}
return "", fmt.Errorf("no current-user-principal returned")
}
func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL string) (inbox, outbox string, err error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:schedule-inbox-URL/>
<c:schedule-outbox-URL/>
</d:prop>
</d:propfind>`
resp, err := propFind(ctx, client, principalURL, "0", body)
if err != nil {
return "", "", err
}
for _, r := range resp.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, p := range ps.Prop.Props {
switch p.XMLName.Local {
case "schedule-inbox-URL":
if h := p.firstHref(); h != "" {
inbox = resolveReference(principalURL, h)
}
case "schedule-outbox-URL":
if h := p.firstHref(); h != "" {
outbox = resolveReference(principalURL, h)
}
}
}
}
}
return inbox, outbox, nil
}
// ── raw PROPFIND ─────────────────────────────────────────────────────────────
// multistatus is the subset of the DAV:multistatus XML schema we need to read
// principal URLs and scheduling hrefs. It is intentionally permissive — extra
// elements are ignored, which makes us tolerant of server-specific extensions.
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Response []msResponse `xml:"response"`
}
type msResponse struct {
Href string `xml:"href"`
Propstat []propstat `xml:"propstat"`
}
type propstat struct {
Prop prop `xml:"prop"`
Status string `xml:"status"`
}
type prop struct {
Props []msProp `xml:",any"`
}
type msProp struct {
XMLName xml.Name
Hrefs []string `xml:"href"`
// A prop may also contain nested <current-user-principal><href>…</href></current-user-principal>,
// which the flat Hrefs slice above captures via xml:"href" descent.
}
func (p msProp) firstHref() string {
if len(p.Hrefs) > 0 {
return p.Hrefs[0]
}
return ""
}
func (m *multistatus) principalHref() []string {
var out []string
for _, r := range m.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, pr := range ps.Prop.Props {
if pr.XMLName.Local == "current-user-principal" {
if h := pr.firstHref(); h != "" {
out = append(out, h)
}
}
}
}
}
return out
}
// propFind is a small PROPFIND helper tuned for small single-resource probes.
// It returns a parsed multistatus; transport-level failures bubble up as err.
func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", depth)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode)
}
var ms multistatus
if err := xml.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("invalid multistatus: %w", err)
}
return &ms, nil
}
func resolveReference(base, ref string) string {
r, err := url.Parse(ref)
if err != nil {
return ref
}
b, err := url.Parse(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}

475
internal/dav/report.go Normal file
View file

@ -0,0 +1,475 @@
package dav
import (
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderReport turns an Observation into a self-contained HTML document.
//
// The report foregrounds action items for the failure modes we see most often
// (well-known misconfig, missing DAV capability, missing credentials,
// downstream TLS issues on the endpoints we published) before showing the
// full per-phase evidence.
//
// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil
// is fine: the TLS section is simply omitted. This is how the happyDomain
// cross-checker composition story (see
// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate
// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS
// dashboard.
func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) {
data := buildReportData(obs, title, tlsRelated)
var buf strings.Builder
if err := reportTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render dav report: %w", err)
}
return buf.String(), nil
}
type reportData struct {
Title string
Domain string
Verdict string
VerdictCls string
Callouts []calloutData
Phases []phaseData
Raw string
ShowSched bool
Scheduling *SchedulingResult
TLSSummaries []TLSSummary
}
type calloutData struct {
Title string
Body string
Severity string // "warn" or "crit"
}
type phaseData struct {
Title string
Items []phaseItem
Open bool
}
type phaseItem struct {
Label string
Status string // "ok", "warn", "fail", "unk", "info"
Detail string
Mono string
}
func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData {
d := reportData{
Title: title,
Domain: o.Domain,
ShowSched: o.Kind == KindCalDAV,
Scheduling: o.Scheduling,
}
d.Callouts = buildCallouts(o)
d.Phases = buildPhases(o)
// Fold downstream TLS probes (published by checker-tls against the
// endpoints we discovered) into the report.
tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
d.TLSSummaries = tlsSummaries
for _, c := range tlsCallouts {
d.Callouts = append(d.Callouts, calloutData{
Severity: c.Severity,
Title: c.Title,
Body: c.Body,
})
}
if len(tlsSummaries) > 0 {
d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries))
}
switch {
case hasSeverity(d.Phases, "fail"):
d.Verdict = "Critical issues detected"
d.VerdictCls = "fail"
case hasSeverity(d.Phases, "warn"):
d.Verdict = "Minor issues detected"
d.VerdictCls = "warn"
case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"):
d.Verdict = "Could not evaluate without credentials"
d.VerdictCls = "unk"
default:
d.Verdict = "All checks passed"
d.VerdictCls = "ok"
}
return d
}
func hasSeverity(phases []phaseData, sev string) bool {
for _, p := range phases {
for _, it := range p.Items {
if it.Status == sev {
return true
}
}
}
return false
}
// buildCallouts surfaces the most common misconfigurations at the top of the
// report so operators don't have to read the full phase tree to find the fix.
func buildCallouts(o *Observation) []calloutData {
var out []calloutData
disc := o.Discovery
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
out = append(out, calloutData{
Severity: "warn",
Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL),
Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)),
})
}
if disc.ContextURL == "" {
out = append(out, calloutData{
Severity: "crit",
Title: "Service discovery failed",
Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain),
})
}
if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 {
out = append(out, calloutData{
Severity: "warn",
Title: "Plaintext SRV record without HTTPS counterpart",
Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)),
})
}
if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "crit",
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()),
Body: fmt.Sprintf("The DAV: response header is %q — this endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
})
}
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "warn",
Title: "Authenticated checks were skipped",
Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.",
})
}
return out
}
func exampleContextURL(k Kind) string {
switch k {
case KindCalDAV:
return "/dav/calendars/"
case KindCardDAV:
return "/dav/addressbooks/"
}
return "/dav/"
}
func buildPhases(o *Observation) []phaseData {
var phases []phaseData
// Phase 1 — Discovery
discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect",
wellKnownStatus(o.Discovery),
o.Discovery.WellKnownError,
summariseChain(o.Discovery.WellKnownChain),
))
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)),
srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError),
o.Discovery.SRVError,
summariseSRV(o.Discovery.SecureSRV),
))
if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" {
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)),
plainSRVStatus(o.Discovery.PlaintextSRV),
"",
summariseSRV(o.Discovery.PlaintextSRV),
))
}
if o.Discovery.TXTPath != "" {
discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath))
}
discovery.Items = append(discovery.Items, itemFor(
"Context URL",
contextStatus(o.Discovery.ContextURL),
"",
o.Discovery.ContextURL,
))
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery)
// Phase 2 — Transport + OPTIONS
transport := phaseData{Title: "Transport & OPTIONS"}
transport.Items = append(transport.Items,
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")),
itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")),
)
if len(o.Options.AuthSchemes) > 0 {
transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", ")))
}
if o.Options.Server != "" {
transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server))
}
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport)
// Phase 3 — Authenticated
auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error),
collectionsItemFor(o.Collections, o.Kind),
reportItemFor(o.Report),
)
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth)
// Phase 4 — Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised {
sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised"))
} else {
sched.Items = append(sched.Items,
itemFor("calendar-schedule advertised", "ok", "", "advertised"),
authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error),
authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""),
)
}
sched.Open = hasItemSeverity(sched.Items, "warn", "fail")
phases = append(phases, sched)
}
return phases
}
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
// rendered at the bottom of the report. Open when anything is non-OK so
// operators don't need to expand it to see the problem.
func buildTLSPhase(summaries []TLSSummary) phaseData {
p := phaseData{Title: "TLS (from checker-tls)"}
for _, s := range summaries {
label := s.Address
if s.TLSVersion != "" {
label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion)
}
p.Items = append(p.Items, phaseItem{
Label: label,
Status: s.Status,
Detail: s.Detail,
})
}
p.Open = hasItemSeverity(p.Items, "warn", "fail")
return p
}
// ── small helpers used by buildPhases ────────────────────────────────────────
func wellKnownStatus(d DiscoveryResult) string {
if d.Source == "explicit" {
return "info"
}
if d.WellKnownCode == 200 {
return "warn"
}
if d.WellKnownCode >= 300 && d.WellKnownCode < 400 {
return "ok"
}
return "fail"
}
func srvStatus(rec []SRVRecord, errStr string) string {
if len(rec) > 0 {
return "ok"
}
if errStr != "" {
return "fail"
}
return "warn"
}
func plainSRVStatus(rec []SRVRecord) string {
if len(rec) > 0 {
return "warn" // plaintext SRV is legacy / discouraged
}
return "ok"
}
func contextStatus(u string) string {
if u == "" {
return "fail"
}
return "ok"
}
func davStatus(o *Observation, r OptionsResult) string {
if r.HasCapability(o.Kind.RequiredCapability()) {
return "ok"
}
return "fail"
}
func methodsStatus(r OptionsResult) string {
if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") {
return "ok"
}
return "warn"
}
func boolStatus(ok bool, failSev string) string {
if ok {
return "ok"
}
return failSev
}
func authItemFor(label, value string, skipped bool, errStr string) phaseItem {
switch {
case skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case errStr != "":
return phaseItem{Label: label, Status: "fail", Detail: errStr}
case value == "":
return phaseItem{Label: label, Status: "warn", Detail: "not returned"}
default:
return phaseItem{Label: label, Status: "ok", Mono: value}
}
}
func collectionsItemFor(c CollectionsResult, k Kind) phaseItem {
label := "Calendars"
if k == KindCardDAV {
label = "Address books"
}
switch {
case c.Skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case c.Error != "":
return phaseItem{Label: label, Status: "fail", Detail: c.Error}
case len(c.Items) == 0:
return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"}
default:
names := make([]string, 0, len(c.Items))
for _, it := range c.Items {
n := it.Name
if n == "" {
n = it.Path
}
names = append(names, n)
}
return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")}
}
}
func reportItemFor(r ReportResult) phaseItem {
switch {
case r.Skipped:
return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"}
case r.Error != "":
return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error}
case !r.QueryOK:
return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"}
default:
return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath}
}
}
func itemFor(label, status, errStr, mono string) phaseItem {
it := phaseItem{Label: label, Status: status, Mono: mono}
if errStr != "" {
it.Detail = errStr
}
return it
}
func hasItemSeverity(items []phaseItem, sevs ...string) bool {
for _, it := range items {
for _, s := range sevs {
if it.Status == s {
return true
}
}
}
return false
}
func summariseChain(chain []string) string {
return strings.Join(chain, " → ")
}
func summariseSRV(rec []SRVRecord) string {
if len(rec) == 0 {
return ""
}
parts := make([]string, 0, len(rec))
for _, r := range rec {
parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight))
}
return strings.Join(parts, "; ")
}
// ── template ─────────────────────────────────────────────────────────────────
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} Report</title>
<style>` + ReportCSS + `</style>
</head>
<body>
<div class="hd">
<h1>{{.Title}}</h1>
<span class="badge {{.VerdictCls}}">{{.Verdict}}</span>
{{if .Domain}}<div class="verdict">Domain: <code>{{.Domain}}</code></div>{{end}}
</div>
{{if .Callouts}}
<div class="callouts">
{{range .Callouts}}
<div class="callout {{if eq .Severity "crit"}}crit{{end}}">
<h3>{{.Title}}</h3>
<p>{{.Body}}</p>
</div>
{{end}}
</div>
{{end}}
{{range .Phases}}
<details{{if .Open}} open{{end}}>
<summary><span class="phase-title">{{.Title}}</span></summary>
<div class="details-body">
<table>
{{range .Items}}
<tr>
<td style="width:1.5rem">
{{if eq .Status "ok"}}<span class="check-ok">&#10003;</span>
{{else if eq .Status "warn"}}<span class="check-warn">&#9888;</span>
{{else if eq .Status "fail"}}<span class="check-fail">&#10007;</span>
{{else if eq .Status "unk"}}<span class="check-unk">?</span>
{{else}}<span class="check-unk">i</span>{{end}}
</td>
<td style="width:45%">{{.Label}}</td>
<td>
{{if .Mono}}<code>{{.Mono}}</code>{{end}}
{{if .Detail}}<div class="note">{{.Detail}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
</div>
</details>
{{end}}
</body>
</html>`))

105
internal/dav/report_css.go Normal file
View file

@ -0,0 +1,105 @@
package dav
// ReportCSS is the shared stylesheet embedded in both checkers' HTML reports.
// Lifted (with minor edits) from checker-matrix so the whole happyDomain
// checker fleet has a consistent visual language.
const ReportCSS = `
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.verdict { color: #4b5563; margin-top: .35rem; font-size: .9rem; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em;
border-radius: 9999px;
font-size: .78rem; font-weight: 700;
letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.unk { background: #e5e7eb; color: #374151; }
.info { background: #dbeafe; color: #1e40af; }
.section {
background: #fff;
border-radius: 8px;
padding: .85rem 1rem;
margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
.callouts { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; }
.callout {
background: #fff7ed;
border-left: 4px solid #f97316;
border-radius: 6px;
padding: .7rem .9rem;
box-shadow: 0 1px 3px rgba(0,0,0,.06);
}
.callout.crit { background: #fef2f2; border-color: #dc2626; }
.callout h3 { margin: 0 0 .2rem; }
.callout p { margin: .15rem 0; font-size: .88rem; color: #374151; }
details {
background: #fff;
border-radius: 8px;
margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
overflow: hidden;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem;
cursor: pointer;
user-select: none;
list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶";
font-size: .65rem;
color: #9ca3af;
transition: transform .15s;
flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.phase-title { flex: 1; font-weight: 600; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; font-weight: 700; }
.check-warn { color: #d97706; font-weight: 700; }
.check-fail { color: #dc2626; font-weight: 700; }
.check-unk { color: #6b7280; font-weight: 700; }
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
.note { color: #6b7280; font-size: .85rem; }
ul { margin: .25rem 0; padding-left: 1.2rem; }
li { margin-bottom: .15rem; }
pre { background: #f9fafb; padding: .5rem; border-radius: 4px; overflow-x: auto; font-size: .8rem; }
`

330
internal/dav/rules.go Normal file
View file

@ -0,0 +1,330 @@
package dav
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the default rule set for kind. CardDAV gets the full set
// except `scheduling`, which only applies to CalDAV.
func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
rules := []sdk.CheckRule{
&discoveryRule{obsKey: obsKey},
&transportRule{obsKey: obsKey},
&optionsRule{obsKey: obsKey, kind: kind},
&principalRule{obsKey: obsKey},
&homeSetRule{obsKey: obsKey},
&collectionsRule{obsKey: obsKey, kind: kind},
&reportRule{obsKey: obsKey},
}
if kind == KindCalDAV {
rules = append(rules, &schedulingRule{obsKey: obsKey})
}
return rules
}
// WorstStatus is a CheckAggregator that picks the highest-severity state from
// the individual rule outcomes. StatusUnknown does not degrade the result
// unless every rule returned Unknown.
type WorstStatus struct{}
func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
if len(states) == 0 {
return sdk.CheckState{Status: sdk.StatusUnknown, Message: "no rules evaluated"}
}
ranks := map[sdk.Status]int{
sdk.StatusOK: 1,
sdk.StatusInfo: 2,
sdk.StatusUnknown: 3,
sdk.StatusWarn: 4,
sdk.StatusCrit: 5,
sdk.StatusError: 6,
}
worst := states[0]
worstRank := ranks[worst.Status]
var msgs []string
for _, s := range states {
if r := ranks[s.Status]; r > worstRank {
worstRank = r
worst = s
}
if s.Message != "" {
msgs = append(msgs, s.Message)
}
}
out := sdk.CheckState{Status: worst.Status, Code: "aggregate"}
out.Message = strings.Join(msgs, "; ")
return out
}
// ── individual rules ─────────────────────────────────────────────────────────
type baseRule struct {
obsKey sdk.ObservationKey
}
func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observation, sdk.CheckState) {
var d Observation
if err := obs.Get(ctx, r.obsKey, &d); err != nil {
return nil, sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load observation: %v", err),
Code: "observation_missing",
}
}
return &d, sdk.CheckState{}
}
// discoveryRule checks that a context URL was resolved and that the
// /.well-known endpoint is configured as a redirect (the #1 user-facing
// misconfig we want to surface).
type discoveryRule struct{ obsKey sdk.ObservationKey }
func (r *discoveryRule) Name() string { return "dav_discovery" }
func (r *discoveryRule) Description() string { return "Service discovery via /.well-known and SRV" }
func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
disc := d.Discovery
if disc.ContextURL == "" {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Code: "discovery_failed",
Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)",
}}
}
// /.well-known returning 200 is legal per RFC but strongly discouraged —
// many clients won't follow it. Warn, don't crit.
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "well_known_not_redirect",
Message: fmt.Sprintf("%s returned 200 instead of a 301/302 redirect", disc.WellKnownURL),
}}
}
if disc.Source == "srv-txt" && disc.WellKnownError != "" {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "well_known_missing",
Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError),
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "discovery_ok",
Message: fmt.Sprintf("context URL %s (via %s)", disc.ContextURL, disc.Source),
}}
}
// transportRule reports only whether the context URL accepts HTTPS requests.
// TLS specifics (cert chain, version) are explicitly out of scope.
type transportRule struct{ obsKey sdk.ObservationKey }
func (r *transportRule) Name() string { return "dav_transport" }
func (r *transportRule) Description() string { return "HTTPS connection to the context URL" }
func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
if !d.Transport.Reached {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Code: "transport_failed",
Message: fmt.Sprintf("HTTPS connection failed: %s", d.Transport.Error),
}}
}
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}}
}
// optionsRule verifies the mandatory DAV class is advertised.
type optionsRule struct {
obsKey sdk.ObservationKey
kind Kind
}
func (r *optionsRule) Name() string { return "dav_options" }
func (r *optionsRule) Description() string {
return "HTTP OPTIONS advertises the required DAV capability"
}
func (r *optionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
o := d.Options
if o.Error != "" && len(o.DAVClasses) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Code: "options_failed",
Message: fmt.Sprintf("OPTIONS request failed: %s", o.Error),
}}
}
cap := r.kind.RequiredCapability()
if !o.HasCapability(cap) {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Code: "capability_missing",
Message: fmt.Sprintf("server does not advertise %q in DAV: header (got %v)", cap, o.DAVClasses),
}}
}
if !o.AllowsMethod("PROPFIND") || !o.AllowsMethod("REPORT") {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "methods_missing",
Message: fmt.Sprintf("Allow: header missing PROPFIND or REPORT (got %v)", o.AllowMethods),
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Code: "options_ok",
Message: fmt.Sprintf("DAV: %s", strings.Join(o.DAVClasses, ", ")),
}}
}
type principalRule struct{ obsKey sdk.ObservationKey }
func (r *principalRule) Name() string { return "dav_principal" }
func (r *principalRule) Description() string { return "Principal URL discovery (authenticated)" }
func (r *principalRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
p := d.Principal
if p.Skipped {
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "principal_skipped", Message: "no credentials supplied"}}
}
if p.Error != "" {
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "principal_failed", Message: p.Error}}
}
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "principal_ok", Message: p.URL}}
}
type homeSetRule struct{ obsKey sdk.ObservationKey }
func (r *homeSetRule) Name() string { return "dav_home_set" }
func (r *homeSetRule) Description() string { return "Home-set discovered from the principal" }
func (r *homeSetRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
h := d.HomeSet
if h.Skipped {
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "home_set_skipped", Message: "no credentials supplied"}}
}
if h.Error != "" {
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "home_set_failed", Message: h.Error}}
}
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "home_set_ok", Message: h.URL}}
}
type collectionsRule struct {
obsKey sdk.ObservationKey
kind Kind
}
func (r *collectionsRule) Name() string { return "dav_collections" }
func (r *collectionsRule) Description() string {
return "Calendar/addressbook collections enumerate and expose required properties"
}
func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
c := d.Collections
if c.Skipped {
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "collections_skipped", Message: "no credentials supplied"}}
}
if c.Error != "" {
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "collections_failed", Message: c.Error}}
}
if len(c.Items) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "collections_empty",
Message: "home-set is empty — the account has no calendars/addressbooks",
}}
}
out := make([]sdk.CheckState, 0, len(c.Items))
for _, it := range c.Items {
msg := it.Name
if msg == "" {
msg = it.Path
}
if r.kind == KindCalDAV && len(it.SupportedComponentSet) > 0 {
msg = fmt.Sprintf("%s (components: %s)", msg, strings.Join(it.SupportedComponentSet, ", "))
} else if r.kind == KindCardDAV && len(it.SupportedAddressData) > 0 {
msg = fmt.Sprintf("%s (address data: %s)", msg, strings.Join(it.SupportedAddressData, ", "))
}
out = append(out, sdk.CheckState{
Status: sdk.StatusOK,
Code: "collection_ok",
Subject: it.Path,
Message: msg,
})
}
return out
}
type reportRule struct{ obsKey sdk.ObservationKey }
func (r *reportRule) Name() string { return "dav_report" }
func (r *reportRule) Description() string { return "Server accepts a minimal REPORT query" }
func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
rep := d.Report
if rep.Skipped {
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "report_skipped", Message: "no credentials supplied or no collection to probe"}}
}
if rep.Error != "" {
return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "report_failed", Message: rep.Error, Subject: rep.ProbePath}}
}
if !rep.QueryOK {
return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "report_query_not_ok", Message: "REPORT query returned an unexpected response", Subject: rep.ProbePath}}
}
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath), Subject: rep.ProbePath}}
}
// schedulingRule is CalDAV-only: if the server advertises calendar-schedule,
// the principal should expose inbox/outbox URLs.
type schedulingRule struct{ obsKey sdk.ObservationKey }
func (r *schedulingRule) Name() string { return "caldav_scheduling" }
func (r *schedulingRule) Description() string {
return "Scheduling inbox/outbox present when calendar-schedule is advertised"
}
func (r *schedulingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return []sdk.CheckState{errState}
}
s := d.Scheduling
if s == nil || !s.Advertised {
return []sdk.CheckState{{Status: sdk.StatusInfo, Code: "scheduling_not_advertised", Message: "server does not advertise calendar-schedule"}}
}
if d.Principal.Skipped {
return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "scheduling_skipped", Message: "no credentials supplied"}}
}
if s.Error != "" {
return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "scheduling_probe_failed", Message: s.Error}}
}
if s.InboxURL == "" || s.OutboxURL == "" {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "scheduling_urls_missing",
Message: "calendar-schedule advertised but schedule-inbox-URL/schedule-outbox-URL missing",
}}
}
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "scheduling_ok", Message: fmt.Sprintf("inbox=%s outbox=%s", s.InboxURL, s.OutboxURL)}}
}

276
internal/dav/tls_related.go Normal file
View file

@ -0,0 +1,276 @@
package dav
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key we expect a TLS checker to publish for
// the endpoints we discover. Matches the cross-checker convention documented
// in happydomain3/docs/checker-discovery-endpoint.md.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally
// only read the fields we need and tolerate missing ones — the TLS checker's
// full schema is owned by that checker.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
// Alternative shape used by the reference checker-tls payload sketched
// in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}.
Cert *struct {
NotAfter time.Time `json:"notAfter,omitempty"`
SANMatch *bool `json:"sanMatch,omitempty"`
ChainValid *bool `json:"chainValid,omitempty"`
DaysRemaining *int `json:"daysRemaining,omitempty"`
SubjectCN string `json:"subjectCN,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
} `json:"cert,omitempty"`
Rules []struct {
Code string `json:"code,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
} `json:"rules,omitempty"`
Issues []struct {
Code string `json:"code,omitempty"`
Severity string `json:"severity,omitempty"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
// address returns the canonical "host:port" string used to match a probe
// against one of our discovered endpoints.
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
// certExpiry normalises the two schema shapes into a single (t, ok) pair so
// callers don't have to know which one the TLS checker emits.
func (v *tlsProbeView) certExpiry() (time.Time, bool) {
if !v.NotAfter.IsZero() {
return v.NotAfter, true
}
if v.Cert != nil && !v.Cert.NotAfter.IsZero() {
return v.Cert.NotAfter, true
}
return time.Time{}, false
}
func (v *tlsProbeView) hostnameOK() (bool, bool) {
if v.HostnameMatch != nil {
return *v.HostnameMatch, true
}
if v.Cert != nil && v.Cert.SANMatch != nil {
return *v.Cert.SANMatch, true
}
return false, false
}
func (v *tlsProbeView) chainOK() (bool, bool) {
if v.ChainValid != nil {
return *v.ChainValid, true
}
if v.Cert != nil && v.Cert.ChainValid != nil {
return *v.Cert.ChainValid, true
}
return false, false
}
// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil
// when the payload doesn't look like one.
//
// Two payload shapes are accepted:
//
// 1. {"probes": {"<ref>": <probe>, …}} — the current convention used by
// checker-tls. Each consumer picks its own probe via r.Ref — the value
// is the DiscoveryEntry.Ref that the producer originally emitted,
// preserved by the host along the lineage chain.
// 2. <probe> — a single top-level probe object, kept for back-compat with
// callers that pre-date the keyed map and with unit-test fixtures.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if p, ok := keyed.Probes[r.Ref]; ok {
return &p
}
return nil
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
// TLSSummary is what the HTML report renders for each probed endpoint.
type TLSSummary struct {
Address string
TLSVersion string
Status string // "ok", "warn", "fail", "info"
Detail string
NotAfter time.Time
DaysRemaining int
}
// tlsCallout captures a cross-checker issue we want to foreground in the
// "Action items" section of the HTML report.
type tlsCallout struct {
Severity string // "warn" or "crit"
Title string
Body string
}
// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary
// for rendering, (2) callouts for the top of the report when there's anything
// actionable. Callers pass both through the reportData pipeline.
func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
sum := buildTLSSummary(v)
summaries = append(summaries, sum)
callouts = append(callouts, buildTLSCallouts(v, sum.Address)...)
}
return summaries, callouts
}
func buildTLSSummary(v *tlsProbeView) TLSSummary {
s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"}
if t, ok := v.certExpiry(); ok {
s.NotAfter = t
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
s.DaysRemaining = days
switch {
case days < 0:
s.Status = "fail"
s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days)
case days < 14:
s.Status = "warn"
s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days)
default:
s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days)
}
}
if ok, has := v.hostnameOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate does not cover the endpoint hostname"
}
if ok, has := v.chainOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate chain validation failed"
}
// Explicit issues from the TLS checker outrank our inferred status.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
switch sev {
case "crit":
s.Status = "fail"
case "warn":
if s.Status != "fail" {
s.Status = "warn"
}
}
if iss.Message != "" {
s.Detail = iss.Message
}
}
return s
}
func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
var out []tlsCallout
// Structured issues from the TLS checker are the preferred source.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
if sev != "crit" && sev != "warn" {
continue
}
callout := tlsCallout{
Severity: sev,
Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)),
}
if callout.Title == "TLS on "+addr+": " {
callout.Title = "TLS issue on " + addr
}
if iss.Fix != "" {
callout.Body = iss.Fix
} else {
callout.Body = "See the TLS checker report for details."
}
out = append(out, callout)
}
if len(out) > 0 {
return out
}
// Fallback: synthesize callouts from structured flags.
if t, ok := v.certExpiry(); ok {
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
switch {
case days < 0:
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate on %s has expired", addr),
Body: fmt.Sprintf("Renew it — clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)),
})
case days < 14:
out = append(out, tlsCallout{
Severity: "warn",
Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days),
Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)),
})
}
}
if ok, has := v.chainOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Broken certificate chain on %s", addr),
Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.",
})
}
if ok, has := v.hostnameOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate does not cover %s", addr),
Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.",
})
}
return out
}

View file

@ -0,0 +1,82 @@
package dav
import (
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func relatedFrom(t *testing.T, payload any) sdk.RelatedObservation {
t.Helper()
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return sdk.RelatedObservation{Key: TLSRelatedKey, Data: b}
}
func TestFoldTLSRelated_expiringCertProducesCallout(t *testing.T) {
exp := time.Now().Add(5 * 24 * time.Hour)
related := []sdk.RelatedObservation{relatedFrom(t, map[string]any{
"host": "dav.example.com",
"port": 443,
"not_after": exp,
})}
sums, callouts := foldTLSRelated(related)
if len(sums) != 1 || sums[0].Address != "dav.example.com:443" || sums[0].Status != "warn" {
t.Fatalf("summary: %+v", sums)
}
if len(callouts) != 1 || callouts[0].Severity != "warn" {
t.Fatalf("expected a warn callout, got %+v", callouts)
}
if !strings.Contains(callouts[0].Title, "expires in") {
t.Errorf("callout title: %q", callouts[0].Title)
}
}
func TestFoldTLSRelated_expiredCertCrit(t *testing.T) {
exp := time.Now().Add(-2 * 24 * time.Hour)
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
"host": "dav.example.com", "port": 443, "not_after": exp,
})})
if len(callouts) != 1 || callouts[0].Severity != "crit" {
t.Fatalf("expected crit for expired cert, got %+v", callouts)
}
}
func TestFoldTLSRelated_chainInvalid(t *testing.T) {
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
"host": "dav.example.com", "port": 443, "chain_valid": false,
})})
if len(callouts) != 1 || callouts[0].Severity != "crit" {
t.Fatalf("expected crit for broken chain, got %+v", callouts)
}
}
func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
"host": "dav.example.com", "port": 443,
"chain_valid": false, // would normally synthesize a callout
"issues": []map[string]any{
{"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"},
},
})})
// When explicit issues exist, we do not also emit synthesized callouts —
// the TLS checker is the source of truth for severity and wording.
if len(callouts) != 1 || callouts[0].Severity != "warn" {
t.Fatalf("want single warn callout, got %+v", callouts)
}
if !strings.Contains(callouts[0].Body, "disable TLS") {
t.Errorf("fix text lost: %q", callouts[0].Body)
}
}
func TestFoldTLSRelated_empty(t *testing.T) {
if sums, callouts := foldTLSRelated(nil); sums != nil || callouts != nil {
t.Errorf("expected nil,nil on nil input, got %+v %+v", sums, callouts)
}
}

168
internal/dav/types.go Normal file
View file

@ -0,0 +1,168 @@
// Package dav holds the code shared between the CalDAV and CardDAV checkers:
// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and
// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in
// their own packages and compose these helpers.
package dav
import "time"
// Kind distinguishes the two protocol flavours. A single Kind value is carried
// end-to-end through a checker run so shared helpers can pick the right
// service names, well-known paths, and required DAV classes.
type Kind string
const (
KindCalDAV Kind = "caldav"
KindCardDAV Kind = "carddav"
)
// ServiceName returns the RFC 6764 SRV service label for kind, with the
// leading "_" but without the "_tcp" suffix.
func (k Kind) ServiceName(secure bool) string {
switch k {
case KindCalDAV:
if secure {
return "_caldavs"
}
return "_caldav"
case KindCardDAV:
if secure {
return "_carddavs"
}
return "_carddav"
}
return ""
}
// WellKnownPath returns the RFC 6764 well-known path for kind.
func (k Kind) WellKnownPath() string {
return "/.well-known/" + string(k)
}
// RequiredCapability is the string that must appear in the DAV: response
// header for the server to qualify as a valid implementation.
func (k Kind) RequiredCapability() string {
switch k {
case KindCalDAV:
return "calendar-access"
case KindCardDAV:
return "addressbook"
}
return ""
}
// Observation is the root data structure persisted by either checker. The
// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left
// zero-valued for KindCardDAV.
type Observation struct {
Kind Kind `json:"kind"`
Domain string `json:"domain"`
HasCredentials bool `json:"has_credentials"`
Discovery DiscoveryResult `json:"discovery"`
Transport TransportResult `json:"transport"`
Options OptionsResult `json:"options"`
Principal PrincipalResult `json:"principal"`
HomeSet HomeSetResult `json:"home_set"`
Collections CollectionsResult `json:"collections"`
Report ReportResult `json:"report"`
Scheduling *SchedulingResult `json:"scheduling,omitempty"`
CollectedAt time.Time `json:"collected_at"`
}
// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
}
// DiscoveryResult captures every signal we gathered while locating the
// service: SRV secure/plaintext, TXT path hints, well-known redirects, and
// the ultimately-resolved context URL.
type DiscoveryResult struct {
SecureSRV []SRVRecord `json:"secure_srv,omitempty"`
PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"`
SRVError string `json:"srv_error,omitempty"`
TXTPath string `json:"txt_path,omitempty"`
TXTError string `json:"txt_error,omitempty"`
WellKnownURL string `json:"well_known_url,omitempty"`
WellKnownCode int `json:"well_known_code,omitempty"`
WellKnownChain []string `json:"well_known_chain,omitempty"`
WellKnownError string `json:"well_known_error,omitempty"`
ContextURL string `json:"context_url,omitempty"`
Source string `json:"source,omitempty"` // "explicit", "well-known", "srv-txt"
Error string `json:"error,omitempty"`
}
// TransportResult records whether the resolved context URL accepts HTTPS
// requests. TLS certificate validation is out of scope (a dedicated TLS
// checker covers it); we only report the raw transport-level error if any.
type TransportResult struct {
Reached bool `json:"reached"`
Error string `json:"error,omitempty"`
}
// OptionsResult captures the response to an OPTIONS request against the
// context URL: which DAV: classes are advertised, which HTTP methods are in
// Allow:, and which authentication schemes the server offered.
type OptionsResult struct {
StatusCode int `json:"status_code"`
DAVClasses []string `json:"dav_classes,omitempty"`
AllowMethods []string `json:"allow_methods,omitempty"`
AuthSchemes []string `json:"auth_schemes,omitempty"`
Server string `json:"server,omitempty"`
Error string `json:"error,omitempty"`
}
// PrincipalResult holds the `current-user-principal` URL discovered after
// authenticating. Skipped is set to true when no credentials were supplied
// (the rule surfaces this as StatusUnknown).
type PrincipalResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV
// `addressbook-home-set` URL for the authenticated principal.
type HomeSetResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
// CollectionInfo describes a single discovered calendar or addressbook.
type CollectionInfo struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
MaxResourceSize int64 `json:"max_resource_size,omitempty"`
SupportedComponentSet []string `json:"supported_component_set,omitempty"` // CalDAV only
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
}
// CollectionsResult is the enumerated content of the home-set.
type CollectionsResult struct {
Skipped bool `json:"skipped,omitempty"`
Items []CollectionInfo `json:"items,omitempty"`
Error string `json:"error,omitempty"`
}
// ReportResult is the outcome of a minimal REPORT probe against the first
// collection found (empty calendar-query/addressbook-query).
type ReportResult struct {
Skipped bool `json:"skipped,omitempty"`
QueryOK bool `json:"query_ok,omitempty"`
ProbePath string `json:"probe_path,omitempty"`
Error string `json:"error,omitempty"`
}
// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server
// advertises the `calendar-schedule` capability.
type SchedulingResult struct {
Advertised bool `json:"advertised"`
InboxURL string `json:"inbox_url,omitempty"`
OutboxURL string `json:"outbox_url,omitempty"`
Error string `json:"error,omitempty"`
}