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