Initial commit

CalDAV and CardDAV checkers sharing a single Go module. Discovery follows
RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal,
home-set, collections and a minimal REPORT query on top of go-webdav.
Common shape in internal/dav/; CalDAV adds a scheduling rule.

Surfaces its context URL (and each secure-SRV target) as TLS endpoints via
the EndpointDiscoverer interface, so the dedicated TLS checker can pick
them up without re-parsing observations.

HTML report foregrounds common misconfigs (well-known returning 200,
missing SRV, plaintext-only SRV, missing DAV capability, skipped auth
phase) as action-item callouts before the full phase breakdown.
This commit is contained in:
nemunaire 2026-04-19 13:44:10 +07:00
commit aae1452e12
37 changed files with 2730 additions and 0 deletions

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
ARG TARGET=checker-caldav
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker ./cmd/${TARGET}
FROM scratch
COPY --from=builder /checker /checker
EXPOSE 8080
ENTRYPOINT ["/checker"]

21
LICENSE Normal file
View file

@ -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.

37
Makefile Normal file
View file

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

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-dummy
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

75
README.md Normal file
View file

@ -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

149
caldav/collect.go Normal file
View file

@ -0,0 +1,149 @@
package caldav
import (
"context"
"net/http"
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
webdav "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
)
// Collect runs the full CalDAV probe pipeline for the target domain.
//
// The pipeline is deliberately resilient: every phase records its outcome into
// the Observation and we keep going as long as we have something to probe
// with. Rules later translate the captured state into CheckStates.
func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
user, _ := sdk.GetOption[string](opts, "username")
pass, _ := sdk.GetOption[string](opts, "password")
explicit, _ := sdk.GetOption[string](opts, "context_url")
timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10)
timeout := time.Duration(timeoutSec * float64(time.Second))
if timeout <= 0 {
timeout = 10 * time.Second
}
obs := &dav.Observation{
Kind: dav.KindCalDAV,
Domain: domain,
HasCredentials: user != "" && pass != "",
CollectedAt: time.Now(),
Scheduling: &dav.SchedulingResult{},
}
anonClient := dav.NewHTTPClient(timeout)
// Phase 1 — Discovery
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit)
if obs.Discovery.ContextURL == "" {
return obs, nil
}
// Phase 2 — Transport + OPTIONS (no auth required)
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes
if err != nil {
obs.Transport = dav.TransportResult{Error: err.Error()}
return obs, nil
}
obs.Transport = dav.TransportResult{Reached: true}
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
// Phase 3 — Authenticated probes
if !obs.HasCredentials {
obs.Principal.Skipped = true
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
authClient := dav.WithBasicAuth(anonClient, user, pass)
// Principal.
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil {
obs.Principal.Error = err.Error()
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.Principal.URL = principal
// Home-set (via go-webdav's CalDAV client).
cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
home, err := cal.FindCalendarHomeSet(ctx, principal)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.HomeSet.URL = home
// Collections.
calendars, err := cal.FindCalendars(ctx, home)
if err != nil {
obs.Collections.Error = err.Error()
obs.Report.Skipped = true
} else {
for _, c := range calendars {
obs.Collections.Items = append(obs.Collections.Items, dav.CollectionInfo{
Path: c.Path,
Name: c.Name,
Description: c.Description,
MaxResourceSize: c.MaxResourceSize,
SupportedComponentSet: c.SupportedComponentSet,
})
}
}
// Report probe — empty calendar-query against the first calendar.
if len(obs.Collections.Items) > 0 {
first := obs.Collections.Items[0].Path
obs.Report.ProbePath = first
q := &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Comps: []caldav.CalendarCompRequest{
{Name: "VEVENT"},
},
},
}
if _, err := cal.QueryCalendar(ctx, first, q); err != nil {
obs.Report.Error = err.Error()
} else {
obs.Report.QueryOK = true
}
} else {
obs.Report.Skipped = true
}
// Scheduling inbox/outbox — only probe if advertised.
if obs.Scheduling.Advertised {
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
if err != nil {
obs.Scheduling.Error = err.Error()
}
obs.Scheduling.InboxURL = inbox
obs.Scheduling.OutboxURL = outbox
}
return obs, nil
}
// asHTTPClient adapts stdlib *http.Client to go-webdav's HTTPClient interface.
// The interface has a single Do method so the conversion is free.
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }

38
caldav/definition.go Normal file
View file

@ -0,0 +1,38 @@
package caldav
import (
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Overridden at link time by the standalone binary via -ldflags.
var Version = "built-in"
// Definition returns the CheckerDefinition for the CalDAV checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "caldav",
Name: "CalDAV server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: dav.UserOptions(),
DomainOpts: dav.DomainOptions(),
RunOpts: dav.RunOptions(),
},
Rules: dav.Rules(dav.KindCalDAV, ObservationKey),
Aggregator: dav.WorstStatus{},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 1 * time.Hour,
Default: 15 * time.Minute,
},
HasHTMLReport: true,
}
}

19
caldav/discovery.go Normal file
View file

@ -0,0 +1,19 @@
package caldav
import (
"fmt"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// DiscoverEndpoints implements sdk.EndpointDiscoverer. The SDK server calls
// this with the native Go value returned by Collect, so we just type-assert
// and delegate to the shared helper.
func (p *caldavProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) {
obs, ok := data.(*dav.Observation)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
return dav.DiscoverEndpoints(obs), nil
}

21
caldav/provider.go Normal file
View file

@ -0,0 +1,21 @@
package caldav
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns the CalDAV observation provider.
//
// The returned value implements sdk.ObservationProvider, plus the optional
// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer
// interfaces so the SDK's HTTP server exposes /definition, /evaluate,
// /report, and forwards discovered TLS endpoints to downstream checkers.
func Provider() sdk.ObservationProvider {
return &caldavProvider{}
}
type caldavProvider struct{}
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey }
func (p *caldavProvider) Definition() *sdk.CheckerDefinition { return Definition() }

22
caldav/report.go Normal file
View file

@ -0,0 +1,22 @@
package caldav
import (
"encoding/json"
"fmt"
"git.happydns.org/checker-dav/internal/dav"
)
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
//
// The actual rendering is delegated to the shared renderer in internal/dav so
// CalDAV and CardDAV produce visually identical reports; the only difference
// is the title and the set of phases rendered (CalDAV includes Scheduling).
func (p *caldavProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
var d dav.Observation
if err := json.Unmarshal(raw, &d); err != nil {
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
}
d.Kind = dav.KindCalDAV
return dav.RenderReport(&d, "CalDAV Server")
}

17
caldav/types.go Normal file
View file

@ -0,0 +1,17 @@
// Package checker (imported as caldav by the standalone binary) implements
// the CalDAV compliance and health checker for happyDomain.
//
// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting
// helpers live in git.happydns.org/checker-dav/internal/dav, so this package
// only wires the CalDAV-specific options, collect pipeline, rules, and HTML
// report together.
package caldav
import "git.happydns.org/checker-dav/internal/dav"
// ObservationKey identifies CalDAV observations in happyDomain's store.
const ObservationKey = "caldav"
// Data is the persisted observation shape. Callers read it back via
// obs.Get(ctx, ObservationKey, &Data).
type Data = dav.Observation

