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 aae1452e12
37 changed files with 2730 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
}

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

@ -0,0 +1,203 @@
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) {
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
secure, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
if err != nil && !isNoSuchHost(err) {
res.SRVError = err.Error()
}
res.SecureSRV = secure
plain, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
if err != nil && !isNoSuchHost(err) && res.SRVError == "" {
res.SRVError = err.Error()
}
res.PlaintextSRV = plain
// 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,111 @@
package dav
import (
"context"
"net/http"
"net/http/httptest"
"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, _ := parseURL(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)
}

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

@ -0,0 +1,89 @@
package dav
import (
"net/url"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// DiscoverEndpoints derives TLS endpoints worth handing off to downstream
// checkers (notably the dedicated TLS checker) from a completed Observation.
//
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, so
// we emit a single `tls` 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 endpoint — those are the names operators actually need certificates on,
// and they may differ from the queried domain.
func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
if obs == nil || obs.Discovery.ContextURL == "" {
return nil
}
var out []sdk.DiscoveredEndpoint
seen := map[string]struct{}{}
add := func(host string, port uint16, sni string) {
if host == "" || port == 0 {
return
}
key := host + ":" + strconv.Itoa(int(port))
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
ep := sdk.DiscoveredEndpoint{
Type: "tls",
Host: host,
Port: port,
}
if sni != "" && sni != host {
ep.SNI = sni
}
out = append(out, ep)
}
// 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,64 @@
package dav
import "testing"
func TestDiscoverEndpoints_contextURLOnly(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"},
}
got := DiscoverEndpoints(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 || got[0].Type != "tls" {
t.Errorf("unexpected endpoint: %+v", got[0])
}
}
func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"},
}
got := DiscoverEndpoints(obs)
if len(got) != 1 || got[0].Port != 8443 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestDiscoverEndpoints_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 := DiscoverEndpoints(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
}
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 TestDiscoverEndpoints_emptyOnNoContextURL(t *testing.T) {
if got := DiscoverEndpoints(&Observation{}); got != nil {
t.Errorf("expected nil, got %+v", got)
}
if got := DiscoverEndpoints(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,61 @@
package dav
// CommonOptions returns the CheckerOptionField descriptors shared by both
// CalDAV and CardDAV checkers. Each checker composes them into its own
// CheckerOptionsDocumentation alongside protocol-specific tweaks.
//
// Returning plain CheckerOptionField slices (from the SDK) keeps this package
// free of any sdk import cycles: the caller already depends on the SDK.
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// UserOptions are editable in the checker settings UI.
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/",
},
}
}
// DomainOptions are auto-filled per-domain by happyDomain.
func DomainOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
},
}
}
// RunOptions are set at collect-time only.
func RunOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}

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
}

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

@ -0,0 +1,161 @@
package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
)
// FindPrincipal issues a PROPFIND for {DAV:}current-user-principal against
// contextURL. Callers should attach credentials to client; a 401/403 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")
}
// FindScheduleURLs looks up {urn:ietf:params:xml:ns:caldav}schedule-inbox-URL
// and schedule-outbox-URL on the CalDAV principal URL. CalDAV-only.
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 := parseURL(ref)
if err != nil {
return ref
}
b, err := parseURL(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}

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

@ -0,0 +1,429 @@
package dav
import (
"fmt"
"html/template"
"strings"
)
// 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) before
// showing the full per-phase evidence.
func RenderReport(obs *Observation, title string) (string, error) {
data := buildReportData(obs, title)
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
}
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) reportData {
d := reportData{
Title: title,
Domain: o.Domain,
ShowSched: o.Kind == KindCalDAV,
Scheduling: o.Scheduling,
}
d.Callouts = buildCallouts(o)
d.Phases = buildPhases(o)
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
}
// ── 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; }
`

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

@ -0,0 +1,314 @@
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 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 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 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 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 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 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",
}
}
label := "calendars"
if r.kind == KindCardDAV {
label = "addressbooks"
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "collections_ok",
Message: fmt.Sprintf("%d %s discovered", len(c.Items), label),
}
}
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 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}
}
if !rep.QueryOK {
return sdk.CheckState{Status: sdk.StatusWarn, Code: "report_query_not_ok", Message: "REPORT query returned an unexpected response"}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", 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 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)}
}

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"`
}

7
internal/dav/util.go Normal file
View file

@ -0,0 +1,7 @@
package dav
import "net/url"
// parseURL is an alias so principal.go can use net/url without importing it
// directly (keeping imports tidy across files).
func parseURL(raw string) (*url.URL, error) { return url.Parse(raw) }