checker-dav/internal/dav/rules.go
Pierre-Olivier Mercier 7d5535fddf 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.
2026-04-26 21:47:40 +07:00

322 lines
11 KiB
Go

package dav
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules omits scheduling for CardDAV (CalDAV-only).
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 picks the highest-severity state. Unknown only wins if every
// rule was 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
}
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 surfaces the #1 user-facing misconfig: a missing or
// non-redirect /.well-known.
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=200 is legal but 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.StatusInfo,
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 covers reachability only; cert specifics are 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"}}
}
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.
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)}}
}