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.
322 lines
11 KiB
Go
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)}}
|
|
}
|