commit 7d5535fddf78ce7935a07bef03733ef3186648f8 Author: Pierre-Olivier Mercier Date: Sun Apr 19 13:44:10 2026 +0700 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4c2b8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +checker-caldav +checker-caldav.so +checker-carddav +checker-carddav.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f19718 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +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 +USER 65534:65534 +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..58a20f0 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-dav +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..3ebfc8e --- /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..5033c61 --- /dev/null +++ b/caldav/collect.go @@ -0,0 +1,142 @@ +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 is intentionally resilient: each phase records its outcome and we +// keep going as long as the next phase has something to work with. Rules +// later turn 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, obs.Discovery.ContextURL, 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 + + 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 + + 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, + }) + } + } + + // Empty calendar-query against the first calendar: cheapest probe that + // still exercises the REPORT pipeline end-to-end. + 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 + } + + 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 +} + +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..67654c0 --- /dev/null +++ b/caldav/definition.go @@ -0,0 +1,41 @@ +package caldav + +import ( + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is overridden at link time by the standalone binary via -ldflags. +var Version = "built-in" + +func (p *caldavProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "caldav", + Name: "CalDAV server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + // Service scope keeps downstream TLS alerts on a dedicated + // "CalDAV" page rather than the domain page. abstract.CalDAV + // isn't in the catalog yet, so this is a no-op until it is. + ApplyToService: true, + LimitToServices: []string{"abstract.CalDAV"}, + }, + 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..b01a9fb --- /dev/null +++ b/caldav/discovery.go @@ -0,0 +1,16 @@ +package caldav + +import ( + "fmt" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + obs, ok := data.(*dav.Observation) + if !ok { + return nil, fmt.Errorf("unexpected data type %T", data) + } + return dav.DiscoverEntries(obs), nil +} diff --git a/caldav/provider.go b/caldav/provider.go new file mode 100644 index 0000000..1b6f8b0 --- /dev/null +++ b/caldav/provider.go @@ -0,0 +1,25 @@ +package caldav + +import ( + "net/http" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider's return value also satisfies CheckerDefinitionProvider, +// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for +// those at runtime. +func Provider() sdk.ObservationProvider { + return &caldavProvider{} +} + +type caldavProvider struct{} + +func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey } + +func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() } + +func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + return dav.ParseInteractiveForm(r) +} diff --git a/caldav/report.go b/caldav/report.go new file mode 100644 index 0000000..6cbdfef --- /dev/null +++ b/caldav/report.go @@ -0,0 +1,22 @@ +package caldav + +import ( + "encoding/json" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" + + "git.happydns.org/checker-dav/internal/dav" +) + +// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV +// produce visually identical reports. Downstream TLS probes attached via +// ctx.Related(dav.TLSRelatedKey) are folded in. +func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var d dav.Observation + if err := json.Unmarshal(ctx.Data(), &d); err != nil { + return "", fmt.Errorf("failed to unmarshal caldav report: %w", err) + } + d.Kind = dav.KindCalDAV + return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey)) +} diff --git a/caldav/types.go b/caldav/types.go new file mode 100644 index 0000000..d139b69 --- /dev/null +++ b/caldav/types.go @@ -0,0 +1,9 @@ +// Package caldav wires the CalDAV-specific options, collect pipeline, +// rules, and HTML report on top of the shared helpers in internal/dav. +package caldav + +import "git.happydns.org/checker-dav/internal/dav" + +const ObservationKey = "caldav" + +type Data = dav.Observation diff --git a/carddav/collect.go b/carddav/collect.go new file mode 100644 index 0000000..188dca4 --- /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, obs.Discovery.ContextURL, 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..e1535b8 --- /dev/null +++ b/carddav/definition.go @@ -0,0 +1,39 @@ +package carddav + +import ( + "time" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "built-in" + +func (p *carddavProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "carddav", + Name: "CardDAV server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToDomain: true, + // See caldav/definition.go for the rationale; abstract.CardDAV + // isn't in the catalog yet, so this is a no-op until it is. + ApplyToService: true, + LimitToServices: []string{"abstract.CardDAV"}, + }, + 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..ba11060 --- /dev/null +++ b/carddav/discovery.go @@ -0,0 +1,16 @@ +package carddav + +import ( + "fmt" + + "git.happydns.org/checker-dav/internal/dav" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + obs, ok := data.(*dav.Observation) + if !ok { + return nil, fmt.Errorf("unexpected data type %T", data) + } + return dav.DiscoverEntries(obs), nil +} diff --git a/carddav/provider.go b/carddav/provider.go new file mode 100644 index 0000000..aa4ddc4 --- /dev/null +++ b/carddav/provider.go @@ -0,0 +1,20 @@ +package carddav + +import ( + "net/http" + + "git.happydns.org/checker-dav/internal/dav" + 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) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() } + +func (p *carddavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + return dav.ParseInteractiveForm(r) +} diff --git a/carddav/report.go b/carddav/report.go new file mode 100644 index 0000000..6c40948 --- /dev/null +++ b/carddav/report.go @@ -0,0 +1,20 @@ +package carddav + +import ( + "encoding/json" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" + + "git.happydns.org/checker-dav/internal/dav" +) + +// GetHTMLReport: see the CalDAV sibling. +func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { + var d dav.Observation + if err := json.Unmarshal(ctx.Data(), &d); err != nil { + return "", fmt.Errorf("failed to unmarshal carddav report: %w", err) + } + d.Kind = dav.KindCardDAV + return dav.RenderReport(&d, "CardDAV Server", ctx.Related(dav.TLSRelatedKey)) +} diff --git a/carddav/types.go b/carddav/types.go new file mode 100644 index 0000000..d02fb74 --- /dev/null +++ b/carddav/types.go @@ -0,0 +1,9 @@ +// Package carddav: see the CalDAV sibling. The two share everything except +// the protocol-specific home-set and REPORT calls 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..dc9ae7d --- /dev/null +++ b/cmd/checker-caldav/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-dav/caldav" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// 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 + srv := server.New(caldav.Provider()) + if err := srv.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..721e84b --- /dev/null +++ b/cmd/checker-carddav/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-dav/carddav" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// 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 + srv := server.New(carddav.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b8fb22a --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module git.happydns.org/checker-dav + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.3.0 + git.happydns.org/checker-tls v0.6.2 +) + +require ( + github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect + github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // 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..4797434 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= +git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= +git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +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..2142c3b --- /dev/null +++ b/internal/dav/client.go @@ -0,0 +1,47 @@ +package dav + +import ( + "net/http" + "net/url" + "strings" + "time" +) + +// NewHTTPClient uses Go's default TLS validation; cert correctness is the +// dedicated TLS checker's job, not ours. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + } +} + +// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect +// to a different host won't leak credentials to a third party. Matches +// curl's behaviour without --location-trusted. +type basicAuthRoundTripper struct { + user, pass string + host string + next http.RoundTripper +} + +func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if strings.EqualFold(req.URL.Host, b.host) { + req.SetBasicAuth(b.user, b.pass) + } + return b.next.RoundTrip(req) +} + +// WithBasicAuth attaches credentials scoped to the host of contextURL. +func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client { + nc := *c + base := c.Transport + if base == nil { + base = http.DefaultTransport + } + host := "" + if u, err := url.Parse(contextURL); err == nil { + host = u.Host + } + nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, host: host, next: base} + return &nc +} diff --git a/internal/dav/discover.go b/internal/dav/discover.go new file mode 100644 index 0000000..5cdb6e3 --- /dev/null +++ b/internal/dav/discover.go @@ -0,0 +1,209 @@ +package dav + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded +// in the result even on failure so the report can pinpoint the broken step. +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 + } + + // Always probe /.well-known even if SRV would suffice: it's the #1 + // misconfig hotspot and we want to surface it. + 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" + } + + 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 follows up to 5 redirects manually so we can record the +// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200 +// at this position is the misconfig we want to flag. +func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) { + chain = make([]string, 0, 5) + cur := u + for i := 0; i < 5; i++ { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil) + if reqErr != nil { + return "", chain, firstCode, reqErr + } + // Snapshot disables the client's own redirect-following so we can + // record each hop ourselves. + 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)) + 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 + + type srvResult struct { + records []SRVRecord + err error + } + secureCh := make(chan srvResult, 1) + plainCh := make(chan srvResult, 1) + go func() { + r, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain) + secureCh <- srvResult{r, err} + }() + go func() { + r, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain) + plainCh <- srvResult{r, err} + }() + + secureRes := <-secureCh + if secureRes.err != nil && !isNoSuchHost(secureRes.err) { + res.SRVError = secureRes.err.Error() + } + res.SecureSRV = secureRes.records + + plainRes := <-plainCh + if plainRes.err != nil && !isNoSuchHost(plainRes.err) && res.SRVError == "" { + res.SRVError = plainRes.err.Error() + } + res.PlaintextSRV = plainRes.records + + 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..a93f9d2 --- /dev/null +++ b/internal/dav/discover_test.go @@ -0,0 +1,112 @@ +package dav + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "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, _ := url.Parse(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..0190f2d --- /dev/null +++ b/internal/dav/endpoints.go @@ -0,0 +1,85 @@ +package dav + +import ( + "log" + "net/url" + "strconv" + + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets +// are emitted alongside the context URL because they're the names operators +// must actually put on the certificate, and they often differ from the +// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120 +// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split. +func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry { + if obs == nil || obs.Discovery.ContextURL == "" { + return nil + } + var out []sdk.DiscoveryEntry + seen := map[string]struct{}{} + + add := func(host string, port uint16) { + if host == "" || port == 0 { + return + } + key := host + ":" + strconv.Itoa(int(port)) + if _, dup := seen[key]; dup { + return + } + seen[key] = struct{}{} + entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{ + Host: host, + Port: port, + SNI: host, + }) + if err != nil { + log.Printf("checker-dav: contract.NewEntry(%s:%d): %v", host, port, err) + return + } + out = append(out, entry) + } + + if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok { + add(host, port) + } + + // Every SRV target is reachable via priority/weight, so each one needs + // its own valid certificate. + for _, r := range obs.Discovery.SecureSRV { + port := r.Port + if port == 0 { + port = 443 + } + add(r.Target, port) + } + + return out +} + +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..a40c14e --- /dev/null +++ b/internal/dav/endpoints_test.go @@ -0,0 +1,98 @@ +package dav + +import ( + "testing" + + tlsct "git.happydns.org/checker-tls/contract" +) + +// parseAll decodes DiscoverEntries output via the TLS contract. Malformed +// entries fail the test so we notice drift quickly. +func parseAll(t *testing.T, obs *Observation) []tlsct.TLSEndpoint { + t.Helper() + entries := DiscoverEntries(obs) + eps, warnings := tlsct.ParseEntries(entries) + if len(warnings) != 0 { + t.Fatalf("unexpected decode warnings: %v", warnings) + } + out := make([]tlsct.TLSEndpoint, len(eps)) + for i, e := range eps { + if e.Ref == "" { + t.Errorf("entry %d has empty Ref", i) + } + out[i] = e.Endpoint + } + return out +} + +func TestDiscoverEntries_contextURLOnly(t *testing.T) { + obs := &Observation{ + Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"}, + } + got := parseAll(t, 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 { + t.Errorf("unexpected endpoint: %+v", got[0]) + } + // Direct TLS; no STARTTLS upgrade. + if got[0].STARTTLS != "" { + t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS) + } + // SNI must be set unconditionally, even when it is equal to Host. + if got[0].SNI != "dav.example.com" { + t.Errorf("SNI = %q, want dav.example.com", got[0].SNI) + } +} + +func TestDiscoverEntries_nonDefaultPort(t *testing.T) { + obs := &Observation{ + Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"}, + } + got := parseAll(t, obs) + if len(got) != 1 || got[0].Port != 8443 { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestDiscoverEntries_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 := parseAll(t, 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 + if e.SNI != e.Host { + t.Errorf("endpoint %+v: SNI=%q, want %q (equal to Host)", e, e.SNI, e.Host) + } + } + 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 TestDiscoverEntries_emptyOnNoContextURL(t *testing.T) { + if got := DiscoverEntries(&Observation{}); got != nil { + t.Errorf("expected nil, got %+v", got) + } + if got := DiscoverEntries(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..b950444 --- /dev/null +++ b/internal/dav/options.go @@ -0,0 +1,94 @@ +package dav + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +// ProbeOptions never treats a missing/incomplete DAV: header as a transport +// error: severity is the caller rule's decision, not ours. +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 matches case-insensitively 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 +} + +func (o OptionsResult) AllowsMethod(m string) bool { + for _, a := range o.AllowMethods { + if strings.EqualFold(a, m) { + return true + } + } + return false +} + +// parseCSVHeader merges repeated headers (net/http keeps them separate) +// into a single split-and-trimmed slice. +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 +} + +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..31856fa --- /dev/null +++ b/internal/dav/options_shared.go @@ -0,0 +1,125 @@ +package dav + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +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/", + }, + } +} + +func DomainOptions() []sdk.CheckerOptionDocumentation { + return []sdk.CheckerOptionDocumentation{ + { + Id: "domain_name", + Label: "Domain name", + AutoFill: sdk.AutoFillDomainName, + }, + } +} + +func RunOptions() []sdk.CheckerOptionDocumentation { + return []sdk.CheckerOptionDocumentation{ + { + Id: "timeout_seconds", + Type: "number", + Label: "Timeout (seconds)", + Description: "Per-request HTTP timeout.", + Default: float64(10), + }, + } +} + +// InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the +// standalone /check page. Discovery happens inside Collect, so all the +// human owes us is the domain. +func InteractiveForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain_name", + Type: "string", + Label: "Domain name", + Placeholder: "example.com", + Required: true, + }, + { + Id: "username", + Type: "string", + Label: "Username", + Description: "Optional. Supplying credentials unlocks authenticated checks.", + }, + { + Id: "password", + Type: "string", + Label: "Password or token", + Description: "Optional. Paired with the username for HTTP Basic auth.", + Secret: true, + }, + { + Id: "context_url", + Type: "string", + Label: "Explicit context URL", + Description: "Optional. Bypasses /.well-known and SRV discovery.", + Placeholder: "https://dav.example.com/caldav/", + }, + { + Id: "timeout_seconds", + Type: "number", + Label: "Timeout (seconds)", + Description: "Per-request HTTP timeout.", + Default: float64(10), + }, + } +} + +func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain_name")) + if domain == "" { + return nil, errors.New("domain name is required") + } + + opts := sdk.CheckerOptions{"domain_name": domain} + if v := strings.TrimSpace(r.FormValue("username")); v != "" { + opts["username"] = v + } + if v := r.FormValue("password"); v != "" { + opts["password"] = v + } + if v := strings.TrimSpace(r.FormValue("context_url")); v != "" { + opts["context_url"] = v + } + if v := strings.TrimSpace(r.FormValue("timeout_seconds")); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil, errors.New("timeout must be a number") + } + opts["timeout_seconds"] = f + } + return opts, nil +} diff --git a/internal/dav/options_test.go b/internal/dav/options_test.go new file mode 100644 index 0000000..6acbbcc --- /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..e2f6075 --- /dev/null +++ b/internal/dav/principal.go @@ -0,0 +1,154 @@ +package dav + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// FindPrincipal requires authenticated credentials on client. +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") +} + +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 +} + +// multistatus is intentionally a permissive subset: unknown elements are +// ignored so server-specific extensions don't break parsing. +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"` +} + +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 tuned for small single-resource probes; not for large listings. +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() + // 10 MiB cap: probes here read a handful of props on one resource; more + // is either misbehaviour or an attempt at memory exhaustion. + data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + 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 := url.Parse(ref) + if err != nil { + return ref + } + b, err := url.Parse(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..eef0a89 --- /dev/null +++ b/internal/dav/report.go @@ -0,0 +1,459 @@ +package dav + +import ( + "fmt" + "html/template" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderReport foregrounds the high-frequency failure modes (well-known +// misconfig, missing DAV class, missing credentials, downstream TLS issues) +// before the full per-phase evidence. tlsRelated is what the host stitched +// from checker-tls; nil simply omits the TLS section. +func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) { + data := buildReportData(obs, title, tlsRelated) + 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 + TLSSummaries []TLSSummary +} + +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, tlsRelated []sdk.RelatedObservation) reportData { + d := reportData{ + Title: title, + Domain: o.Domain, + ShowSched: o.Kind == KindCalDAV, + Scheduling: o.Scheduling, + } + d.Callouts = buildCallouts(o) + d.Phases = buildPhases(o) + + tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated) + d.TLSSummaries = tlsSummaries + for _, c := range tlsCallouts { + d.Callouts = append(d.Callouts, calloutData{ + Severity: c.Severity, + Title: c.Title, + Body: c.Body, + }) + } + if len(tlsSummaries) > 0 { + d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries)) + } + + 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 pulls common misconfigurations to the top so operators +// don't have to expand the 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 +} + +// buildTLSPhase auto-opens when anything is non-OK so the failure is +// visible without an extra click. +func buildTLSPhase(summaries []TLSSummary) phaseData { + p := phaseData{Title: "TLS (from checker-tls)"} + for _, s := range summaries { + label := s.Address + if s.TLSVersion != "" { + label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion) + } + p.Items = append(p.Items, phaseItem{ + Label: label, + Status: s.Status, + Detail: s.Detail, + }) + } + p.Open = hasItemSeverity(p.Items, "warn", "fail") + return p +} + +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, "; ") +} + +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..c5d107a --- /dev/null +++ b/internal/dav/rules.go @@ -0,0 +1,322 @@ +package dav + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules omits scheduling for CardDAV (CalDAV-only). +func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule { + rules := []sdk.CheckRule{ + &discoveryRule{obsKey: obsKey}, + &transportRule{obsKey: obsKey}, + &optionsRule{obsKey: obsKey, kind: kind}, + &principalRule{obsKey: obsKey}, + &homeSetRule{obsKey: obsKey}, + &collectionsRule{obsKey: obsKey, kind: kind}, + &reportRule{obsKey: obsKey}, + } + if kind == KindCalDAV { + rules = append(rules, &schedulingRule{obsKey: obsKey}) + } + return rules +} + +// WorstStatus picks the highest-severity state. Unknown only wins if every +// rule was Unknown. +type WorstStatus struct{} + +func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { + if len(states) == 0 { + return sdk.CheckState{Status: sdk.StatusUnknown, Message: "no rules evaluated"} + } + ranks := map[sdk.Status]int{ + sdk.StatusOK: 1, + sdk.StatusInfo: 2, + sdk.StatusUnknown: 3, + sdk.StatusWarn: 4, + sdk.StatusCrit: 5, + sdk.StatusError: 6, + } + worst := states[0] + worstRank := ranks[worst.Status] + var msgs []string + for _, s := range states { + if r := ranks[s.Status]; r > worstRank { + worstRank = r + worst = s + } + if s.Message != "" { + msgs = append(msgs, s.Message) + } + } + out := sdk.CheckState{Status: worst.Status, Code: "aggregate"} + out.Message = strings.Join(msgs, "; ") + return out +} + +type baseRule struct { + obsKey sdk.ObservationKey +} + +func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observation, sdk.CheckState) { + var d Observation + if err := obs.Get(ctx, r.obsKey, &d); err != nil { + return nil, sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load observation: %v", err), + Code: "observation_missing", + } + } + return &d, sdk.CheckState{} +} + +// discoveryRule surfaces the #1 user-facing misconfig: a missing or +// non-redirect /.well-known. +type discoveryRule struct{ obsKey sdk.ObservationKey } + +func (r *discoveryRule) Name() string { return "dav_discovery" } +func (r *discoveryRule) Description() string { return "Service discovery via /.well-known and SRV" } +func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + disc := d.Discovery + if disc.ContextURL == "" { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "discovery_failed", + Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)", + }} + } + // /.well-known=200 is legal but discouraged; many clients won't follow + // it. Warn, don't crit. + if disc.WellKnownCode == 200 && disc.Source != "explicit" { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "well_known_not_redirect", + Message: fmt.Sprintf("%s returned 200 instead of a 301/302 redirect", disc.WellKnownURL), + }} + } + if disc.Source == "srv-txt" && disc.WellKnownError != "" { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "well_known_missing", + Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError), + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "discovery_ok", + Message: fmt.Sprintf("context URL %s (via %s)", disc.ContextURL, disc.Source), + }} +} + +// transportRule covers reachability only; cert specifics are out of scope. +type transportRule struct{ obsKey sdk.ObservationKey } + +func (r *transportRule) Name() string { return "dav_transport" } +func (r *transportRule) Description() string { return "HTTPS connection to the context URL" } +func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + if !d.Transport.Reached { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "transport_failed", + Message: fmt.Sprintf("HTTPS connection failed: %s", d.Transport.Error), + }} + } + return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}} +} + +type optionsRule struct { + obsKey sdk.ObservationKey + kind Kind +} + +func (r *optionsRule) Name() string { return "dav_options" } +func (r *optionsRule) Description() string { + return "HTTP OPTIONS advertises the required DAV capability" +} +func (r *optionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + o := d.Options + if o.Error != "" && len(o.DAVClasses) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "options_failed", + Message: fmt.Sprintf("OPTIONS request failed: %s", o.Error), + }} + } + cap := r.kind.RequiredCapability() + if !o.HasCapability(cap) { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Code: "capability_missing", + Message: fmt.Sprintf("server does not advertise %q in DAV: header (got %v)", cap, o.DAVClasses), + }} + } + if !o.AllowsMethod("PROPFIND") || !o.AllowsMethod("REPORT") { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "methods_missing", + Message: fmt.Sprintf("Allow: header missing PROPFIND or REPORT (got %v)", o.AllowMethods), + }} + } + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Code: "options_ok", + Message: fmt.Sprintf("DAV: %s", strings.Join(o.DAVClasses, ", ")), + }} +} + +type principalRule struct{ obsKey sdk.ObservationKey } + +func (r *principalRule) Name() string { return "dav_principal" } +func (r *principalRule) Description() string { return "Principal URL discovery (authenticated)" } +func (r *principalRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + p := d.Principal + if p.Skipped { + return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "principal_skipped", Message: "no credentials supplied"}} + } + if p.Error != "" { + return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "principal_failed", Message: p.Error}} + } + return []sdk.CheckState{{Status: sdk.StatusOK, Code: "principal_ok", Message: p.URL}} +} + +type homeSetRule struct{ obsKey sdk.ObservationKey } + +func (r *homeSetRule) Name() string { return "dav_home_set" } +func (r *homeSetRule) Description() string { return "Home-set discovered from the principal" } +func (r *homeSetRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + h := d.HomeSet + if h.Skipped { + return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "home_set_skipped", Message: "no credentials supplied"}} + } + if h.Error != "" { + return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "home_set_failed", Message: h.Error}} + } + return []sdk.CheckState{{Status: sdk.StatusOK, Code: "home_set_ok", Message: h.URL}} +} + +type collectionsRule struct { + obsKey sdk.ObservationKey + kind Kind +} + +func (r *collectionsRule) Name() string { return "dav_collections" } +func (r *collectionsRule) Description() string { + return "Calendar/addressbook collections enumerate and expose required properties" +} +func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + c := d.Collections + if c.Skipped { + return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "collections_skipped", Message: "no credentials supplied"}} + } + if c.Error != "" { + return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "collections_failed", Message: c.Error}} + } + if len(c.Items) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "collections_empty", + Message: "home-set is empty; the account has no calendars/addressbooks", + }} + } + out := make([]sdk.CheckState, 0, len(c.Items)) + for _, it := range c.Items { + msg := it.Name + if msg == "" { + msg = it.Path + } + if r.kind == KindCalDAV && len(it.SupportedComponentSet) > 0 { + msg = fmt.Sprintf("%s (components: %s)", msg, strings.Join(it.SupportedComponentSet, ", ")) + } else if r.kind == KindCardDAV && len(it.SupportedAddressData) > 0 { + msg = fmt.Sprintf("%s (address data: %s)", msg, strings.Join(it.SupportedAddressData, ", ")) + } + out = append(out, sdk.CheckState{ + Status: sdk.StatusOK, + Code: "collection_ok", + Subject: it.Path, + Message: msg, + }) + } + return out +} + +type reportRule struct{ obsKey sdk.ObservationKey } + +func (r *reportRule) Name() string { return "dav_report" } +func (r *reportRule) Description() string { return "Server accepts a minimal REPORT query" } +func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + rep := d.Report + if rep.Skipped { + return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "report_skipped", Message: "no credentials supplied or no collection to probe"}} + } + if rep.Error != "" { + return []sdk.CheckState{{Status: sdk.StatusCrit, Code: "report_failed", Message: rep.Error, Subject: rep.ProbePath}} + } + if !rep.QueryOK { + return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "report_query_not_ok", Message: "REPORT query returned an unexpected response", Subject: rep.ProbePath}} + } + return []sdk.CheckState{{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath), Subject: rep.ProbePath}} +} + +// schedulingRule is CalDAV-only. +type schedulingRule struct{ obsKey sdk.ObservationKey } + +func (r *schedulingRule) Name() string { return "caldav_scheduling" } +func (r *schedulingRule) Description() string { + return "Scheduling inbox/outbox present when calendar-schedule is advertised" +} +func (r *schedulingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs) + if d == nil { + return []sdk.CheckState{errState} + } + s := d.Scheduling + if s == nil || !s.Advertised { + return []sdk.CheckState{{Status: sdk.StatusInfo, Code: "scheduling_not_advertised", Message: "server does not advertise calendar-schedule"}} + } + if d.Principal.Skipped { + return []sdk.CheckState{{Status: sdk.StatusUnknown, Code: "scheduling_skipped", Message: "no credentials supplied"}} + } + if s.Error != "" { + return []sdk.CheckState{{Status: sdk.StatusWarn, Code: "scheduling_probe_failed", Message: s.Error}} + } + if s.InboxURL == "" || s.OutboxURL == "" { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "scheduling_urls_missing", + Message: "calendar-schedule advertised but schedule-inbox-URL/schedule-outbox-URL missing", + }} + } + return []sdk.CheckState{{Status: sdk.StatusOK, Code: "scheduling_ok", Message: fmt.Sprintf("inbox=%s outbox=%s", s.InboxURL, s.OutboxURL)}} +} diff --git a/internal/dav/tls_related.go b/internal/dav/tls_related.go new file mode 100644 index 0000000..805743c --- /dev/null +++ b/internal/dav/tls_related.go @@ -0,0 +1,257 @@ +package dav + +import ( + "encoding/json" + "fmt" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey matches the cross-checker convention in +// happydomain3/docs/checker-discovery-endpoint.md. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView decodes only the fields we actually use; the full TLS schema +// belongs to checker-tls and we don't want to track its evolution here. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + + // Alternative shape used by the reference checker-tls payload sketched + // in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}. + Cert *struct { + NotAfter time.Time `json:"notAfter,omitempty"` + SANMatch *bool `json:"sanMatch,omitempty"` + ChainValid *bool `json:"chainValid,omitempty"` + DaysRemaining *int `json:"daysRemaining,omitempty"` + SubjectCN string `json:"subjectCN,omitempty"` + IssuerCN string `json:"issuerCN,omitempty"` + } `json:"cert,omitempty"` + + Rules []struct { + Code string `json:"code,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + } `json:"rules,omitempty"` + + Issues []struct { + Code string `json:"code,omitempty"` + Severity string `json:"severity,omitempty"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +// certExpiry hides the two payload shapes from callers. +func (v *tlsProbeView) certExpiry() (time.Time, bool) { + if !v.NotAfter.IsZero() { + return v.NotAfter, true + } + if v.Cert != nil && !v.Cert.NotAfter.IsZero() { + return v.Cert.NotAfter, true + } + return time.Time{}, false +} + +func (v *tlsProbeView) hostnameOK() (bool, bool) { + if v.HostnameMatch != nil { + return *v.HostnameMatch, true + } + if v.Cert != nil && v.Cert.SANMatch != nil { + return *v.Cert.SANMatch, true + } + return false, false +} + +func (v *tlsProbeView) chainOK() (bool, bool) { + if v.ChainValid != nil { + return *v.ChainValid, true + } + if v.Cert != nil && v.Cert.ChainValid != nil { + return *v.Cert.ChainValid, true + } + return false, false +} + +// parseTLSRelated accepts both the keyed {"probes": {"": …}} shape +// (current checker-tls output, picked by r.Ref) and a bare top-level probe +// (legacy/test fixtures). Returns nil for anything else. +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var keyed struct { + Probes map[string]tlsProbeView `json:"probes"` + } + if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil { + if p, ok := keyed.Probes[r.Ref]; ok { + return &p + } + return nil + } + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +type TLSSummary struct { + Address string + TLSVersion string + Status string // "ok", "warn", "fail", "info" + Detail string + NotAfter time.Time + DaysRemaining int +} + +type tlsCallout struct { + Severity string // "warn" or "crit" + Title string + Body string +} + +func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) { + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + sum := buildTLSSummary(v) + summaries = append(summaries, sum) + callouts = append(callouts, buildTLSCallouts(v, sum.Address)...) + } + return summaries, callouts +} + +func buildTLSSummary(v *tlsProbeView) TLSSummary { + s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"} + + if t, ok := v.certExpiry(); ok { + s.NotAfter = t + days := int(time.Until(t) / (24 * time.Hour)) + if v.Cert != nil && v.Cert.DaysRemaining != nil { + days = *v.Cert.DaysRemaining + } + s.DaysRemaining = days + switch { + case days < 0: + s.Status = "fail" + s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days) + case days < 14: + s.Status = "warn" + s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days) + default: + s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days) + } + } + + if ok, has := v.hostnameOK(); has && !ok { + s.Status = "fail" + s.Detail = "certificate does not cover the endpoint hostname" + } + if ok, has := v.chainOK(); has && !ok { + s.Status = "fail" + s.Detail = "certificate chain validation failed" + } + + // Explicit issues from the TLS checker outrank our inferred status. + for _, iss := range v.Issues { + sev := strings.ToLower(iss.Severity) + switch sev { + case "crit": + s.Status = "fail" + case "warn": + if s.Status != "fail" { + s.Status = "warn" + } + } + if iss.Message != "" { + s.Detail = iss.Message + } + } + return s +} + +func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout { + var out []tlsCallout + + // Structured issues from the TLS checker are the preferred source. + for _, iss := range v.Issues { + sev := strings.ToLower(iss.Severity) + if sev != "crit" && sev != "warn" { + continue + } + callout := tlsCallout{ + Severity: sev, + Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)), + } + if callout.Title == "TLS on "+addr+": " { + callout.Title = "TLS issue on " + addr + } + if iss.Fix != "" { + callout.Body = iss.Fix + } else { + callout.Body = "See the TLS checker report for details." + } + out = append(out, callout) + } + if len(out) > 0 { + return out + } + + // Fallback: synthesize callouts from structured flags. + if t, ok := v.certExpiry(); ok { + days := int(time.Until(t) / (24 * time.Hour)) + if v.Cert != nil && v.Cert.DaysRemaining != nil { + days = *v.Cert.DaysRemaining + } + switch { + case days < 0: + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Certificate on %s has expired", addr), + Body: fmt.Sprintf("Renew it. Clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)), + }) + case days < 14: + out = append(out, tlsCallout{ + Severity: "warn", + Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days), + Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)), + }) + } + } + if ok, has := v.chainOK(); has && !ok { + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Broken certificate chain on %s", addr), + Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.", + }) + } + if ok, has := v.hostnameOK(); has && !ok { + out = append(out, tlsCallout{ + Severity: "crit", + Title: fmt.Sprintf("Certificate does not cover %s", addr), + Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.", + }) + } + return out +} diff --git a/internal/dav/tls_related_test.go b/internal/dav/tls_related_test.go new file mode 100644 index 0000000..5f39554 --- /dev/null +++ b/internal/dav/tls_related_test.go @@ -0,0 +1,82 @@ +package dav + +import ( + "encoding/json" + "strings" + "testing" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func relatedFrom(t *testing.T, payload any) sdk.RelatedObservation { + t.Helper() + b, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return sdk.RelatedObservation{Key: TLSRelatedKey, Data: b} +} + +func TestFoldTLSRelated_expiringCertProducesCallout(t *testing.T) { + exp := time.Now().Add(5 * 24 * time.Hour) + related := []sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", + "port": 443, + "not_after": exp, + })} + sums, callouts := foldTLSRelated(related) + + if len(sums) != 1 || sums[0].Address != "dav.example.com:443" || sums[0].Status != "warn" { + t.Fatalf("summary: %+v", sums) + } + if len(callouts) != 1 || callouts[0].Severity != "warn" { + t.Fatalf("expected a warn callout, got %+v", callouts) + } + if !strings.Contains(callouts[0].Title, "expires in") { + t.Errorf("callout title: %q", callouts[0].Title) + } +} + +func TestFoldTLSRelated_expiredCertCrit(t *testing.T) { + exp := time.Now().Add(-2 * 24 * time.Hour) + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, "not_after": exp, + })}) + if len(callouts) != 1 || callouts[0].Severity != "crit" { + t.Fatalf("expected crit for expired cert, got %+v", callouts) + } +} + +func TestFoldTLSRelated_chainInvalid(t *testing.T) { + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, "chain_valid": false, + })}) + if len(callouts) != 1 || callouts[0].Severity != "crit" { + t.Fatalf("expected crit for broken chain, got %+v", callouts) + } +} + +func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) { + _, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{ + "host": "dav.example.com", "port": 443, + "chain_valid": false, // would normally synthesize a callout + "issues": []map[string]any{ + {"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"}, + }, + })}) + // When explicit issues exist, we do not also emit synthesized callouts; + // the TLS checker is the source of truth for severity and wording. + if len(callouts) != 1 || callouts[0].Severity != "warn" { + t.Fatalf("want single warn callout, got %+v", callouts) + } + if !strings.Contains(callouts[0].Body, "disable TLS") { + t.Errorf("fix text lost: %q", callouts[0].Body) + } +} + +func TestFoldTLSRelated_empty(t *testing.T) { + if sums, callouts := foldTLSRelated(nil); sums != nil || callouts != nil { + t.Errorf("expected nil,nil on nil input, got %+v %+v", sums, callouts) + } +} diff --git a/internal/dav/types.go b/internal/dav/types.go new file mode 100644 index 0000000..507c726 --- /dev/null +++ b/internal/dav/types.go @@ -0,0 +1,149 @@ +// Package dav holds code shared by the CalDAV and CardDAV checkers: +// discovery, OPTIONS probing, PROPFIND helpers, and report rendering. +package dav + +import "time" + +// Kind is carried end-to-end through a run so shared helpers branch on it +// rather than duplicating per-protocol code. +type Kind string + +const ( + KindCalDAV Kind = "caldav" + KindCardDAV Kind = "carddav" +) + +// ServiceName returns the RFC 6764 SRV label, 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 "" +} + +func (k Kind) WellKnownPath() string { + return "/.well-known/" + string(k) +} + +// RequiredCapability is the DAV: header token a compliant server must +// advertise. +func (k Kind) RequiredCapability() string { + switch k { + case KindCalDAV: + return "calendar-access" + case KindCardDAV: + return "addressbook" + } + return "" +} + +// Observation is what each checker persists. Scheduling is CalDAV-only and +// left nil for CardDAV. +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"` +} + +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` +} + +// DiscoveryResult records every signal seen during lookup, even on failure, +// so the report can pinpoint which leg of discovery broke. +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 is intentionally minimal: cert validation is out of scope +// here, a dedicated TLS checker owns it. +type TransportResult struct { + Reached bool `json:"reached"` + Error string `json:"error,omitempty"` +} + +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.Skipped is set when no credentials were supplied; the +// rule turns that into StatusUnknown rather than a failure. +type PrincipalResult struct { + Skipped bool `json:"skipped,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +type HomeSetResult struct { + Skipped bool `json:"skipped,omitempty"` + URL string `json:"url,omitempty"` + Error string `json:"error,omitempty"` +} + +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 +} + +type CollectionsResult struct { + Skipped bool `json:"skipped,omitempty"` + Items []CollectionInfo `json:"items,omitempty"` + Error string `json:"error,omitempty"` +} + +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. +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/plugin/caldav/plugin.go b/plugin/caldav/plugin.go new file mode 100644 index 0000000..2b6b057 --- /dev/null +++ b/plugin/caldav/plugin.go @@ -0,0 +1,15 @@ +// 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 + prvd := caldav.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +} diff --git a/plugin/carddav/plugin.go b/plugin/carddav/plugin.go new file mode 100644 index 0000000..7b5f11e --- /dev/null +++ b/plugin/carddav/plugin.go @@ -0,0 +1,15 @@ +// 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 + prvd := carddav.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}