125
carddav/collect.go Normal file
View file

@ -0,0 +1,125 @@
package carddav
import (
"context"
"net/http"
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
webdav "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/carddav"
)
func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name")
user, _ := sdk.GetOption[string](opts, "username")
pass, _ := sdk.GetOption[string](opts, "password")
explicit, _ := sdk.GetOption[string](opts, "context_url")
timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10)
timeout := time.Duration(timeoutSec * float64(time.Second))
if timeout <= 0 {
timeout = 10 * time.Second
}
obs := &dav.Observation{
Kind: dav.KindCardDAV,
Domain: domain,
HasCredentials: user != "" && pass != "",
CollectedAt: time.Now(),
}
anonClient := dav.NewHTTPClient(timeout)
// Phase 1 — Discovery
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit)
if obs.Discovery.ContextURL == "" {
return obs, nil
}
// Phase 2 — OPTIONS
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes
if err != nil {
obs.Transport = dav.TransportResult{Error: err.Error()}
return obs, nil
}
obs.Transport = dav.TransportResult{Reached: true}
// Phase 3 — Authenticated
if !obs.HasCredentials {
obs.Principal.Skipped = true
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
authClient := dav.WithBasicAuth(anonClient, user, pass)
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil {
obs.Principal.Error = err.Error()
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.Principal.URL = principal
card, err := carddav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
home, err := card.FindAddressBookHomeSet(ctx, principal)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.HomeSet.URL = home
books, err := card.FindAddressBooks(ctx, home)
if err != nil {
obs.Collections.Error = err.Error()
obs.Report.Skipped = true
} else {
for _, b := range books {
item := dav.CollectionInfo{
Path: b.Path,
Name: b.Name,
Description: b.Description,
MaxResourceSize: b.MaxResourceSize,
}
for _, d := range b.SupportedAddressData {
item.SupportedAddressData = append(item.SupportedAddressData, d.ContentType+";"+d.Version)
}
obs.Collections.Items = append(obs.Collections.Items, item)
}
}
if len(obs.Collections.Items) > 0 {
first := obs.Collections.Items[0].Path
obs.Report.ProbePath = first
q := &carddav.AddressBookQuery{
DataRequest: carddav.AddressDataRequest{AllProp: true},
Limit: 1,
}
if _, err := card.QueryAddressBook(ctx, first, q); err != nil {
obs.Report.Error = err.Error()
} else {
obs.Report.QueryOK = true
}
} else {
obs.Report.Skipped = true
}
return obs, nil
}
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }

35
carddav/definition.go Normal file
View file

@ -0,0 +1,35 @@
package carddav
import (
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "carddav",
Name: "CardDAV server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: dav.UserOptions(),
DomainOpts: dav.DomainOptions(),
RunOpts: dav.RunOptions(),
},
Rules: dav.Rules(dav.KindCardDAV, ObservationKey),
Aggregator: dav.WorstStatus{},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 1 * time.Hour,
Default: 15 * time.Minute,
},
HasHTMLReport: true,
}
}

18
carddav/discovery.go Normal file
View file

@ -0,0 +1,18 @@
package carddav
import (
"fmt"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// DiscoverEndpoints implements sdk.EndpointDiscoverer. See the CalDAV sibling
// for the rationale — the shared helper produces the TLS endpoints.
func (p *carddavProvider) DiscoverEndpoints(data any) ([]sdk.DiscoveredEndpoint, error) {
obs, ok := data.(*dav.Observation)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
return dav.DiscoverEndpoints(obs), nil
}

12
carddav/provider.go Normal file
View file

@ -0,0 +1,12 @@
package carddav
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider { return &carddavProvider{} }
type carddavProvider struct{}
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey }
func (p *carddavProvider) Definition() *sdk.CheckerDefinition { return Definition() }

17
carddav/report.go Normal file
View file

@ -0,0 +1,17 @@
package carddav
import (
"encoding/json"
"fmt"
"git.happydns.org/checker-dav/internal/dav"
)
func (p *carddavProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
var d dav.Observation
if err := json.Unmarshal(raw, &d); err != nil {
return "", fmt.Errorf("failed to unmarshal carddav report: %w", err)
}
d.Kind = dav.KindCardDAV
return dav.RenderReport(&d, "CardDAV Server")
}

11
carddav/types.go Normal file
View file

@ -0,0 +1,11 @@
// Package checker (imported as carddav) implements the CardDAV compliance
// and health checker for happyDomain. See the CalDAV sibling for the shape;
// the two packages share ~everything except the protocol-specific home-set
// and REPORT calls, which live in collect.go.
package carddav
import "git.happydns.org/checker-dav/internal/dav"
const ObservationKey = "carddav"
type Data = dav.Observation

View file

@ -0,0 +1,23 @@
package main
import (
"flag"
"log"
"git.happydns.org/checker-dav/caldav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is injected at link time via -ldflags "-X main.Version=...".
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
caldav.Version = Version
server := sdk.NewServer(caldav.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

View file

@ -0,0 +1,23 @@
package main
import (
"flag"
"log"
"git.happydns.org/checker-dav/carddav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is injected at link time via -ldflags "-X main.Version=...".
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
carddav.Version = Version
server := sdk.NewServer(carddav.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module git.happydns.org/checker-dav
go 1.25.0
require git.happydns.org/checker-sdk-go v1.0.0
// Pinned to the local checkout until the EndpointDiscoverer interface
// (commit 131e3cd, 2026-04-19) ships in a tagged release.
replace git.happydns.org/checker-sdk-go => ../checker-sdk-go
require github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect
require (
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect
github.com/emersion/go-webdav v0.7.0
github.com/teambition/rrule-go v1.8.2 // indirect
)

10
go.sum Normal file
View file

@ -0,0 +1,10 @@
git.happydns.org/checker-sdk-go v1.0.0 h1:5u8vnvoH2KEbHtAqPu/Wh6xBQ8PWgle9iZ1j7HTZXd8=
git.happydns.org/checker-sdk-go v1.0.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=

39
internal/dav/client.go Normal file
View file

@ -0,0 +1,39 @@
package dav
import (
"net/http"
"time"
)
// NewHTTPClient returns an http.Client with a sane default transport for
// probing DAV servers. TLS certificate validation uses Go's default rules —
// dedicated TLS correctness belongs in a separate checker.
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
}
}
// basicAuthRoundTripper injects HTTP Basic credentials on every request so
// callers can pass the same client through go-webdav's own API without losing
// auth on internal redirects.
type basicAuthRoundTripper struct {
user, pass string
next http.RoundTripper
}
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.SetBasicAuth(b.user, b.pass)
return b.next.RoundTrip(req)
}
// WithBasicAuth clones c and attaches Basic credentials to the transport.
func WithBasicAuth(c *http.Client, user, pass string) *http.Client {
nc := *c
base := c.Transport
if base == nil {
base = http.DefaultTransport
}
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base}
return &nc
}

