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:
commit
7d5535fddf
39 changed files with 3179 additions and 0 deletions
322
internal/dav/rules.go
Normal file
322
internal/dav/rules.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
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)}}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue