From aae1452e127a0b8b49a98e15fb7988685b299dc0 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 19 Apr 2026 13:44:10 +0700 Subject: [PATCH] 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. --- Dockerfile | 15 ++ LICENSE | 21 ++ Makefile | 37 +++ NOTICE | 26 ++ README.md | 75 ++++++ caldav/collect.go | 149 ++++++++++++ caldav/definition.go | 38 +++ caldav/discovery.go | 19 ++ caldav/provider.go | 21 ++ caldav/report.go | 22 ++ caldav/types.go | 17 ++ carddav/collect.go | 125 ++++++++++ carddav/definition.go | 35 +++ carddav/discovery.go | 18 ++ carddav/provider.go | 12 + carddav/report.go | 17 ++ carddav/types.go | 11 + cmd/checker-caldav/main.go | 23 ++ cmd/checker-carddav/main.go | 23 ++ go.mod | 17 ++ go.sum | 10 + internal/dav/client.go | 39 +++ internal/dav/discover.go | 203 ++++++++++++++++ internal/dav/discover_test.go | 111 +++++++++ internal/dav/endpoints.go | 89 +++++++ internal/dav/endpoints_test.go | 64 +++++ internal/dav/options.go | 101 ++++++++ internal/dav/options_shared.go | 61 +++++ internal/dav/options_test.go | 115 +++++++++ internal/dav/principal.go | 161 +++++++++++++ internal/dav/report.go | 429 +++++++++++++++++++++++++++++++++ internal/dav/report_css.go | 105 ++++++++ internal/dav/rules.go | 314 ++++++++++++++++++++++++ internal/dav/types.go | 168 +++++++++++++ internal/dav/util.go | 7 + plugin/caldav/plugin.go | 16 ++ plugin/carddav/plugin.go | 16 ++ 37 files changed, 2730 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 caldav/collect.go create mode 100644 caldav/definition.go create mode 100644 caldav/discovery.go create mode 100644 caldav/provider.go create mode 100644 caldav/report.go create mode 100644 caldav/types.go create mode 100644 carddav/collect.go create mode 100644 carddav/definition.go create mode 100644 carddav/discovery.go create mode 100644 carddav/provider.go create mode 100644 carddav/report.go create mode 100644 carddav/types.go create mode 100644 cmd/checker-caldav/main.go create mode 100644 cmd/checker-carddav/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/dav/client.go create mode 100644 internal/dav/discover.go create mode 100644 internal/dav/discover_test.go create mode 100644 internal/dav/endpoints.go create mode 100644 internal/dav/endpoints_test.go create mode 100644 internal/dav/options.go create mode 100644 internal/dav/options_shared.go create mode 100644 internal/dav/options_test.go create mode 100644 internal/dav/principal.go create mode 100644 internal/dav/report.go create mode 100644 internal/dav/report_css.go create mode 100644 internal/dav/rules.go create mode 100644 internal/dav/types.go create mode 100644 internal/dav/util.go create mode 100644 plugin/caldav/plugin.go create mode 100644 plugin/carddav/plugin.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91af07b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build +ARG TARGET=checker-caldav + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker ./cmd/${TARGET} + +FROM scratch +COPY --from=builder /checker /checker +EXPOSE 8080 +ENTRYPOINT ["/checker"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5a55200 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +CHECKER_VERSION ?= custom-build +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +BINARIES := checker-caldav checker-carddav +PLUGINS := $(addsuffix .so,$(BINARIES)) + +.PHONY: all test clean plugin docker docker-caldav docker-carddav $(BINARIES) + +all: $(BINARIES) + +checker-caldav: + go build -ldflags "$(GO_LDFLAGS)" -o $@ ./cmd/checker-caldav + +checker-carddav: + go build -ldflags "$(GO_LDFLAGS)" -o $@ ./cmd/checker-carddav + +plugin: $(PLUGINS) + +checker-caldav.so: + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/caldav + +checker-carddav.so: + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/carddav + +test: + go test ./... + +docker: docker-caldav docker-carddav + +docker-caldav: + docker build --build-arg TARGET=checker-caldav --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t happydomain/checker-caldav . + +docker-carddav: + docker build --build-arg TARGET=checker-carddav --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t happydomain/checker-carddav . + +clean: + rm -f $(BINARIES) $(PLUGINS) diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ae61715 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-dummy +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9c67bb --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# checker-dav + +happyDomain checkers for **CalDAV** (RFC 4791) and **CardDAV** (RFC 6352) +servers. Discovery (RFC 6764) + OPTIONS + authenticated PROPFIND/REPORT +probes, with an opinionated HTML report that foregrounds common misconfigs. + +Two binaries are produced from this module: + +| Binary | Checker ID | Entrypoint | +|------------------|------------|---------------------------------| +| `checker-caldav` | `caldav` | `./cmd/checker-caldav` | +| `checker-carddav`| `carddav` | `./cmd/checker-carddav` | + +Shared code lives in `internal/dav/`: discovery, OPTIONS parsing, raw-XML +PROPFIND helpers, the rule set, and the HTML template. + +## Build + +``` +make # builds both binaries +make checker-caldav # one binary +make plugin # .so plugins for in-process loading +make docker # both Docker images +make test # unit tests +``` + +## Run + +``` +./checker-caldav -listen :8080 +``` + +The SDK exposes `/definition`, `/collect`, `/evaluate`, `/report`, and +`/health` as usual. Pass `Accept: text/html` on `/report` to get the HTML +view; the default is a JSON metrics dump. + +## Options + +Both checkers accept the same options: + +- `domain_name` (auto-filled) — required +- `username`, `password` — optional Basic credentials; unlock authenticated + checks (principal, home-set, collections, REPORT probe) +- `context_url` — optional explicit override, bypasses `/.well-known` + SRV +- `timeout_seconds` — per-request HTTP timeout, default 10 + +## What is checked + +1. **Discovery** — `/.well-known/{caldav,carddav}` (must 3xx, not 200), + `_caldavs._tcp` / `_carddavs._tcp` SRV, TXT `path=` hint. +2. **Transport** — HTTPS reachable. TLS certificate validation is + deliberately out of scope — a dedicated TLS checker covers that. +3. **OPTIONS** — `DAV:` advertises `calendar-access` or `addressbook`; Allow + includes `PROPFIND` and `REPORT`; auth schemes captured for info. +4. **Principal** — PROPFIND `current-user-principal` (auth required). +5. **Home-set** — `calendar-home-set` / `addressbook-home-set`. +6. **Collections** — enumerate, record properties (`supported-calendar-component-set`, + `supported-address-data`, display name, description, max size). +7. **REPORT probe** — issue a minimal `calendar-query` / `addressbook-query` + against the first collection. +8. **Scheduling** (CalDAV only) — if `calendar-schedule` is advertised, + verify `schedule-inbox-URL` and `schedule-outbox-URL` on the principal. + +The HTML report surfaces the most common failures at the top as callouts: + +- `/.well-known` returns 200 instead of 301/302 +- No SRV and no well-known → service unreachable +- Plaintext SRV record without secure counterpart +- Server does not advertise the required DAV class (wrong endpoint or stripping proxy) +- No credentials supplied → authenticated phase skipped + +## Dependencies + +- [`github.com/emersion/go-webdav`](https://github.com/emersion/go-webdav) — CalDAV/CardDAV client +- [`git.happydns.org/checker-sdk-go`](https://git.happydns.org/happyDomain/checker-sdk-go) — checker SDK diff --git a/caldav/collect.go b/caldav/collect.go new file mode 100644 index 0000000..04dfc83 --- /dev/null +++ b/caldav/collect.go @@ -0,0 +1,149 @@ +package caldav + +import ( + "context" + "net/http" + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" + webdav "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/caldav" +) + +// Collect runs the full CalDAV probe pipeline for the target domain. +// +// The pipeline is deliberately resilient: every phase records its outcome into +// the Observation and we keep going as long as we have something to probe +// with. Rules later translate the captured state into CheckStates. +func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain_name") + user, _ := sdk.GetOption[string](opts, "username") + pass, _ := sdk.GetOption[string](opts, "password") + explicit, _ := sdk.GetOption[string](opts, "context_url") + timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10) + + timeout := time.Duration(timeoutSec * float64(time.Second)) + if timeout <= 0 { + timeout = 10 * time.Second + } + + obs := &dav.Observation{ + Kind: dav.KindCalDAV, + Domain: domain, + HasCredentials: user != "" && pass != "", + CollectedAt: time.Now(), + Scheduling: &dav.SchedulingResult{}, + } + + anonClient := dav.NewHTTPClient(timeout) + + // Phase 1 — Discovery + obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit) + if obs.Discovery.ContextURL == "" { + return obs, nil + } + + // Phase 2 — Transport + OPTIONS (no auth required) + optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) + obs.Options = optsRes + if err != nil { + obs.Transport = dav.TransportResult{Error: err.Error()} + return obs, nil + } + obs.Transport = dav.TransportResult{Reached: true} + obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule") + + // Phase 3 — Authenticated probes + if !obs.HasCredentials { + obs.Principal.Skipped = true + obs.HomeSet.Skipped = true + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + + authClient := dav.WithBasicAuth(anonClient, user, pass) + + // Principal. + principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL) + if err != nil { + obs.Principal.Error = err.Error() + obs.HomeSet.Skipped = true + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + obs.Principal.URL = principal + + // Home-set (via go-webdav's CalDAV client). + cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL) + if err != nil { + obs.HomeSet.Error = err.Error() + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + home, err := cal.FindCalendarHomeSet(ctx, principal) + if err != nil { + obs.HomeSet.Error = err.Error() + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + obs.HomeSet.URL = home + + // Collections. + calendars, err := cal.FindCalendars(ctx, home) + if err != nil { + obs.Collections.Error = err.Error() + obs.Report.Skipped = true + } else { + for _, c := range calendars { + obs.Collections.Items = append(obs.Collections.Items, dav.CollectionInfo{ + Path: c.Path, + Name: c.Name, + Description: c.Description, + MaxResourceSize: c.MaxResourceSize, + SupportedComponentSet: c.SupportedComponentSet, + }) + } + } + + // Report probe — empty calendar-query against the first calendar. + if len(obs.Collections.Items) > 0 { + first := obs.Collections.Items[0].Path + obs.Report.ProbePath = first + q := &caldav.CalendarQuery{ + CompRequest: caldav.CalendarCompRequest{ + Name: "VCALENDAR", + Comps: []caldav.CalendarCompRequest{ + {Name: "VEVENT"}, + }, + }, + } + if _, err := cal.QueryCalendar(ctx, first, q); err != nil { + obs.Report.Error = err.Error() + } else { + obs.Report.QueryOK = true + } + } else { + obs.Report.Skipped = true + } + + // Scheduling inbox/outbox — only probe if advertised. + if obs.Scheduling.Advertised { + inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal) + if err != nil { + obs.Scheduling.Error = err.Error() + } + obs.Scheduling.InboxURL = inbox + obs.Scheduling.OutboxURL = outbox + } + + return obs, nil +} + +// asHTTPClient adapts stdlib *http.Client to go-webdav's HTTPClient interface. +// The interface has a single Do method so the conversion is free. +func asHTTPClient(c *http.Client) webdav.HTTPClient { return c } diff --git a/caldav/definition.go b/caldav/definition.go new file mode 100644 index 0000000..f9d6c39 --- /dev/null +++ b/caldav/definition.go @@ -0,0 +1,38 @@ +package caldav + +import ( + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the checker version reported in CheckerDefinition.Version. +// Overridden at link time by the standalone binary via -ldflags. +var Version = "built-in" + +// Definition returns the CheckerDefinition for the CalDAV checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "caldav", + Name: "CalDAV server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKey}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: dav.UserOptions(), + DomainOpts: dav.DomainOptions(), + RunOpts: dav.RunOptions(), + }, + Rules: dav.Rules(dav.KindCalDAV, ObservationKey), + Aggregator: dav.WorstStatus{}, + Interval: &sdk.CheckIntervalSpec{ + Min: 1 * time.Minute, + Max: 1 * time.Hour, + Default: 15 * time.Minute, + }, + HasHTMLReport: true, + } +} diff --git a/caldav/discovery.go b/caldav/discovery.go new file mode 100644 index 0000000..72bfea7 --- /dev/null +++ b/caldav/discovery.go @@ -0,0 +1,19 @@ +package caldav + +import ( + "fmt" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// DiscoverEndpoints implements sdk.EndpointDiscoverer. The SDK server calls +// this with the native Go value returned by Collect, so we just type-assert +// and delegate to the shared helper. +func (p *caldavProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) { + obs, ok := data.(*dav.Observation) + if !ok { + return nil, fmt.Errorf("unexpected data type %T", data) + } + return dav.DiscoverEndpoints(obs), nil +} diff --git a/caldav/provider.go b/caldav/provider.go new file mode 100644 index 0000000..2de68ee --- /dev/null +++ b/caldav/provider.go @@ -0,0 +1,21 @@ +package caldav + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns the CalDAV observation provider. +// +// The returned value implements sdk.ObservationProvider, plus the optional +// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer +// interfaces so the SDK's HTTP server exposes /definition, /evaluate, +// /report, and forwards discovered TLS endpoints to downstream checkers. +func Provider() sdk.ObservationProvider { + return &caldavProvider{} +} + +type caldavProvider struct{} + +func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey } + +func (p *caldavProvider) Definition() *sdk.CheckerDefinition { return Definition() } diff --git a/caldav/report.go b/caldav/report.go new file mode 100644 index 0000000..39dd680 --- /dev/null +++ b/caldav/report.go @@ -0,0 +1,22 @@ +package caldav + +import ( + "encoding/json" + "fmt" + + "git.happydns.org/checker-dav/internal/dav" +) + +// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider. +// +// The actual rendering is delegated to the shared renderer in internal/dav so +// CalDAV and CardDAV produce visually identical reports; the only difference +// is the title and the set of phases rendered (CalDAV includes Scheduling). +func (p *caldavProvider) GetHTMLReport(raw json.RawMessage) (string, error) { + var d dav.Observation + if err := json.Unmarshal(raw, &d); err != nil { + return "", fmt.Errorf("failed to unmarshal caldav report: %w", err) + } + d.Kind = dav.KindCalDAV + return dav.RenderReport(&d, "CalDAV Server") +} diff --git a/caldav/types.go b/caldav/types.go new file mode 100644 index 0000000..1a2ddc3 --- /dev/null +++ b/caldav/types.go @@ -0,0 +1,17 @@ +// Package checker (imported as caldav by the standalone binary) implements +// the CalDAV compliance and health checker for happyDomain. +// +// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting +// helpers live in git.happydns.org/checker-dav/internal/dav, so this package +// only wires the CalDAV-specific options, collect pipeline, rules, and HTML +// report together. +package caldav + +import "git.happydns.org/checker-dav/internal/dav" + +// ObservationKey identifies CalDAV observations in happyDomain's store. +const ObservationKey = "caldav" + +// Data is the persisted observation shape. Callers read it back via +// obs.Get(ctx, ObservationKey, &Data). +type Data = dav.Observation diff --git a/carddav/collect.go b/carddav/collect.go new file mode 100644 index 0000000..a36ed57 --- /dev/null +++ b/carddav/collect.go @@ -0,0 +1,125 @@ +package carddav + +import ( + "context" + "net/http" + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" + webdav "github.com/emersion/go-webdav" + "github.com/emersion/go-webdav/carddav" +) + +func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain_name") + user, _ := sdk.GetOption[string](opts, "username") + pass, _ := sdk.GetOption[string](opts, "password") + explicit, _ := sdk.GetOption[string](opts, "context_url") + timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10) + + timeout := time.Duration(timeoutSec * float64(time.Second)) + if timeout <= 0 { + timeout = 10 * time.Second + } + + obs := &dav.Observation{ + Kind: dav.KindCardDAV, + Domain: domain, + HasCredentials: user != "" && pass != "", + CollectedAt: time.Now(), + } + + anonClient := dav.NewHTTPClient(timeout) + + // Phase 1 — Discovery + obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit) + if obs.Discovery.ContextURL == "" { + return obs, nil + } + + // Phase 2 — OPTIONS + optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) + obs.Options = optsRes + if err != nil { + obs.Transport = dav.TransportResult{Error: err.Error()} + return obs, nil + } + obs.Transport = dav.TransportResult{Reached: true} + + // Phase 3 — Authenticated + if !obs.HasCredentials { + obs.Principal.Skipped = true + obs.HomeSet.Skipped = true + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + + authClient := dav.WithBasicAuth(anonClient, user, pass) + + principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL) + if err != nil { + obs.Principal.Error = err.Error() + obs.HomeSet.Skipped = true + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + obs.Principal.URL = principal + + card, err := carddav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL) + if err != nil { + obs.HomeSet.Error = err.Error() + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + home, err := card.FindAddressBookHomeSet(ctx, principal) + if err != nil { + obs.HomeSet.Error = err.Error() + obs.Collections.Skipped = true + obs.Report.Skipped = true + return obs, nil + } + obs.HomeSet.URL = home + + books, err := card.FindAddressBooks(ctx, home) + if err != nil { + obs.Collections.Error = err.Error() + obs.Report.Skipped = true + } else { + for _, b := range books { + item := dav.CollectionInfo{ + Path: b.Path, + Name: b.Name, + Description: b.Description, + MaxResourceSize: b.MaxResourceSize, + } + for _, d := range b.SupportedAddressData { + item.SupportedAddressData = append(item.SupportedAddressData, d.ContentType+";"+d.Version) + } + obs.Collections.Items = append(obs.Collections.Items, item) + } + } + + if len(obs.Collections.Items) > 0 { + first := obs.Collections.Items[0].Path + obs.Report.ProbePath = first + q := &carddav.AddressBookQuery{ + DataRequest: carddav.AddressDataRequest{AllProp: true}, + Limit: 1, + } + if _, err := card.QueryAddressBook(ctx, first, q); err != nil { + obs.Report.Error = err.Error() + } else { + obs.Report.QueryOK = true + } + } else { + obs.Report.Skipped = true + } + + return obs, nil +} + +func asHTTPClient(c *http.Client) webdav.HTTPClient { return c } diff --git a/carddav/definition.go b/carddav/definition.go new file mode 100644 index 0000000..b45e33d --- /dev/null +++ b/carddav/definition.go @@ -0,0 +1,35 @@ +package carddav + +import ( + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "built-in" + +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "carddav", + Name: "CardDAV server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + }, + ObservationKeys: []sdk.ObservationKey{ObservationKey}, + Options: sdk.CheckerOptionsDocumentation{ + UserOpts: dav.UserOptions(), + DomainOpts: dav.DomainOptions(), + RunOpts: dav.RunOptions(), + }, + Rules: dav.Rules(dav.KindCardDAV, ObservationKey), + Aggregator: dav.WorstStatus{}, + Interval: &sdk.CheckIntervalSpec{ + Min: 1 * time.Minute, + Max: 1 * time.Hour, + Default: 15 * time.Minute, + }, + HasHTMLReport: true, + } +} diff --git a/carddav/discovery.go b/carddav/discovery.go new file mode 100644 index 0000000..fe13770 --- /dev/null +++ b/carddav/discovery.go @@ -0,0 +1,18 @@ +package carddav + +import ( + "fmt" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// DiscoverEndpoints implements sdk.EndpointDiscoverer. See the CalDAV sibling +// for the rationale — the shared helper produces the TLS endpoints. +func (p *carddavProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) { + obs, ok := data.(*dav.Observation) + if !ok { + return nil, fmt.Errorf("unexpected data type %T", data) + } + return dav.DiscoverEndpoints(obs), nil +} diff --git a/carddav/provider.go b/carddav/provider.go new file mode 100644 index 0000000..e8a6165 --- /dev/null +++ b/carddav/provider.go @@ -0,0 +1,12 @@ +package carddav + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Provider() sdk.ObservationProvider { return &carddavProvider{} } + +type carddavProvider struct{} + +func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey } +func (p *carddavProvider) Definition() *sdk.CheckerDefinition { return Definition() } diff --git a/carddav/report.go b/carddav/report.go new file mode 100644 index 0000000..61ce078 --- /dev/null +++ b/carddav/report.go @@ -0,0 +1,17 @@ +package carddav + +import ( + "encoding/json" + "fmt" + + "git.happydns.org/checker-dav/internal/dav" +) + +func (p *carddavProvider) GetHTMLReport(raw json.RawMessage) (string, error) { + var d dav.Observation + if err := json.Unmarshal(raw, &d); err != nil { + return "", fmt.Errorf("failed to unmarshal carddav report: %w", err) + } + d.Kind = dav.KindCardDAV + return dav.RenderReport(&d, "CardDAV Server") +} diff --git a/carddav/types.go b/carddav/types.go new file mode 100644 index 0000000..814e506 --- /dev/null +++ b/carddav/types.go @@ -0,0 +1,11 @@ +// Package checker (imported as carddav) implements the CardDAV compliance +// and health checker for happyDomain. See the CalDAV sibling for the shape; +// the two packages share ~everything except the protocol-specific home-set +// and REPORT calls, which live in collect.go. +package carddav + +import "git.happydns.org/checker-dav/internal/dav" + +const ObservationKey = "carddav" + +type Data = dav.Observation diff --git a/cmd/checker-caldav/main.go b/cmd/checker-caldav/main.go new file mode 100644 index 0000000..78c1f00 --- /dev/null +++ b/cmd/checker-caldav/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-dav/caldav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is injected at link time via -ldflags "-X main.Version=...". +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + caldav.Version = Version + server := sdk.NewServer(caldav.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/cmd/checker-carddav/main.go b/cmd/checker-carddav/main.go new file mode 100644 index 0000000..19f4c2c --- /dev/null +++ b/cmd/checker-carddav/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-dav/carddav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is injected at link time via -ldflags "-X main.Version=...". +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + carddav.Version = Version + server := sdk.NewServer(carddav.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3ee8728 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.happydns.org/checker-dav + +go 1.25.0 + +require git.happydns.org/checker-sdk-go v1.0.0 + +// Pinned to the local checkout until the EndpointDiscoverer interface +// (commit 131e3cd, 2026-04-19) ships in a tagged release. +replace git.happydns.org/checker-sdk-go => ../checker-sdk-go + +require github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect + +require ( + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect + github.com/emersion/go-webdav v0.7.0 + github.com/teambition/rrule-go v1.8.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..104d91e --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +git.happydns.org/checker-sdk-go v1.0.0 h1:5u8vnvoH2KEbHtAqPu/Wh6xBQ8PWgle9iZ1j7HTZXd8= +git.happydns.org/checker-sdk-go v1.0.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= diff --git a/internal/dav/client.go b/internal/dav/client.go new file mode 100644 index 0000000..266c31c --- /dev/null +++ b/internal/dav/client.go @@ -0,0 +1,39 @@ +package dav + +import ( + "net/http" + "time" +) + +// NewHTTPClient returns an http.Client with a sane default transport for +// probing DAV servers. TLS certificate validation uses Go's default rules — +// dedicated TLS correctness belongs in a separate checker. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + } +} + +// basicAuthRoundTripper injects HTTP Basic credentials on every request so +// callers can pass the same client through go-webdav's own API without losing +// auth on internal redirects. +type basicAuthRoundTripper struct { + user, pass string + next http.RoundTripper +} + +func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(b.user, b.pass) + return b.next.RoundTrip(req) +} + +// WithBasicAuth clones c and attaches Basic credentials to the transport. +func WithBasicAuth(c *http.Client, user, pass string) *http.Client { + nc := *c + base := c.Transport + if base == nil { + base = http.DefaultTransport + } + nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base} + return &nc +} diff --git a/internal/dav/discover.go b/internal/dav/discover.go new file mode 100644 index 0000000..94e074f --- /dev/null +++ b/internal/dav/discover.go @@ -0,0 +1,203 @@ +package dav + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +// Discover resolves the DAV context URL for domain following RFC 6764: +// /.well-known/{caldav,carddav} first (cheap and works for the common case), +// then SRV/TXT. An explicit override shortcuts everything. +// +// The returned Observation.Discovery is fully populated with whatever was +// learned along the way, even if every step fails — the report leans on the +// captured evidence to tell the user which leg of the discovery broke. +func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult { + res := DiscoveryResult{} + + if explicitURL != "" { + res.ContextURL = explicitURL + res.Source = "explicit" + return res + } + + // 1. /.well-known — this is the #1 misconfig hotspot, so we always probe + // it even if SRV below might have worked, to surface the mistake. + wellKnown := "https://" + domain + kind.WellKnownPath() + res.WellKnownURL = wellKnown + ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown) + res.WellKnownCode = code + res.WellKnownChain = chain + if err != nil { + res.WellKnownError = err.Error() + } else if ctxURL != "" { + res.ContextURL = ctxURL + res.Source = "well-known" + } + + // 2. SRV + TXT fallback (also informational even when well-known worked). + discoverSRV(ctx, kind, domain, &res) + + if res.ContextURL == "" && len(res.SecureSRV) > 0 { + target := res.SecureSRV[0] + path := res.TXTPath + if path == "" { + path = "/" + } + res.ContextURL = srvURL(target, path, true) + res.Source = "srv-txt" + } + + if res.ContextURL == "" && res.Error == "" { + res.Error = "could not resolve a context URL via /.well-known or SRV" + } + return res +} + +// followWellKnown issues a GET against path and follows up to 5 redirects +// manually so we can capture the redirect chain. The well-known endpoint +// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we +// want to flag, and 404 means the site-owner forgot to set it up. +func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) { + cur := u + for i := 0; i < 5; i++ { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil) + if reqErr != nil { + return "", chain, firstCode, reqErr + } + // Use a no-redirect client snapshot so we observe each hop. + c := *client + c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + resp, doErr := c.Do(req) + if doErr != nil { + return "", chain, firstCode, doErr + } + resp.Body.Close() + chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur)) + // We track the *first* response code because the rule cares about + // whether /.well-known itself redirected. A chain like 301→200 is + // correct; a chain starting with 200 is the misconfig we flag. + if i == 0 { + firstCode = resp.StatusCode + } + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if loc == "" { + return "", chain, firstCode, errors.New("redirect with empty Location header") + } + next, parseErr := resolveLocation(cur, loc) + if parseErr != nil { + return "", chain, firstCode, parseErr + } + cur = next + continue + } + + if resp.StatusCode == http.StatusOK { + return cur, chain, firstCode, nil + } + + return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return "", chain, firstCode, errors.New("too many redirects") +} + +func resolveLocation(base, loc string) (string, error) { + baseURL, err := url.Parse(base) + if err != nil { + return "", err + } + locURL, err := url.Parse(loc) + if err != nil { + return "", err + } + return baseURL.ResolveReference(locURL).String(), nil +} + +func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) { + resolver := net.DefaultResolver + + secure, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain) + if err != nil && !isNoSuchHost(err) { + res.SRVError = err.Error() + } + res.SecureSRV = secure + + plain, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain) + if err != nil && !isNoSuchHost(err) && res.SRVError == "" { + res.SRVError = err.Error() + } + res.PlaintextSRV = plain + + // Pull the TXT path hint from whichever SRV target we plan to use. + var txtName string + if len(res.SecureSRV) > 0 { + txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target) + } else if len(res.PlaintextSRV) > 0 { + txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target) + } + if txtName != "" { + txts, err := resolver.LookupTXT(ctx, txtName) + if err != nil && !isNoSuchHost(err) { + res.TXTError = err.Error() + } + for _, t := range txts { + if strings.HasPrefix(t, "path=") { + res.TXTPath = strings.TrimPrefix(t, "path=") + break + } + } + } +} + +func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) { + _, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name) + if err != nil { + return nil, err + } + out := make([]SRVRecord, 0, len(addrs)) + for _, a := range addrs { + out = append(out, SRVRecord{ + Target: trimTrailingDot(a.Target), + Port: a.Port, + Priority: a.Priority, + Weight: a.Weight, + }) + } + return out, nil +} + +func srvURL(r SRVRecord, path string, secure bool) string { + scheme := "https" + defaultPort := uint16(443) + if !secure { + scheme = "http" + defaultPort = 80 + } + host := r.Target + if r.Port != defaultPort { + host = fmt.Sprintf("%s:%d", r.Target, r.Port) + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return scheme + "://" + host + path +} + +func trimTrailingDot(s string) string { + return strings.TrimSuffix(s, ".") +} + +func isNoSuchHost(err error) bool { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return dnsErr.IsNotFound + } + return false +} diff --git a/internal/dav/discover_test.go b/internal/dav/discover_test.go new file mode 100644 index 0000000..0124171 --- /dev/null +++ b/internal/dav/discover_test.go @@ -0,0 +1,111 @@ +package dav + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestDiscover_wellKnownRedirect walks the happy path: /.well-known/caldav +// returns a 301 to the real context URL. +func TestDiscover_wellKnownRedirect(t *testing.T) { + var hits []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits = append(hits, r.URL.Path) + if r.URL.Path == "/.well-known/caldav" { + http.Redirect(w, r, "/dav/", http.StatusMovedPermanently) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + // Route "example.test/.well-known/caldav" through the test server. + c := srv.Client() + c.Transport = rewriteTransport{base: srv.URL, next: c.Transport} + + res := Discover(context.Background(), c, KindCalDAV, "example.test", "") + if res.Source != "well-known" { + t.Errorf("source = %q, want well-known", res.Source) + } + if !strings.HasSuffix(res.ContextURL, "/dav/") { + t.Errorf("context URL = %q", res.ContextURL) + } + if res.WellKnownCode != 301 { + t.Errorf("expected 301 captured, got %d", res.WellKnownCode) + } + if len(res.WellKnownChain) < 1 { + t.Error("expected redirect chain to be recorded") + } +} + +// TestDiscover_wellKnownReturns200 reproduces the most common misconfig: the +// server returns 200 on /.well-known/caldav instead of redirecting. Discover +// must still set ContextURL (to the well-known URL) but WellKnownCode=200 so +// the rule can emit the warning callout. +func TestDiscover_wellKnownReturns200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + c := srv.Client() + c.Transport = rewriteTransport{base: srv.URL, next: c.Transport} + + res := Discover(context.Background(), c, KindCardDAV, "example.test", "") + if res.WellKnownCode != 200 { + t.Errorf("well-known code = %d, want 200", res.WellKnownCode) + } + if res.ContextURL == "" { + t.Error("expected ContextURL to fall back to the well-known URL") + } +} + +func TestDiscover_explicitOverride(t *testing.T) { + res := Discover(context.Background(), http.DefaultClient, KindCalDAV, "example.test", "https://custom.example/dav/") + if res.Source != "explicit" { + t.Errorf("source: %q", res.Source) + } + if res.ContextURL != "https://custom.example/dav/" { + t.Errorf("ctx: %q", res.ContextURL) + } + if res.WellKnownURL != "" { + t.Errorf("should not have probed well-known, got %q", res.WellKnownURL) + } +} + +func TestDiscover_redirectLoop(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always redirect to itself → triggers "too many redirects". + http.Redirect(w, r, r.URL.Path, http.StatusFound) + })) + t.Cleanup(srv.Close) + + c := srv.Client() + c.Transport = rewriteTransport{base: srv.URL, next: c.Transport} + + res := Discover(context.Background(), c, KindCalDAV, "example.test", "") + if res.WellKnownError == "" { + t.Error("expected well-known error, got none") + } +} + +// rewriteTransport rewrites any request URL's host to point at base so we can +// exercise Discover() without setting up DNS. It preserves the original path. +type rewriteTransport struct { + base string + next http.RoundTripper +} + +func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { + baseURL, _ := parseURL(r.base) + req.URL.Scheme = baseURL.Scheme + req.URL.Host = baseURL.Host + next := r.next + if next == nil { + next = http.DefaultTransport + } + return next.RoundTrip(req) +} diff --git a/internal/dav/endpoints.go b/internal/dav/endpoints.go new file mode 100644 index 0000000..7e57fcb --- /dev/null +++ b/internal/dav/endpoints.go @@ -0,0 +1,89 @@ +package dav + +import ( + "net/url" + "strconv" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// DiscoverEndpoints derives TLS endpoints worth handing off to downstream +// checkers (notably the dedicated TLS checker) from a completed Observation. +// +// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, so +// we emit a single `tls` entry for the resolved context URL's host:port. If +// the endpoint was reached via SRV, we also surface each SRV target as its +// own endpoint — those are the names operators actually need certificates on, +// and they may differ from the queried domain. +func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint { + if obs == nil || obs.Discovery.ContextURL == "" { + return nil + } + var out []sdk.DiscoveredEndpoint + seen := map[string]struct{}{} + + add := func(host string, port uint16, sni string) { + if host == "" || port == 0 { + return + } + key := host + ":" + strconv.Itoa(int(port)) + if _, dup := seen[key]; dup { + return + } + seen[key] = struct{}{} + ep := sdk.DiscoveredEndpoint{ + Type: "tls", + Host: host, + Port: port, + } + if sni != "" && sni != host { + ep.SNI = sni + } + out = append(out, ep) + } + + // Primary endpoint: the resolved context URL. + if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok { + add(host, port, "") + } + + // Secondary endpoints: every TLS SRV target. Clients may connect to any + // of them per weight/priority, and all of them need a valid certificate. + for _, r := range obs.Discovery.SecureSRV { + port := r.Port + if port == 0 { + port = 443 + } + add(r.Target, port, "") + } + + return out +} + +// hostPortFromURL extracts the (host, port) pair from an absolute URL. The +// port defaults to 443 for https and 80 for http. Returns ok=false for +// malformed URLs so callers can silently skip them. +func hostPortFromURL(raw string) (host string, port uint16, ok bool) { + u, err := url.Parse(raw) + if err != nil { + return "", 0, false + } + host = u.Hostname() + if host == "" { + return "", 0, false + } + if p := u.Port(); p != "" { + n, convErr := strconv.ParseUint(p, 10, 16) + if convErr != nil { + return "", 0, false + } + return host, uint16(n), true + } + switch u.Scheme { + case "https": + return host, 443, true + case "http": + return host, 80, true + } + return "", 0, false +} diff --git a/internal/dav/endpoints_test.go b/internal/dav/endpoints_test.go new file mode 100644 index 0000000..8f1d5e1 --- /dev/null +++ b/internal/dav/endpoints_test.go @@ -0,0 +1,64 @@ +package dav + +import "testing" + +func TestDiscoverEndpoints_contextURLOnly(t *testing.T) { + obs := &Observation{ + Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"}, + } + got := DiscoverEndpoints(obs) + if len(got) != 1 { + t.Fatalf("got %d endpoints, want 1: %+v", len(got), got) + } + if got[0].Host != "dav.example.com" || got[0].Port != 443 || got[0].Type != "tls" { + t.Errorf("unexpected endpoint: %+v", got[0]) + } +} + +func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) { + obs := &Observation{ + Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"}, + } + got := DiscoverEndpoints(obs) + if len(got) != 1 || got[0].Port != 8443 { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestDiscoverEndpoints_srvTargets(t *testing.T) { + // SRV pointing to a different name than the domain → we must surface + // the SRV target too, because that's the hostname the cert needs to + // cover. + obs := &Observation{ + Discovery: DiscoveryResult{ + ContextURL: "https://dav.example.com/caldav/", + SecureSRV: []SRVRecord{ + {Target: "dav-backend-1.example.net", Port: 443}, + {Target: "dav-backend-2.example.net", Port: 443}, + {Target: "dav.example.com", Port: 443}, // duplicate of context → deduped + }, + }, + } + got := DiscoverEndpoints(obs) + if len(got) != 3 { + t.Fatalf("expected 3 unique endpoints, got %d: %+v", len(got), got) + } + hosts := map[string]bool{} + for _, e := range got { + hosts[e.Host] = true + } + for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} { + if !hosts[want] { + t.Errorf("missing host %q in %+v", want, got) + } + } +} + +func TestDiscoverEndpoints_emptyOnNoContextURL(t *testing.T) { + if got := DiscoverEndpoints(&Observation{}); got != nil { + t.Errorf("expected nil, got %+v", got) + } + if got := DiscoverEndpoints(nil); got != nil { + t.Errorf("expected nil for nil obs, got %+v", got) + } +} diff --git a/internal/dav/options.go b/internal/dav/options.go new file mode 100644 index 0000000..ebb3d91 --- /dev/null +++ b/internal/dav/options.go @@ -0,0 +1,101 @@ +package dav + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV +// headers. A missing DAV: header, or one that does not contain the kind's +// required capability, is not treated as a transport error here — the caller +// rule decides severity from the parsed values. +func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) { + res := OptionsResult{} + req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil) + if err != nil { + res.Error = err.Error() + return res, err + } + resp, err := client.Do(req) + if err != nil { + res.Error = err.Error() + return res, err + } + defer resp.Body.Close() + + res.StatusCode = resp.StatusCode + res.Server = resp.Header.Get("Server") + res.DAVClasses = parseCSVHeader(resp.Header.Values("Dav")) + res.AllowMethods = parseCSVHeader(resp.Header.Values("Allow")) + + for _, h := range resp.Header.Values("Www-Authenticate") { + if scheme := authScheme(h); scheme != "" { + res.AuthSchemes = appendUnique(res.AuthSchemes, scheme) + } + } + + if res.StatusCode >= 400 { + res.Error = fmt.Sprintf("OPTIONS returned HTTP %d", res.StatusCode) + } + return res, nil +} + +// HasCapability returns true when the OPTIONS response advertised cap in the +// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1. +func (o OptionsResult) HasCapability(cap string) bool { + for _, c := range o.DAVClasses { + if strings.EqualFold(c, cap) { + return true + } + } + return false +} + +// AllowsMethod returns true when the OPTIONS response's Allow: listed m. +func (o OptionsResult) AllowsMethod(m string) bool { + for _, a := range o.AllowMethods { + if strings.EqualFold(a, m) { + return true + } + } + return false +} + +// parseCSVHeader splits one or more header values on commas, trims, and drops +// empties. Multiple headers of the same name (net/http preserves them) are +// merged. +func parseCSVHeader(values []string) []string { + var out []string + for _, v := range values { + for _, part := range strings.Split(v, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + } + return out +} + +// authScheme returns the scheme token from a WWW-Authenticate header value +// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed. +func authScheme(h string) string { + h = strings.TrimSpace(h) + if h == "" { + return "" + } + if i := strings.IndexAny(h, " \t"); i > 0 { + return h[:i] + } + return h +} + +func appendUnique(s []string, v string) []string { + for _, x := range s { + if strings.EqualFold(x, v) { + return s + } + } + return append(s, v) +} diff --git a/internal/dav/options_shared.go b/internal/dav/options_shared.go new file mode 100644 index 0000000..abf6c13 --- /dev/null +++ b/internal/dav/options_shared.go @@ -0,0 +1,61 @@ +package dav + +// CommonOptions returns the CheckerOptionField descriptors shared by both +// CalDAV and CardDAV checkers. Each checker composes them into its own +// CheckerOptionsDocumentation alongside protocol-specific tweaks. +// +// Returning plain CheckerOptionField slices (from the SDK) keeps this package +// free of any sdk import cycles: the caller already depends on the SDK. +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// UserOptions are editable in the checker settings UI. +func UserOptions() []sdk.CheckerOptionDocumentation { + return []sdk.CheckerOptionDocumentation{ + { + Id: "username", + Type: "string", + Label: "Username", + Description: "Optional. Supplying credentials unlocks authenticated checks (principal, home-set, collections, report probe).", + }, + { + Id: "password", + Type: "string", + Label: "Password or token", + Description: "Optional. Paired with the username for HTTP Basic authentication.", + Secret: true, + }, + { + Id: "context_url", + Type: "string", + Label: "Explicit context URL", + Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.", + Placeholder: "https://dav.example.com/caldav/", + }, + } +} + +// DomainOptions are auto-filled per-domain by happyDomain. +func DomainOptions() []sdk.CheckerOptionDocumentation { + return []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Domain name", + AutoFill: sdk.AutoFillDomainName, + }, + } +} + +// RunOptions are set at collect-time only. +func RunOptions() []sdk.CheckerOptionDocumentation { + return []sdk.CheckerOptionDocumentation{ + { + Id: "timeout_seconds", + Type: "number", + Label: "Timeout (seconds)", + Description: "Per-request HTTP timeout.", + Default: float64(10), + }, + } +} diff --git a/internal/dav/options_test.go b/internal/dav/options_test.go new file mode 100644 index 0000000..becbd7d --- /dev/null +++ b/internal/dav/options_test.go @@ -0,0 +1,115 @@ +package dav + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProbeOptions_parsesHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodOptions { + t.Fatalf("expected OPTIONS, got %s", r.Method) + } + w.Header().Set("DAV", "1, 2, calendar-access, calendar-schedule") + w.Header().Set("Allow", "OPTIONS, PROPFIND, REPORT, PUT") + w.Header().Set("Server", "TestSrv/1.0") + w.Header().Add("WWW-Authenticate", `Basic realm="test"`) + w.Header().Add("WWW-Authenticate", `Digest realm="test", nonce="abc"`) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("ProbeOptions: %v", err) + } + if !res.HasCapability("calendar-access") { + t.Errorf("expected calendar-access in %v", res.DAVClasses) + } + if !res.HasCapability("CALENDAR-SCHEDULE") { + t.Errorf("case-insensitive match failed for calendar-schedule") + } + if !res.AllowsMethod("REPORT") || !res.AllowsMethod("PROPFIND") { + t.Errorf("expected REPORT and PROPFIND in %v", res.AllowMethods) + } + if len(res.AuthSchemes) != 2 { + t.Errorf("expected 2 auth schemes, got %v", res.AuthSchemes) + } + if res.Server != "TestSrv/1.0" { + t.Errorf("Server header: %q", res.Server) + } +} + +func TestProbeOptions_missingDAVHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("unexpected transport error: %v", err) + } + if res.HasCapability("calendar-access") { + t.Error("expected capability absent") + } + if len(res.DAVClasses) != 0 { + t.Errorf("expected empty DAV classes, got %v", res.DAVClasses) + } +} + +func TestProbeOptions_errorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + + res, err := ProbeOptions(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("transport err: %v", err) + } + if res.StatusCode != 503 { + t.Errorf("status: %d", res.StatusCode) + } + if res.Error == "" { + t.Error("expected Error to be set for 503") + } +} + +func TestParseCSVHeader_mergeAndTrim(t *testing.T) { + got := parseCSVHeader([]string{"1, 2 ,calendar-access", " calendar-schedule"}) + want := []string{"1", "2", "calendar-access", "calendar-schedule"} + if !equalSlices(got, want) { + t.Errorf("got %v want %v", got, want) + } +} + +func TestAuthScheme(t *testing.T) { + cases := map[string]string{ + `Basic realm="x"`: "Basic", + "Bearer": "Bearer", + `Digest realm="r", nonce="n"`: "Digest", + "": "", + " ": "", + } + for in, want := range cases { + if got := authScheme(in); got != want { + t.Errorf("authScheme(%q) = %q, want %q", in, got, want) + } + } +} + +func equalSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !strings.EqualFold(a[i], b[i]) { + return false + } + } + return true +} diff --git a/internal/dav/principal.go b/internal/dav/principal.go new file mode 100644 index 0000000..62cf26d --- /dev/null +++ b/internal/dav/principal.go @@ -0,0 +1,161 @@ +package dav + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" +) + +// FindPrincipal issues a PROPFIND for {DAV:}current-user-principal against +// contextURL. Callers should attach credentials to client; a 401/403 bubbles +// up as the returned error. +func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) { + body := ` + + +` + hrefs, err := propFind(ctx, client, contextURL, "0", body) + if err != nil { + return "", err + } + for _, href := range hrefs.principalHref() { + return resolveReference(contextURL, href), nil + } + return "", fmt.Errorf("no current-user-principal returned") +} + +// FindScheduleURLs looks up {urn:ietf:params:xml:ns:caldav}schedule-inbox-URL +// and schedule-outbox-URL on the CalDAV principal URL. CalDAV-only. +func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL string) (inbox, outbox string, err error) { + body := ` + + + + + +` + resp, err := propFind(ctx, client, principalURL, "0", body) + if err != nil { + return "", "", err + } + for _, r := range resp.Response { + for _, ps := range r.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + for _, p := range ps.Prop.Props { + switch p.XMLName.Local { + case "schedule-inbox-URL": + if h := p.firstHref(); h != "" { + inbox = resolveReference(principalURL, h) + } + case "schedule-outbox-URL": + if h := p.firstHref(); h != "" { + outbox = resolveReference(principalURL, h) + } + } + } + } + } + return inbox, outbox, nil +} + +// ── raw PROPFIND ───────────────────────────────────────────────────────────── + +// multistatus is the subset of the DAV:multistatus XML schema we need to read +// principal URLs and scheduling hrefs. It is intentionally permissive — extra +// elements are ignored, which makes us tolerant of server-specific extensions. +type multistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Response []msResponse `xml:"response"` +} + +type msResponse struct { + Href string `xml:"href"` + Propstat []propstat `xml:"propstat"` +} + +type propstat struct { + Prop prop `xml:"prop"` + Status string `xml:"status"` +} + +type prop struct { + Props []msProp `xml:",any"` +} + +type msProp struct { + XMLName xml.Name + Hrefs []string `xml:"href"` + // A prop may also contain nested , + // which the flat Hrefs slice above captures via xml:"href" descent. +} + +func (p msProp) firstHref() string { + if len(p.Hrefs) > 0 { + return p.Hrefs[0] + } + return "" +} + +func (m *multistatus) principalHref() []string { + var out []string + for _, r := range m.Response { + for _, ps := range r.Propstat { + if !strings.Contains(ps.Status, "200") { + continue + } + for _, pr := range ps.Prop.Props { + if pr.XMLName.Local == "current-user-principal" { + if h := pr.firstHref(); h != "" { + out = append(out, h) + } + } + } + } + } + return out +} + +// propFind is a small PROPFIND helper tuned for small single-resource probes. +// It returns a parsed multistatus; transport-level failures bubble up as err. +func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) { + req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + req.Header.Set("Depth", depth) + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode) + } + var ms multistatus + if err := xml.Unmarshal(data, &ms); err != nil { + return nil, fmt.Errorf("invalid multistatus: %w", err) + } + return &ms, nil +} + +func resolveReference(base, ref string) string { + r, err := parseURL(ref) + if err != nil { + return ref + } + b, err := parseURL(base) + if err != nil { + return ref + } + return b.ResolveReference(r).String() +} diff --git a/internal/dav/report.go b/internal/dav/report.go new file mode 100644 index 0000000..91d639b --- /dev/null +++ b/internal/dav/report.go @@ -0,0 +1,429 @@ +package dav + +import ( + "fmt" + "html/template" + "strings" +) + +// RenderReport turns an Observation into a self-contained HTML document. +// +// The report foregrounds action items for the failure modes we see most often +// (well-known misconfig, missing DAV capability, missing credentials) before +// showing the full per-phase evidence. +func RenderReport(obs *Observation, title string) (string, error) { + data := buildReportData(obs, title) + var buf strings.Builder + if err := reportTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("render dav report: %w", err) + } + return buf.String(), nil +} + +type reportData struct { + Title string + Domain string + Verdict string + VerdictCls string + Callouts []calloutData + Phases []phaseData + Raw string + ShowSched bool + Scheduling *SchedulingResult +} + +type calloutData struct { + Title string + Body string + Severity string // "warn" or "crit" +} + +type phaseData struct { + Title string + Items []phaseItem + Open bool +} + +type phaseItem struct { + Label string + Status string // "ok", "warn", "fail", "unk", "info" + Detail string + Mono string +} + +func buildReportData(o *Observation, title string) reportData { + d := reportData{ + Title: title, + Domain: o.Domain, + ShowSched: o.Kind == KindCalDAV, + Scheduling: o.Scheduling, + } + d.Callouts = buildCallouts(o) + d.Phases = buildPhases(o) + + switch { + case hasSeverity(d.Phases, "fail"): + d.Verdict = "Critical issues detected" + d.VerdictCls = "fail" + case hasSeverity(d.Phases, "warn"): + d.Verdict = "Minor issues detected" + d.VerdictCls = "warn" + case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"): + d.Verdict = "Could not evaluate without credentials" + d.VerdictCls = "unk" + default: + d.Verdict = "All checks passed" + d.VerdictCls = "ok" + } + return d +} + +func hasSeverity(phases []phaseData, sev string) bool { + for _, p := range phases { + for _, it := range p.Items { + if it.Status == sev { + return true + } + } + } + return false +} + +// buildCallouts surfaces the most common misconfigurations at the top of the +// report so operators don't have to read the full phase tree to find the fix. +func buildCallouts(o *Observation) []calloutData { + var out []calloutData + disc := o.Discovery + if disc.WellKnownCode == 200 && disc.Source != "explicit" { + out = append(out, calloutData{ + Severity: "warn", + Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL), + Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)), + }) + } + if disc.ContextURL == "" { + out = append(out, calloutData{ + Severity: "crit", + Title: "Service discovery failed", + Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain), + }) + } + if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 { + out = append(out, calloutData{ + Severity: "warn", + Title: "Plaintext SRV record without HTTPS counterpart", + Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)), + }) + } + if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) { + out = append(out, calloutData{ + Severity: "crit", + Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()), + Body: fmt.Sprintf("The DAV: response header is %q — this endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind), + }) + } + if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) { + out = append(out, calloutData{ + Severity: "warn", + Title: "Authenticated checks were skipped", + Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.", + }) + } + return out +} + +func exampleContextURL(k Kind) string { + switch k { + case KindCalDAV: + return "/dav/calendars/" + case KindCardDAV: + return "/dav/addressbooks/" + } + return "/dav/" +} + +func buildPhases(o *Observation) []phaseData { + var phases []phaseData + + // Phase 1 — Discovery + discovery := phaseData{Title: "Discovery"} + discovery.Items = append(discovery.Items, itemFor( + "/.well-known redirect", + wellKnownStatus(o.Discovery), + o.Discovery.WellKnownError, + summariseChain(o.Discovery.WellKnownChain), + )) + discovery.Items = append(discovery.Items, itemFor( + fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)), + srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError), + o.Discovery.SRVError, + summariseSRV(o.Discovery.SecureSRV), + )) + if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" { + discovery.Items = append(discovery.Items, itemFor( + fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)), + plainSRVStatus(o.Discovery.PlaintextSRV), + "", + summariseSRV(o.Discovery.PlaintextSRV), + )) + } + if o.Discovery.TXTPath != "" { + discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath)) + } + discovery.Items = append(discovery.Items, itemFor( + "Context URL", + contextStatus(o.Discovery.ContextURL), + "", + o.Discovery.ContextURL, + )) + discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail") + phases = append(phases, discovery) + + // Phase 2 — Transport + OPTIONS + transport := phaseData{Title: "Transport & OPTIONS"} + transport.Items = append(transport.Items, + itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""), + itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")), + itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")), + ) + if len(o.Options.AuthSchemes) > 0 { + transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", "))) + } + if o.Options.Server != "" { + transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server)) + } + transport.Open = hasItemSeverity(transport.Items, "warn", "fail") + phases = append(phases, transport) + + // Phase 3 — Authenticated + auth := phaseData{Title: "Authenticated probes"} + auth.Items = append(auth.Items, + authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error), + authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error), + collectionsItemFor(o.Collections, o.Kind), + reportItemFor(o.Report), + ) + auth.Open = hasItemSeverity(auth.Items, "warn", "fail") + phases = append(phases, auth) + + // Phase 4 — Scheduling (CalDAV only) + if o.Kind == KindCalDAV && o.Scheduling != nil { + sched := phaseData{Title: "Scheduling (CalDAV)"} + if !o.Scheduling.Advertised { + sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised")) + } else { + sched.Items = append(sched.Items, + itemFor("calendar-schedule advertised", "ok", "", "advertised"), + authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error), + authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""), + ) + } + sched.Open = hasItemSeverity(sched.Items, "warn", "fail") + phases = append(phases, sched) + } + + return phases +} + +// ── small helpers used by buildPhases ──────────────────────────────────────── + +func wellKnownStatus(d DiscoveryResult) string { + if d.Source == "explicit" { + return "info" + } + if d.WellKnownCode == 200 { + return "warn" + } + if d.WellKnownCode >= 300 && d.WellKnownCode < 400 { + return "ok" + } + return "fail" +} + +func srvStatus(rec []SRVRecord, errStr string) string { + if len(rec) > 0 { + return "ok" + } + if errStr != "" { + return "fail" + } + return "warn" +} + +func plainSRVStatus(rec []SRVRecord) string { + if len(rec) > 0 { + return "warn" // plaintext SRV is legacy / discouraged + } + return "ok" +} + +func contextStatus(u string) string { + if u == "" { + return "fail" + } + return "ok" +} + +func davStatus(o *Observation, r OptionsResult) string { + if r.HasCapability(o.Kind.RequiredCapability()) { + return "ok" + } + return "fail" +} + +func methodsStatus(r OptionsResult) string { + if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") { + return "ok" + } + return "warn" +} + +func boolStatus(ok bool, failSev string) string { + if ok { + return "ok" + } + return failSev +} + +func authItemFor(label, value string, skipped bool, errStr string) phaseItem { + switch { + case skipped: + return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"} + case errStr != "": + return phaseItem{Label: label, Status: "fail", Detail: errStr} + case value == "": + return phaseItem{Label: label, Status: "warn", Detail: "not returned"} + default: + return phaseItem{Label: label, Status: "ok", Mono: value} + } +} + +func collectionsItemFor(c CollectionsResult, k Kind) phaseItem { + label := "Calendars" + if k == KindCardDAV { + label = "Address books" + } + switch { + case c.Skipped: + return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"} + case c.Error != "": + return phaseItem{Label: label, Status: "fail", Detail: c.Error} + case len(c.Items) == 0: + return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"} + default: + names := make([]string, 0, len(c.Items)) + for _, it := range c.Items { + n := it.Name + if n == "" { + n = it.Path + } + names = append(names, n) + } + return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")} + } +} + +func reportItemFor(r ReportResult) phaseItem { + switch { + case r.Skipped: + return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"} + case r.Error != "": + return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error} + case !r.QueryOK: + return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"} + default: + return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath} + } +} + +func itemFor(label, status, errStr, mono string) phaseItem { + it := phaseItem{Label: label, Status: status, Mono: mono} + if errStr != "" { + it.Detail = errStr + } + return it +} + +func hasItemSeverity(items []phaseItem, sevs ...string) bool { + for _, it := range items { + for _, s := range sevs { + if it.Status == s { + return true + } + } + } + return false +} + +func summariseChain(chain []string) string { + return strings.Join(chain, " → ") +} + +func summariseSRV(rec []SRVRecord) string { + if len(rec) == 0 { + return "" + } + parts := make([]string, 0, len(rec)) + for _, r := range rec { + parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight)) + } + return strings.Join(parts, "; ") +} + +// ── template ───────────────────────────────────────────────────────────────── + +var reportTemplate = template.Must(template.New("dav").Parse(` + + + + +{{.Title}} Report + + + + +
+

{{.Title}}

+ {{.Verdict}} + {{if .Domain}}
Domain: {{.Domain}}
{{end}} +
+ +{{if .Callouts}} +
+ {{range .Callouts}} +
+

{{.Title}}

+

{{.Body}}

+
+ {{end}} +
+{{end}} + +{{range .Phases}} + + {{.Title}} +
+ + {{range .Items}} + + + + + + {{end}} +
+ {{if eq .Status "ok"}} + {{else if eq .Status "warn"}} + {{else if eq .Status "fail"}} + {{else if eq .Status "unk"}}? + {{else}}i{{end}} + {{.Label}} + {{if .Mono}}{{.Mono}}{{end}} + {{if .Detail}}
{{.Detail}}
{{end}} +
+
+ +{{end}} + + +`)) diff --git a/internal/dav/report_css.go b/internal/dav/report_css.go new file mode 100644 index 0000000..9fc8a55 --- /dev/null +++ b/internal/dav/report_css.go @@ -0,0 +1,105 @@ +package dav + +// ReportCSS is the shared stylesheet embedded in both checkers' HTML reports. +// Lifted (with minor edits) from checker-matrix so the whole happyDomain +// checker fleet has a consistent visual language. +const ReportCSS = ` +*, *::before, *::after { box-sizing: border-box; } +:root { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + line-height: 1.5; + color: #1f2937; + background: #f3f4f6; +} +body { margin: 0; padding: 1rem; } +code { font-family: ui-monospace, monospace; font-size: .9em; } +h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; } +h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; } +h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; } + +.hd { + background: #fff; + border-radius: 10px; + padding: 1rem 1.25rem; + margin-bottom: .75rem; + box-shadow: 0 1px 3px rgba(0,0,0,.08); +} +.verdict { color: #4b5563; margin-top: .35rem; font-size: .9rem; } + +.badge { + display: inline-flex; align-items: center; + padding: .2em .65em; + border-radius: 9999px; + font-size: .78rem; font-weight: 700; + letter-spacing: .02em; +} +.ok { background: #d1fae5; color: #065f46; } +.warn { background: #fef3c7; color: #92400e; } +.fail { background: #fee2e2; color: #991b1b; } +.unk { background: #e5e7eb; color: #374151; } +.info { background: #dbeafe; color: #1e40af; } + +.section { + background: #fff; + border-radius: 8px; + padding: .85rem 1rem; + margin-bottom: .6rem; + box-shadow: 0 1px 3px rgba(0,0,0,.07); +} + +.callouts { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; } +.callout { + background: #fff7ed; + border-left: 4px solid #f97316; + border-radius: 6px; + padding: .7rem .9rem; + box-shadow: 0 1px 3px rgba(0,0,0,.06); +} +.callout.crit { background: #fef2f2; border-color: #dc2626; } +.callout h3 { margin: 0 0 .2rem; } +.callout p { margin: .15rem 0; font-size: .88rem; color: #374151; } + +details { + background: #fff; + border-radius: 8px; + margin-bottom: .45rem; + box-shadow: 0 1px 3px rgba(0,0,0,.07); + overflow: hidden; +} +summary { + display: flex; align-items: center; gap: .5rem; + padding: .65rem 1rem; + cursor: pointer; + user-select: none; + list-style: none; +} +summary::-webkit-details-marker { display: none; } +summary::before { + content: "▶"; + font-size: .65rem; + color: #9ca3af; + transition: transform .15s; + flex-shrink: 0; +} +details[open] > summary::before { transform: rotate(90deg); } +.phase-title { flex: 1; font-weight: 600; } +.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; } + +table { border-collapse: collapse; width: 100%; font-size: .85rem; } +th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; } +th { font-weight: 600; color: #6b7280; } + +.check-ok { color: #059669; font-weight: 700; } +.check-warn { color: #d97706; font-weight: 700; } +.check-fail { color: #dc2626; font-weight: 700; } +.check-unk { color: #6b7280; font-weight: 700; } + +.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; } +.note { color: #6b7280; font-size: .85rem; } + +ul { margin: .25rem 0; padding-left: 1.2rem; } +li { margin-bottom: .15rem; } + +pre { background: #f9fafb; padding: .5rem; border-radius: 4px; overflow-x: auto; font-size: .8rem; } +` diff --git a/internal/dav/rules.go b/internal/dav/rules.go new file mode 100644 index 0000000..36e1a5b --- /dev/null +++ b/internal/dav/rules.go @@ -0,0 +1,314 @@ +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)} +} diff --git a/internal/dav/types.go b/internal/dav/types.go new file mode 100644 index 0000000..6b6d069 --- /dev/null +++ b/internal/dav/types.go @@ -0,0 +1,168 @@ +// Package dav holds the code shared between the CalDAV and CardDAV checkers: +// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and +// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in +// their own packages and compose these helpers. +package dav + +import "time" + +// Kind distinguishes the two protocol flavours. A single Kind value is carried +// end-to-end through a checker run so shared helpers can pick the right +// service names, well-known paths, and required DAV classes. +type Kind string + +const ( + KindCalDAV Kind = "caldav" + KindCardDAV Kind = "carddav" +) + +// ServiceName returns the RFC 6764 SRV service label for kind, with the +// leading "_" but without the "_tcp" suffix. +func (k Kind) ServiceName(secure bool) string { + switch k { + case KindCalDAV: + if secure { + return "_caldavs" + } + return "_caldav" + case KindCardDAV: + if secure { + return "_carddavs" + } + return "_carddav" + } + return "" +} + +// WellKnownPath returns the RFC 6764 well-known path for kind. +func (k Kind) WellKnownPath() string { + return "/.well-known/" + string(k) +} + +// RequiredCapability is the string that must appear in the DAV: response +// header for the server to qualify as a valid implementation. +func (k Kind) RequiredCapability() string { + switch k { + case KindCalDAV: + return "calendar-access" + case KindCardDAV: + return "addressbook" + } + return "" +} + +// Observation is the root data structure persisted by either checker. The +// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left +// zero-valued for KindCardDAV. +type Observation struct { + Kind Kind `json:"kind"` + Domain string `json:"domain"` + HasCredentials bool `json:"has_credentials"` + Discovery DiscoveryResult `json:"discovery"` + Transport TransportResult `json:"transport"` + Options OptionsResult `json:"options"` + Principal PrincipalResult `json:"principal"` + HomeSet HomeSetResult `json:"home_set"` + Collections CollectionsResult `json:"collections"` + Report ReportResult `json:"report"` + Scheduling *SchedulingResult `json:"scheduling,omitempty"` + CollectedAt time.Time `json:"collected_at"` +} + +// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer. +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` +} + +// DiscoveryResult captures every signal we gathered while locating the +// service: SRV secure/plaintext, TXT path hints, well-known redirects, and +// the ultimately-resolved context URL. +type DiscoveryResult struct { + SecureSRV []SRVRecord `json:"secure_srv,omitempty"` + PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"` + SRVError string `json:"srv_error,omitempty"` + TXTPath string `json:"txt_path,omitempty"` + TXTError string `json:"txt_error,omitempty"` + WellKnownURL string `json:"well_known_url,omitempty"` + WellKnownCode int `json:"well_known_code,omitempty"` + WellKnownChain []string `json:"well_known_chain,omitempty"` + WellKnownError string `json:"well_known_error,omitempty"` + ContextURL string `json:"context_url,omitempty"` + Source string `json:"source,omitempty"` // "explicit", "well-known", "srv-txt" + Error string `json:"error,omitempty"` +} + +// TransportResult records whether the resolved context URL accepts HTTPS +// requests. TLS certificate validation is out of scope (a dedicated TLS +// checker covers it); we only report the raw transport-level error if any. +type TransportResult struct { + Reached bool `json:"reached"` + Error string `json:"error,omitempty"` +} + +// OptionsResult captures the response to an OPTIONS request against the +// context URL: which DAV: classes are advertised, which HTTP methods are in +// Allow:, and which authentication schemes the server offered. +type OptionsResult struct { + StatusCode int `json:"status_code"` + DAVClasses []string `json:"dav_classes,omitempty"` + AllowMethods []string `json:"allow_methods,omitempty"` + AuthSchemes []string `json:"auth_schemes,omitempty"` + Server string `json:"server,omitempty"` + Error string `json:"error,omitempty"` +} + +// PrincipalResult holds the `current-user-principal` URL discovered after +// authenticating. Skipped is set to true when no credentials were supplied +// (the rule surfaces this as StatusUnknown). +type PrincipalResult struct { + Skipped bool `json:"skipped,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV +// `addressbook-home-set` URL for the authenticated principal. +type HomeSetResult struct { + Skipped bool `json:"skipped,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +// CollectionInfo describes a single discovered calendar or addressbook. +type CollectionInfo struct { + Path string `json:"path"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + MaxResourceSize int64 `json:"max_resource_size,omitempty"` + SupportedComponentSet []string `json:"supported_component_set,omitempty"` // CalDAV only + SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only +} + +// CollectionsResult is the enumerated content of the home-set. +type CollectionsResult struct { + Skipped bool `json:"skipped,omitempty"` + Items []CollectionInfo `json:"items,omitempty"` + Error string `json:"error,omitempty"` +} + +// ReportResult is the outcome of a minimal REPORT probe against the first +// collection found (empty calendar-query/addressbook-query). +type ReportResult struct { + Skipped bool `json:"skipped,omitempty"` + QueryOK bool `json:"query_ok,omitempty"` + ProbePath string `json:"probe_path,omitempty"` + Error string `json:"error,omitempty"` +} + +// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server +// advertises the `calendar-schedule` capability. +type SchedulingResult struct { + Advertised bool `json:"advertised"` + InboxURL string `json:"inbox_url,omitempty"` + OutboxURL string `json:"outbox_url,omitempty"` + Error string `json:"error,omitempty"` +} diff --git a/internal/dav/util.go b/internal/dav/util.go new file mode 100644 index 0000000..c7149cc --- /dev/null +++ b/internal/dav/util.go @@ -0,0 +1,7 @@ +package dav + +import "net/url" + +// parseURL is an alias so principal.go can use net/url without importing it +// directly (keeping imports tidy across files). +func parseURL(raw string) (*url.URL, error) { return url.Parse(raw) } diff --git a/plugin/caldav/plugin.go b/plugin/caldav/plugin.go new file mode 100644 index 0000000..522d088 --- /dev/null +++ b/plugin/caldav/plugin.go @@ -0,0 +1,16 @@ +// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV +// checker. Built with `go build -buildmode=plugin` and loaded at runtime by +// happyDomain. +package main + +import ( + "git.happydns.org/checker-dav/caldav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + caldav.Version = Version + return caldav.Definition(), caldav.Provider(), nil +} diff --git a/plugin/carddav/plugin.go b/plugin/carddav/plugin.go new file mode 100644 index 0000000..98a008e --- /dev/null +++ b/plugin/carddav/plugin.go @@ -0,0 +1,16 @@ +// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV +// checker. Built with `go build -buildmode=plugin` and loaded at runtime by +// happyDomain. +package main + +import ( + "git.happydns.org/checker-dav/carddav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + carddav.Version = Version + return carddav.Definition(), carddav.Provider(), nil +}