203
internal/dav/discover.go Normal file
View file

@ -0,0 +1,203 @@
package dav
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// Discover resolves the DAV context URL for domain following RFC 6764:
// /.well-known/{caldav,carddav} first (cheap and works for the common case),
// then SRV/TXT. An explicit override shortcuts everything.
//
// The returned Observation.Discovery is fully populated with whatever was
// learned along the way, even if every step fails — the report leans on the
// captured evidence to tell the user which leg of the discovery broke.
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
res := DiscoveryResult{}
if explicitURL != "" {
res.ContextURL = explicitURL
res.Source = "explicit"
return res
}
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
// it even if SRV below might have worked, to surface the mistake.
wellKnown := "https://" + domain + kind.WellKnownPath()
res.WellKnownURL = wellKnown
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
res.WellKnownCode = code
res.WellKnownChain = chain
if err != nil {
res.WellKnownError = err.Error()
} else if ctxURL != "" {
res.ContextURL = ctxURL
res.Source = "well-known"
}
// 2. SRV + TXT fallback (also informational even when well-known worked).
discoverSRV(ctx, kind, domain, &res)
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
target := res.SecureSRV[0]
path := res.TXTPath
if path == "" {
path = "/"
}
res.ContextURL = srvURL(target, path, true)
res.Source = "srv-txt"
}
if res.ContextURL == "" && res.Error == "" {
res.Error = "could not resolve a context URL via /.well-known or SRV"
}
return res
}
// followWellKnown issues a GET against path and follows up to 5 redirects
// manually so we can capture the redirect chain. The well-known endpoint
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we
// want to flag, and 404 means the site-owner forgot to set it up.
func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
cur := u
for i := 0; i < 5; i++ {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil)
if reqErr != nil {
return "", chain, firstCode, reqErr
}
// Use a no-redirect client snapshot so we observe each hop.
c := *client
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, doErr := c.Do(req)
if doErr != nil {
return "", chain, firstCode, doErr
}
resp.Body.Close()
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
// We track the *first* response code because the rule cares about
// whether /.well-known itself redirected. A chain like 301→200 is
// correct; a chain starting with 200 is the misconfig we flag.
if i == 0 {
firstCode = resp.StatusCode
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
return "", chain, firstCode, errors.New("redirect with empty Location header")
}
next, parseErr := resolveLocation(cur, loc)
if parseErr != nil {
return "", chain, firstCode, parseErr
}
cur = next
continue
}
if resp.StatusCode == http.StatusOK {
return cur, chain, firstCode, nil
}
return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return "", chain, firstCode, errors.New("too many redirects")
}
func resolveLocation(base, loc string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", err
}
locURL, err := url.Parse(loc)
if err != nil {
return "", err
}
return baseURL.ResolveReference(locURL).String(), nil
}
func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) {
resolver := net.DefaultResolver
secure, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
if err != nil && !isNoSuchHost(err) {
res.SRVError = err.Error()
}
res.SecureSRV = secure
plain, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
if err != nil && !isNoSuchHost(err) && res.SRVError == "" {
res.SRVError = err.Error()
}
res.PlaintextSRV = plain
// Pull the TXT path hint from whichever SRV target we plan to use.
var txtName string
if len(res.SecureSRV) > 0 {
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
} else if len(res.PlaintextSRV) > 0 {
txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target)
}
if txtName != "" {
txts, err := resolver.LookupTXT(ctx, txtName)
if err != nil && !isNoSuchHost(err) {
res.TXTError = err.Error()
}
for _, t := range txts {
if strings.HasPrefix(t, "path=") {
res.TXTPath = strings.TrimPrefix(t, "path=")
break
}
}
}
}
func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) {
_, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name)
if err != nil {
return nil, err
}
out := make([]SRVRecord, 0, len(addrs))
for _, a := range addrs {
out = append(out, SRVRecord{
Target: trimTrailingDot(a.Target),
Port: a.Port,
Priority: a.Priority,
Weight: a.Weight,
})
}
return out, nil
}
func srvURL(r SRVRecord, path string, secure bool) string {
scheme := "https"
defaultPort := uint16(443)
if !secure {
scheme = "http"
defaultPort = 80
}
host := r.Target
if r.Port != defaultPort {
host = fmt.Sprintf("%s:%d", r.Target, r.Port)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return scheme + "://" + host + path
}
func trimTrailingDot(s string) string {
return strings.TrimSuffix(s, ".")
}
func isNoSuchHost(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
return false
}

View file

@ -0,0 +1,111 @@
package dav
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestDiscover_wellKnownRedirect walks the happy path: /.well-known/caldav
// returns a 301 to the real context URL.
func TestDiscover_wellKnownRedirect(t *testing.T) {
var hits []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits = append(hits, r.URL.Path)
if r.URL.Path == "/.well-known/caldav" {
http.Redirect(w, r, "/dav/", http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
// Route "example.test/.well-known/caldav" through the test server.
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
if res.Source != "well-known" {
t.Errorf("source = %q, want well-known", res.Source)
}
if !strings.HasSuffix(res.ContextURL, "/dav/") {
t.Errorf("context URL = %q", res.ContextURL)
}
if res.WellKnownCode != 301 {
t.Errorf("expected 301 captured, got %d", res.WellKnownCode)
}
if len(res.WellKnownChain) < 1 {
t.Error("expected redirect chain to be recorded")
}
}
// TestDiscover_wellKnownReturns200 reproduces the most common misconfig: the
// server returns 200 on /.well-known/caldav instead of redirecting. Discover
// must still set ContextURL (to the well-known URL) but WellKnownCode=200 so
// the rule can emit the warning callout.
func TestDiscover_wellKnownReturns200(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCardDAV, "example.test", "")
if res.WellKnownCode != 200 {
t.Errorf("well-known code = %d, want 200", res.WellKnownCode)
}
if res.ContextURL == "" {
t.Error("expected ContextURL to fall back to the well-known URL")
}
}
func TestDiscover_explicitOverride(t *testing.T) {
res := Discover(context.Background(), http.DefaultClient, KindCalDAV, "example.test", "https://custom.example/dav/")
if res.Source != "explicit" {
t.Errorf("source: %q", res.Source)
}
if res.ContextURL != "https://custom.example/dav/" {
t.Errorf("ctx: %q", res.ContextURL)
}
if res.WellKnownURL != "" {
t.Errorf("should not have probed well-known, got %q", res.WellKnownURL)
}
}
func TestDiscover_redirectLoop(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Always redirect to itself → triggers "too many redirects".
http.Redirect(w, r, r.URL.Path, http.StatusFound)
}))
t.Cleanup(srv.Close)
c := srv.Client()
c.Transport = rewriteTransport{base: srv.URL, next: c.Transport}
res := Discover(context.Background(), c, KindCalDAV, "example.test", "")
if res.WellKnownError == "" {
t.Error("expected well-known error, got none")
}
}
// rewriteTransport rewrites any request URL's host to point at base so we can
// exercise Discover() without setting up DNS. It preserves the original path.
type rewriteTransport struct {
base string
next http.RoundTripper
}
func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
baseURL, _ := parseURL(r.base)
req.URL.Scheme = baseURL.Scheme
req.URL.Host = baseURL.Host
next := r.next
if next == nil {
next = http.DefaultTransport
}
return next.RoundTrip(req)
}

89
internal/dav/endpoints.go Normal file
View file

@ -0,0 +1,89 @@
package dav
import (
"net/url"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// DiscoverEndpoints derives TLS endpoints worth handing off to downstream
// checkers (notably the dedicated TLS checker) from a completed Observation.
//
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, so
// we emit a single `tls` entry for the resolved context URL's host:port. If
// the endpoint was reached via SRV, we also surface each SRV target as its
// own endpoint — those are the names operators actually need certificates on,
// and they may differ from the queried domain.
func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
if obs == nil || obs.Discovery.ContextURL == "" {
return nil
}
var out []sdk.DiscoveredEndpoint
seen := map[string]struct{}{}
add := func(host string, port uint16, sni string) {
if host == "" || port == 0 {
return
}
key := host + ":" + strconv.Itoa(int(port))
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
ep := sdk.DiscoveredEndpoint{
Type: "tls",
Host: host,
Port: port,
}
if sni != "" && sni != host {
ep.SNI = sni
}
out = append(out, ep)
}
// Primary endpoint: the resolved context URL.
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
add(host, port, "")
}
// Secondary endpoints: every TLS SRV target. Clients may connect to any
// of them per weight/priority, and all of them need a valid certificate.
for _, r := range obs.Discovery.SecureSRV {
port := r.Port
if port == 0 {
port = 443
}
add(r.Target, port, "")
}
return out
}
// hostPortFromURL extracts the (host, port) pair from an absolute URL. The
// port defaults to 443 for https and 80 for http. Returns ok=false for
// malformed URLs so callers can silently skip them.
func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
u, err := url.Parse(raw)
if err != nil {
return "", 0, false
}
host = u.Hostname()
if host == "" {
return "", 0, false
}
if p := u.Port(); p != "" {
n, convErr := strconv.ParseUint(p, 10, 16)
if convErr != nil {
return "", 0, false
}
return host, uint16(n), true
}
switch u.Scheme {
case "https":
return host, 443, true
case "http":
return host, 80, true
}
return "", 0, false
}

View file

@ -0,0 +1,64 @@
package dav
import "testing"
func TestDiscoverEndpoints_contextURLOnly(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com/caldav/"},
}
got := DiscoverEndpoints(obs)
if len(got) != 1 {
t.Fatalf("got %d endpoints, want 1: %+v", len(got), got)
}
if got[0].Host != "dav.example.com" || got[0].Port != 443 || got[0].Type != "tls" {
t.Errorf("unexpected endpoint: %+v", got[0])
}
}
func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) {
obs := &Observation{
Discovery: DiscoveryResult{ContextURL: "https://dav.example.com:8443/caldav/"},
}
got := DiscoverEndpoints(obs)
if len(got) != 1 || got[0].Port != 8443 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestDiscoverEndpoints_srvTargets(t *testing.T) {
// SRV pointing to a different name than the domain → we must surface
// the SRV target too, because that's the hostname the cert needs to
// cover.
obs := &Observation{
Discovery: DiscoveryResult{
ContextURL: "https://dav.example.com/caldav/",
SecureSRV: []SRVRecord{
{Target: "dav-backend-1.example.net", Port: 443},
{Target: "dav-backend-2.example.net", Port: 443},
{Target: "dav.example.com", Port: 443}, // duplicate of context → deduped
},
},
}
got := DiscoverEndpoints(obs)
if len(got) != 3 {
t.Fatalf("expected 3 unique endpoints, got %d: %+v", len(got), got)
}
hosts := map[string]bool{}
for _, e := range got {
hosts[e.Host] = true
}
for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} {
if !hosts[want] {
t.Errorf("missing host %q in %+v", want, got)
}
}
}
func TestDiscoverEndpoints_emptyOnNoContextURL(t *testing.T) {
if got := DiscoverEndpoints(&Observation{}); got != nil {
t.Errorf("expected nil, got %+v", got)
}
if got := DiscoverEndpoints(nil); got != nil {
t.Errorf("expected nil for nil obs, got %+v", got)
}
}

101
internal/dav/options.go Normal file
View file

@ -0,0 +1,101 @@
package dav
import (
"context"
"fmt"
"net/http"
"strings"
)
// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV
// headers. A missing DAV: header, or one that does not contain the kind's
// required capability, is not treated as a transport error here — the caller
// rule decides severity from the parsed values.
func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
res := OptionsResult{}
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil)
if err != nil {
res.Error = err.Error()
return res, err
}
resp, err := client.Do(req)
if err != nil {
res.Error = err.Error()
return res, err
}
defer resp.Body.Close()
res.StatusCode = resp.StatusCode
res.Server = resp.Header.Get("Server")
res.DAVClasses = parseCSVHeader(resp.Header.Values("Dav"))
res.AllowMethods = parseCSVHeader(resp.Header.Values("Allow"))
for _, h := range resp.Header.Values("Www-Authenticate") {
if scheme := authScheme(h); scheme != "" {
res.AuthSchemes = appendUnique(res.AuthSchemes, scheme)
}
}
if res.StatusCode >= 400 {
res.Error = fmt.Sprintf("OPTIONS returned HTTP %d", res.StatusCode)
}
return res, nil
}
// HasCapability returns true when the OPTIONS response advertised cap in the
// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1.
func (o OptionsResult) HasCapability(cap string) bool {
for _, c := range o.DAVClasses {
if strings.EqualFold(c, cap) {
return true
}
}
return false
}
// AllowsMethod returns true when the OPTIONS response's Allow: listed m.
func (o OptionsResult) AllowsMethod(m string) bool {
for _, a := range o.AllowMethods {
if strings.EqualFold(a, m) {
return true
}
}
return false
}
// parseCSVHeader splits one or more header values on commas, trims, and drops
// empties. Multiple headers of the same name (net/http preserves them) are
// merged.
func parseCSVHeader(values []string) []string {
var out []string
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
}
return out
}
// authScheme returns the scheme token from a WWW-Authenticate header value
// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed.
func authScheme(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if i := strings.IndexAny(h, " \t"); i > 0 {
return h[:i]
}
return h
}
func appendUnique(s []string, v string) []string {
for _, x := range s {
if strings.EqualFold(x, v) {
return s
}
}
return append(s, v)
}

View file

