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)} }