@ -0,0 +1,61 @@
package dav
// CommonOptions returns the CheckerOptionField descriptors shared by both
// CalDAV and CardDAV checkers. Each checker composes them into its own
// CheckerOptionsDocumentation alongside protocol-specific tweaks.
//
// Returning plain CheckerOptionField slices (from the SDK) keeps this package
// free of any sdk import cycles: the caller already depends on the SDK.
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// UserOptions are editable in the checker settings UI.
func UserOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "username",
Type: "string",
Label: "Username",
Description: "Optional. Supplying credentials unlocks authenticated checks (principal, home-set, collections, report probe).",
},
{
Id: "password",
Type: "string",
Label: "Password or token",
Description: "Optional. Paired with the username for HTTP Basic authentication.",
Secret: true,
},
{
Id: "context_url",
Type: "string",
Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.",
Placeholder: "https://dav.example.com/caldav/",
},
}
}
// DomainOptions are auto-filled per-domain by happyDomain.
func DomainOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
},
}
}
// RunOptions are set at collect-time only.
func RunOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}

View file

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

161
internal/dav/principal.go Normal file
View file

@ -0,0 +1,161 @@
package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
)
// FindPrincipal issues a PROPFIND for {DAV:}current-user-principal against
// contextURL. Callers should attach credentials to client; a 401/403 bubbles
// up as the returned error.
func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>`
hrefs, err := propFind(ctx, client, contextURL, "0", body)
if err != nil {
return "", err
}
for _, href := range hrefs.principalHref() {
return resolveReference(contextURL, href), nil
}
return "", fmt.Errorf("no current-user-principal returned")
}
// FindScheduleURLs looks up {urn:ietf:params:xml:ns:caldav}schedule-inbox-URL
// and schedule-outbox-URL on the CalDAV principal URL. CalDAV-only.
func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL string) (inbox, outbox string, err error) {
body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:schedule-inbox-URL/>
<c:schedule-outbox-URL/>
</d:prop>
</d:propfind>`
resp, err := propFind(ctx, client, principalURL, "0", body)
if err != nil {
return "", "", err
}
for _, r := range resp.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, p := range ps.Prop.Props {
switch p.XMLName.Local {
case "schedule-inbox-URL":
if h := p.firstHref(); h != "" {
inbox = resolveReference(principalURL, h)
}
case "schedule-outbox-URL":
if h := p.firstHref(); h != "" {
outbox = resolveReference(principalURL, h)
}
}
}
}
}
return inbox, outbox, nil
}
// ── raw PROPFIND ─────────────────────────────────────────────────────────────
// multistatus is the subset of the DAV:multistatus XML schema we need to read
// principal URLs and scheduling hrefs. It is intentionally permissive — extra
// elements are ignored, which makes us tolerant of server-specific extensions.
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Response []msResponse `xml:"response"`
}
type msResponse struct {
Href string `xml:"href"`
Propstat []propstat `xml:"propstat"`
}
type propstat struct {
Prop prop `xml:"prop"`
Status string `xml:"status"`
}
type prop struct {
Props []msProp `xml:",any"`
}
type msProp struct {
XMLName xml.Name
Hrefs []string `xml:"href"`
// A prop may also contain nested <current-user-principal><href>…</href></current-user-principal>,
// which the flat Hrefs slice above captures via xml:"href" descent.
}
func (p msProp) firstHref() string {
if len(p.Hrefs) > 0 {
return p.Hrefs[0]
}
return ""
}
func (m *multistatus) principalHref() []string {
var out []string
for _, r := range m.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, pr := range ps.Prop.Props {
if pr.XMLName.Local == "current-user-principal" {
if h := pr.firstHref(); h != "" {
out = append(out, h)
}
}
}
}
}
return out
}
// propFind is a small PROPFIND helper tuned for small single-resource probes.
// It returns a parsed multistatus; transport-level failures bubble up as err.
func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", depth)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode)
}
var ms multistatus
if err := xml.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("invalid multistatus: %w", err)
}
return &ms, nil
}
func resolveReference(base, ref string) string {
r, err := parseURL(ref)
if err != nil {
return ref
}
b, err := parseURL(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}

429
internal/dav/report.go Normal file
View file

@ -0,0 +1,429 @@
package dav
import (
"fmt"
"html/template"
"strings"
)
// RenderReport turns an Observation into a self-contained HTML document.
//
// The report foregrounds action items for the failure modes we see most often
// (well-known misconfig, missing DAV capability, missing credentials) before
// showing the full per-phase evidence.
func RenderReport(obs *Observation, title string) (string, error) {
data := buildReportData(obs, title)
var buf strings.Builder
if err := reportTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render dav report: %w", err)
}
return buf.String(), nil
}
type reportData struct {
Title string
Domain string
Verdict string
VerdictCls string
Callouts []calloutData
Phases []phaseData
Raw string
ShowSched bool
Scheduling *SchedulingResult
}
type calloutData struct {
Title string
Body string
Severity string // "warn" or "crit"
}
type phaseData struct {
Title string
Items []phaseItem
Open bool
}
type phaseItem struct {
Label string
Status string // "ok", "warn", "fail", "unk", "info"
Detail string
Mono string
}
func buildReportData(o *Observation, title string) reportData {
d := reportData{
Title: title,
Domain: o.Domain,
ShowSched: o.Kind == KindCalDAV,
Scheduling: o.Scheduling,
}
d.Callouts = buildCallouts(o)
d.Phases = buildPhases(o)
switch {
case hasSeverity(d.Phases, "fail"):
d.Verdict = "Critical issues detected"
d.VerdictCls = "fail"
case hasSeverity(d.Phases, "warn"):
d.Verdict = "Minor issues detected"
d.VerdictCls = "warn"
case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"):
d.Verdict = "Could not evaluate without credentials"
d.VerdictCls = "unk"
default:
d.Verdict = "All checks passed"
d.VerdictCls = "ok"
}
return d
}
func hasSeverity(phases []phaseData, sev string) bool {
for _, p := range phases {
for _, it := range p.Items {
if it.Status == sev {
return true
}
}
}
return false
}
// buildCallouts surfaces the most common misconfigurations at the top of the
// report so operators don't have to read the full phase tree to find the fix.
func buildCallouts(o *Observation) []calloutData {
var out []calloutData
disc := o.Discovery
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
out = append(out, calloutData{
Severity: "warn",
Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL),
Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)),
})
}
if disc.ContextURL == "" {
out = append(out, calloutData{
Severity: "crit",
Title: "Service discovery failed",
Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain),
})
}
if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 {
out = append(out, calloutData{
Severity: "warn",
Title: "Plaintext SRV record without HTTPS counterpart",
Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)),
})
}
if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "crit",
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()),
Body: fmt.Sprintf("The DAV: response header is %q — this endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
})
}
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "warn",
Title: "Authenticated checks were skipped",
Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.",
})
}
return out
}
func exampleContextURL(k Kind) string {
switch k {
case KindCalDAV:
return "/dav/calendars/"
case KindCardDAV:
return "/dav/addressbooks/"
}
return "/dav/"
}
func buildPhases(o *Observation) []phaseData {
var phases []phaseData
// Phase 1 — Discovery
discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect",
wellKnownStatus(o.Discovery),
o.Discovery.WellKnownError,
summariseChain(o.Discovery.WellKnownChain),
))
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)),
srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError),
o.Discovery.SRVError,
summariseSRV(o.Discovery.SecureSRV),
))
if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" {
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)),
plainSRVStatus(o.Discovery.PlaintextSRV),
"",
summariseSRV(o.Discovery.PlaintextSRV),
))
}
if o.Discovery.TXTPath != "" {
discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath))
}
discovery.Items = append(discovery.Items, itemFor(
"Context URL",
contextStatus(o.Discovery.ContextURL),
"",
o.Discovery.ContextURL,
))
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery)
// Phase 2 — Transport + OPTIONS
transport := phaseData{Title: "Transport & OPTIONS"}
transport.Items = append(transport.Items,
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")),
itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")),
)
if len(o.Options.AuthSchemes) > 0 {
transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", ")))
}
if o.Options.Server != "" {
transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server))
}
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport)
// Phase 3 — Authenticated
auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error),
collectionsItemFor(o.Collections, o.Kind),
reportItemFor(o.Report),
)
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth)
// Phase 4 — Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised {
sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised"))
} else {
sched.Items = append(sched.Items,
itemFor("calendar-schedule advertised", "ok", "", "advertised"),
authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error),
authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""),
)
}
sched.Open = hasItemSeverity(sched.Items, "warn", "fail")
phases = append(phases, sched)
}
return phases
}
// ── small helpers used by buildPhases ────────────────────────────────────────
func wellKnownStatus(d DiscoveryResult) string {
if d.Source == "explicit" {
return "info"
}
if d.WellKnownCode == 200 {
return "warn"
}
if d.WellKnownCode >= 300 && d.WellKnownCode < 400 {
return "ok"
}
return "fail"
}
func srvStatus(rec []SRVRecord, errStr string) string {
if len(rec) > 0 {
return "ok"
}
if errStr != "" {
return "fail"
}
return "warn"
}
func plainSRVStatus(rec []SRVRecord) string {
if len(rec) > 0 {
return "warn" // plaintext SRV is legacy / discouraged
}
return "ok"
}
func contextStatus(u string) string {
if u == "" {
return "fail"
}
return "ok"
}
func davStatus(o *Observation, r OptionsResult) string {
if r.HasCapability(o.Kind.RequiredCapability()) {
return "ok"
}
return "fail"
}
func methodsStatus(r OptionsResult) string {
if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") {
return "ok"
}
return "warn"
}
func boolStatus(ok bool, failSev string) string {
if ok {
return "ok"
}
return failSev
}
func authItemFor(label, value string, skipped bool, errStr string) phaseItem {
switch {
case skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case errStr != "":
return phaseItem{Label: label, Status: "fail", Detail: errStr}
case value == "":
return phaseItem{Label: label, Status: "warn", Detail: "not returned"}
default:
return phaseItem{Label: label, Status: "ok", Mono: value}
}
}
func collectionsItemFor(c CollectionsResult, k Kind) phaseItem {
label := "Calendars"
if k == KindCardDAV {
label = "Address books"
}
switch {
case c.Skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case c.Error != "":
return phaseItem{Label: label, Status: "fail", Detail: c.Error}
case len(c.Items) == 0:
return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"}
default:
names := make([]string, 0, len(c.Items))
for _, it := range c.Items {
n := it.Name
if n == "" {
n = it.Path
}
names = append(names, n)
}
return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")}
}
}
func reportItemFor(r ReportResult) phaseItem {
switch {
case r.Skipped:
return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"}
case r.Error != "":
return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error}
case !r.QueryOK:
return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"}
default:
return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath}
}
}
func itemFor(label, status, errStr, mono string) phaseItem {
it := phaseItem{Label: label, Status: status, Mono: mono}
if errStr != "" {
it.Detail = errStr
}
return it
}
func hasItemSeverity(items []phaseItem, sevs ...string) bool {
for _, it := range items {
for _, s := range sevs {
if it.Status == s {
return true
}
}
}
return false
}
func summariseChain(chain []string) string {
return strings.Join(chain, " → ")
}
func summariseSRV(rec []SRVRecord) string {
if len(rec) == 0 {
return ""
}
parts := make([]string, 0, len(rec))
for _, r := range rec {
parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight))
}
return strings.Join(parts, "; ")
}
// ── template ─────────────────────────────────────────────────────────────────
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} Report</title>
<style>` + ReportCSS + `</style>
</head>
<body>
<div class="hd">
<h1>{{.Title}}</h1>
<span class="badge {{.VerdictCls}}">{{.Verdict}}</span>
{{if .Domain}}<div class="verdict">Domain: <code>{{.Domain}}</code></div>{{end}}
</div>
{{if .Callouts}}
<div class="callouts">
{{range .Callouts}}
<div class="callout {{if eq .Severity "crit"}}crit{{end}}">
<h3>{{.Title}}</h3>
<p>{{.Body}}</p>
</div>
{{end}}
</div>
{{end}}
{{range .Phases}}
<details{{if .Open}} open{{end}}>
<summary><span class="phase-title">{{.Title}}</span></summary>
<div class="details-body">
<table>
{{range .Items}}
<tr>
<td style="width:1.5rem">
{{if eq .Status "ok"}}<span class="check-ok">&#10003;</span>
{{else if eq .Status "warn"}}<span class="check-warn">&#9888;</span>
{{else if eq .Status "fail"}}<span class="check-fail">&#10007;</span>
{{else if eq .Status "unk"}}<span class="check-unk">?</span>
{{else}}<span class="check-unk">i</span>{{end}}
</td>
<td style="width:45%">{{.Label}}</td>
<td>
{{if .Mono}}<code>{{.Mono}}</code>{{end}}
{{if .Detail}}<div class="note">{{.Detail}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
</div>
</details>
{{end}}
</body>
</html>`))

105
internal/dav/report_css.go Normal file
View file

@ -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; }
`

314
internal/dav/rules.go Normal file
View file

@ -0,0 +1,314 @@
package dav
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the default rule set for kind. CardDAV gets the full set
// except `scheduling`, which only applies to CalDAV.
func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
rules := []sdk.CheckRule{
&discoveryRule{obsKey: obsKey},
&transportRule{obsKey: obsKey},
&optionsRule{obsKey: obsKey, kind: kind},
&principalRule{obsKey: obsKey},
&homeSetRule{obsKey: obsKey},
&collectionsRule{obsKey: obsKey, kind: kind},
&reportRule{obsKey: obsKey},
}
if kind == KindCalDAV {
rules = append(rules, &schedulingRule{obsKey: obsKey})
}
return rules
}
// WorstStatus is a CheckAggregator that picks the highest-severity state from
// the individual rule outcomes. StatusUnknown does not degrade the result
// unless every rule returned Unknown.
type WorstStatus struct{}
func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
if len(states) == 0 {
return sdk.CheckState{Status: sdk.StatusUnknown, Message: "no rules evaluated"}
}
ranks := map[sdk.Status]int{
sdk.StatusOK: 1,
sdk.StatusInfo: 2,
sdk.StatusUnknown: 3,
sdk.StatusWarn: 4,
sdk.StatusCrit: 5,
sdk.StatusError: 6,
}
worst := states[0]
worstRank := ranks[worst.Status]
var msgs []string
for _, s := range states {
if r := ranks[s.Status]; r > worstRank {
worstRank = r
worst = s
}
if s.Message != "" {
msgs = append(msgs, s.Message)
}
}
out := sdk.CheckState{Status: worst.Status, Code: "aggregate"}
out.Message = strings.Join(msgs, "; ")
return out
}
// ── individual rules ─────────────────────────────────────────────────────────
type baseRule struct {
obsKey sdk.ObservationKey
}
func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observation, sdk.CheckState) {
var d Observation
if err := obs.Get(ctx, r.obsKey, &d); err != nil {
return nil, sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load observation: %v", err),
Code: "observation_missing",
}
}
return &d, sdk.CheckState{}
}
// discoveryRule checks that a context URL was resolved and that the
// /.well-known endpoint is configured as a redirect (the #1 user-facing
// misconfig we want to surface).
type discoveryRule struct{ obsKey sdk.ObservationKey }
func (r *discoveryRule) Name() string { return "dav_discovery" }
func (r *discoveryRule) Description() string { return "Service discovery via /.well-known and SRV" }
func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
disc := d.Discovery
if disc.ContextURL == "" {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "discovery_failed",
Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)",
}
}
// /.well-known returning 200 is legal per RFC but strongly discouraged —
// many clients won't follow it. Warn, don't crit.
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "well_known_not_redirect",
Message: fmt.Sprintf("%s returned 200 instead of a 301/302 redirect", disc.WellKnownURL),
}
}
if disc.Source == "srv-txt" && disc.WellKnownError != "" {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "well_known_missing",
Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError),
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "discovery_ok",
Message: fmt.Sprintf("context URL %s (via %s)", disc.ContextURL, disc.Source),
}
}
// transportRule reports only whether the context URL accepts HTTPS requests.
// TLS specifics (cert chain, version) are explicitly out of scope.
type transportRule struct{ obsKey sdk.ObservationKey }
func (r *transportRule) Name() string { return "dav_transport" }
func (r *transportRule) Description() string { return "HTTPS connection to the context URL" }
func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
if !d.Transport.Reached {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "transport_failed",
Message: fmt.Sprintf("HTTPS connection failed: %s", d.Transport.Error),
}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}
}
// optionsRule verifies the mandatory DAV class is advertised.
type optionsRule struct {
obsKey sdk.ObservationKey
kind Kind
}
func (r *optionsRule) Name() string { return "dav_options" }
func (r *optionsRule) Description() string { return "HTTP OPTIONS advertises the required DAV capability" }
func (r *optionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
o := d.Options
if o.Error != "" && len(o.DAVClasses) == 0 {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "options_failed",
Message: fmt.Sprintf("OPTIONS request failed: %s", o.Error),
}
}
cap := r.kind.RequiredCapability()
if !o.HasCapability(cap) {
return sdk.CheckState{
Status: sdk.StatusCrit,
Code: "capability_missing",
Message: fmt.Sprintf("server does not advertise %q in DAV: header (got %v)", cap, o.DAVClasses),
}
}
if !o.AllowsMethod("PROPFIND") || !o.AllowsMethod("REPORT") {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "methods_missing",
Message: fmt.Sprintf("Allow: header missing PROPFIND or REPORT (got %v)", o.AllowMethods),
}
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "options_ok",
Message: fmt.Sprintf("DAV: %s", strings.Join(o.DAVClasses, ", ")),
}
}
type principalRule struct{ obsKey sdk.ObservationKey }
func (r *principalRule) Name() string { return "dav_principal" }
func (r *principalRule) Description() string { return "Principal URL discovery (authenticated)" }
func (r *principalRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
p := d.Principal
if p.Skipped {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: "principal_skipped", Message: "no credentials supplied"}
}
if p.Error != "" {
return sdk.CheckState{Status: sdk.StatusCrit, Code: "principal_failed", Message: p.Error}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "principal_ok", Message: p.URL}
}
type homeSetRule struct{ obsKey sdk.ObservationKey }
func (r *homeSetRule) Name() string { return "dav_home_set" }
func (r *homeSetRule) Description() string { return "Home-set discovered from the principal" }
func (r *homeSetRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
h := d.HomeSet
if h.Skipped {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: "home_set_skipped", Message: "no credentials supplied"}
}
if h.Error != "" {
return sdk.CheckState{Status: sdk.StatusCrit, Code: "home_set_failed", Message: h.Error}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "home_set_ok", Message: h.URL}
}
type collectionsRule struct {
obsKey sdk.ObservationKey
kind Kind
}
func (r *collectionsRule) Name() string { return "dav_collections" }
func (r *collectionsRule) Description() string { return "Calendar/addressbook collections enumerate and expose required properties" }
func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
c := d.Collections
if c.Skipped {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: "collections_skipped", Message: "no credentials supplied"}
}
if c.Error != "" {
return sdk.CheckState{Status: sdk.StatusCrit, Code: "collections_failed", Message: c.Error}
}
if len(c.Items) == 0 {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "collections_empty",
Message: "home-set is empty — the account has no calendars/addressbooks",
}
}
label := "calendars"
if r.kind == KindCardDAV {
label = "addressbooks"
}
return sdk.CheckState{
Status: sdk.StatusOK,
Code: "collections_ok",
Message: fmt.Sprintf("%d %s discovered", len(c.Items), label),
}
}
type reportRule struct{ obsKey sdk.ObservationKey }
func (r *reportRule) Name() string { return "dav_report" }
func (r *reportRule) Description() string { return "Server accepts a minimal REPORT query" }
func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
rep := d.Report
if rep.Skipped {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: "report_skipped", Message: "no credentials supplied or no collection to probe"}
}
if rep.Error != "" {
return sdk.CheckState{Status: sdk.StatusCrit, Code: "report_failed", Message: rep.Error}
}
if !rep.QueryOK {
return sdk.CheckState{Status: sdk.StatusWarn, Code: "report_query_not_ok", Message: "REPORT query returned an unexpected response"}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath)}
}
// schedulingRule is CalDAV-only: if the server advertises calendar-schedule,
// the principal should expose inbox/outbox URLs.
type schedulingRule struct{ obsKey sdk.ObservationKey }
func (r *schedulingRule) Name() string { return "caldav_scheduling" }
func (r *schedulingRule) Description() string { return "Scheduling inbox/outbox present when calendar-schedule is advertised" }
func (r *schedulingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) sdk.CheckState {
d, errState := (&baseRule{obsKey: r.obsKey}).get(ctx, obs)
if d == nil {
return errState
}
s := d.Scheduling
if s == nil || !s.Advertised {
return sdk.CheckState{Status: sdk.StatusInfo, Code: "scheduling_not_advertised", Message: "server does not advertise calendar-schedule"}
}
if d.Principal.Skipped {
return sdk.CheckState{Status: sdk.StatusUnknown, Code: "scheduling_skipped", Message: "no credentials supplied"}
}
if s.Error != "" {
return sdk.CheckState{Status: sdk.StatusWarn, Code: "scheduling_probe_failed", Message: s.Error}
}
if s.InboxURL == "" || s.OutboxURL == "" {
return sdk.CheckState{
Status: sdk.StatusWarn,
Code: "scheduling_urls_missing",
Message: "calendar-schedule advertised but schedule-inbox-URL/schedule-outbox-URL missing",
}
}
return sdk.CheckState{Status: sdk.StatusOK, Code: "scheduling_ok", Message: fmt.Sprintf("inbox=%s outbox=%s", s.InboxURL, s.OutboxURL)}
}

168
internal/dav/types.go Normal file
View file

@ -0,0 +1,168 @@
// Package dav holds the code shared between the CalDAV and CardDAV checkers:
// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and
// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in
// their own packages and compose these helpers.
package dav
import "time"
// Kind distinguishes the two protocol flavours. A single Kind value is carried
// end-to-end through a checker run so shared helpers can pick the right
// service names, well-known paths, and required DAV classes.
type Kind string
const (
KindCalDAV Kind = "caldav"
KindCardDAV Kind = "carddav"
)
// ServiceName returns the RFC 6764 SRV service label for kind, with the
// leading "_" but without the "_tcp" suffix.
func (k Kind) ServiceName(secure bool) string {
switch k {
case KindCalDAV:
if secure {
return "_caldavs"
}
return "_caldav"
case KindCardDAV:
if secure {
return "_carddavs"
}
return "_carddav"
}
return ""
}
// WellKnownPath returns the RFC 6764 well-known path for kind.
func (k Kind) WellKnownPath() string {
return "/.well-known/" + string(k)
}
// RequiredCapability is the string that must appear in the DAV: response
// header for the server to qualify as a valid implementation.
func (k Kind) RequiredCapability() string {
switch k {
case KindCalDAV:
return "calendar-access"
case KindCardDAV:
return "addressbook"
}
return ""
}
// Observation is the root data structure persisted by either checker. The
// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left
// zero-valued for KindCardDAV.
type Observation struct {
Kind Kind `json:"kind"`
Domain string `json:"domain"`
HasCredentials bool `json:"has_credentials"`
Discovery DiscoveryResult `json:"discovery"`
Transport TransportResult `json:"transport"`
Options OptionsResult `json:"options"`
Principal PrincipalResult `json:"principal"`
HomeSet HomeSetResult `json:"home_set"`
Collections CollectionsResult `json:"collections"`
Report ReportResult `json:"report"`
Scheduling *SchedulingResult `json:"scheduling,omitempty"`
CollectedAt time.Time `json:"collected_at"`
}
// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
}
// DiscoveryResult captures every signal we gathered while locating the
// service: SRV secure/plaintext, TXT path hints, well-known redirects, and
// the ultimately-resolved context URL.
type DiscoveryResult struct {
SecureSRV []SRVRecord `json:"secure_srv,omitempty"`
PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"`
SRVError string `json:"srv_error,omitempty"`
TXTPath string `json:"txt_path,omitempty"`
TXTError string `json:"txt_error,omitempty"`
WellKnownURL string `json:"well_known_url,omitempty"`
WellKnownCode int `json:"well_known_code,omitempty"`
WellKnownChain []string `json:"well_known_chain,omitempty"`
WellKnownError string `json:"well_known_error,omitempty"`
ContextURL string `json:"context_url,omitempty"`
Source string `json:"source,omitempty"` // "explicit", "well-known", "srv-txt"
Error string `json:"error,omitempty"`
}
// TransportResult records whether the resolved context URL accepts HTTPS
// requests. TLS certificate validation is out of scope (a dedicated TLS
// checker covers it); we only report the raw transport-level error if any.
type TransportResult struct {
Reached bool `json:"reached"`
Error string `json:"error,omitempty"`
}
// OptionsResult captures the response to an OPTIONS request against the
// context URL: which DAV: classes are advertised, which HTTP methods are in
// Allow:, and which authentication schemes the server offered.
type OptionsResult struct {
StatusCode int `json:"status_code"`
DAVClasses []string `json:"dav_classes,omitempty"`
AllowMethods []string `json:"allow_methods,omitempty"`
AuthSchemes []string `json:"auth_schemes,omitempty"`
Server string `json:"server,omitempty"`
Error string `json:"error,omitempty"`
}
// PrincipalResult holds the `current-user-principal` URL discovered after
// authenticating. Skipped is set to true when no credentials were supplied
// (the rule surfaces this as StatusUnknown).
type PrincipalResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV
// `addressbook-home-set` URL for the authenticated principal.
type HomeSetResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
// CollectionInfo describes a single discovered calendar or addressbook.
type CollectionInfo struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
MaxResourceSize int64 `json:"max_resource_size,omitempty"`
SupportedComponentSet []string `json:"supported_component_set,omitempty"` // CalDAV only
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
}
// CollectionsResult is the enumerated content of the home-set.
type CollectionsResult struct {
Skipped bool `json:"skipped,omitempty"`
Items []CollectionInfo `json:"items,omitempty"`
Error string `json:"error,omitempty"`
}
// ReportResult is the outcome of a minimal REPORT probe against the first
// collection found (empty calendar-query/addressbook-query).
type ReportResult struct {
Skipped bool `json:"skipped,omitempty"`
QueryOK bool `json:"query_ok,omitempty"`
ProbePath string `json:"probe_path,omitempty"`
Error string `json:"error,omitempty"`
}
// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server
// advertises the `calendar-schedule` capability.
type SchedulingResult struct {
Advertised bool `json:"advertised"`
InboxURL string `json:"inbox_url,omitempty"`
OutboxURL string `json:"outbox_url,omitempty"`
Error string `json:"error,omitempty"`
}

7
internal/dav/util.go Normal file
View file

@ -0,0 +1,7 @@
package dav
import "net/url"
// parseURL is an alias so principal.go can use net/url without importing it
// directly (keeping imports tidy across files).
func parseURL(raw string) (*url.URL, error) { return url.Parse(raw) }

16
plugin/caldav/plugin.go Normal file
View file

@ -0,0 +1,16 @@
// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
// happyDomain.
package main
import (
"git.happydns.org/checker-dav/caldav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
caldav.Version = Version
return caldav.Definition(), caldav.Provider(), nil
}

16
plugin/carddav/plugin.go Normal file
View file

@ -0,0 +1,16 @@
// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
// happyDomain.
package main
import (
"git.happydns.org/checker-dav/carddav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
carddav.Version = Version
return carddav.Definition(), carddav.Provider(), nil
}