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 7eb0dbddc7
39 changed files with 3324 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
checker-caldav
checker-caldav.so
checker-carddav
checker-carddav.so

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 }

50
caldav/definition.go Normal file
View file

@ -0,0 +1,50 @@
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{
// The probe itself only needs a domain name (discovery runs on
// the whole domain via /.well-known + SRV), so the checker is
// always offered at domain scope.
ApplyToDomain: true,
// Also offered at service scope so alerts — including the TLS
// alerts derived from the endpoints we publish — surface on a
// dedicated "CalDAV" service page rather than on the domain
// page. The abstract.CalDAV service type does not exist in the
// happyDomain service catalog yet; until it does, this has no
// visible effect, but makes the intent explicit.
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,
}
}

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"
)
// DiscoverEntries implements sdk.DiscoveryPublisher. 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) 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
}

30
caldav/provider.go Normal file
View file

@ -0,0 +1,30 @@
package caldav
import (
"net/http"
"git.happydns.org/checker-dav/internal/dav"
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() }
func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
return dav.ParseInteractiveForm(r)
}

29
caldav/report.go Normal file
View file

@ -0,0 +1,29 @@
package caldav
import (
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-dav/internal/dav"
)
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
//
// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV
// produce visually identical reports; the only differences are the title
// and the set of phases (CalDAV includes Scheduling).
//
// Downstream TLS probes published for the endpoints we discovered are read
// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts +
// dedicated TLS phase) — per
// happydomain3/docs/checker-discovery-endpoint.md.
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))
}

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 }

45
carddav/definition.go Normal file
View file

@ -0,0 +1,45 @@
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{
// Domain scope for the probe itself (discovery runs across the
// whole domain via /.well-known + SRV).
ApplyToDomain: true,
// Service scope so downstream TLS alerts attach to a dedicated
// "CardDAV" service page instead of the domain page. See the
// CalDAV sibling for the rationale; abstract.CardDAV is not in
// the happyDomain service catalog yet but the intent is encoded
// here ahead of time.
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,
}
}

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"
)
// DiscoverEntries implements sdk.DiscoveryPublisher. See the CalDAV sibling
// for the rationale — the shared helper produces the TLS discovery entries.
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
}

21
carddav/provider.go Normal file
View file

@ -0,0 +1,21 @@
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) Definition() *sdk.CheckerDefinition { return Definition() }
func (p *carddavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
func (p *carddavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
return dav.ParseInteractiveForm(r)
}

22
carddav/report.go Normal file
View file

@ -0,0 +1,22 @@
package carddav
import (
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-dav/internal/dav"
)
// GetHTMLReport folds downstream TLS probes (published on our discovered
// endpoints) into the CardDAV report via ctx.Related — see the CalDAV
// sibling for the rationale.
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))
}

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

15
go.mod Normal file
View file

@ -0,0 +1,15 @@
module git.happydns.org/checker-dav
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.2.0
git.happydns.org/checker-tls v0.2.0
)
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
)

12
go.sum Normal file
View file

@ -0,0 +1,12 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
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
}

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

@ -0,0 +1,219 @@
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) {
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
}
// 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
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
// 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,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)
}

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

@ -0,0 +1,97 @@
package dav
import (
"log"
"net/url"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to
// downstream checkers (notably checker-tls) from a completed Observation.
//
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint,
// so we emit a single tls.endpoint.v1 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 entry — those are the names operators actually need
// certificates on, and they may differ from the queried domain.
//
// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike
// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target
// split: clients negotiate TLS for the hostname they connect to). We fill
// the field unconditionally so consumers can rely on it being set.
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)
}
// 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,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)
}
}

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,129 @@
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 returns the fields shown on the standalone /check page.
// Discovery (well-known + SRV) happens inside Collect, so the human only
// needs to provide a domain plus the same optional knobs exposed to
// happyDomain users.
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),
},
}
}
// ParseInteractiveForm turns the submitted /check form into CheckerOptions.
// Collect already handles discovery, so there is no extra lookup to do
// here beyond validating the inputs.
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
}

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
}

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

@ -0,0 +1,159 @@
package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// FindPrincipal requires credentials on client; a 401/403 from the server
// 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")
}
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 := url.Parse(ref)
if err != nil {
return ref
}
b, err := url.Parse(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}

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

@ -0,0 +1,475 @@
package dav
import (
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// 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,
// downstream TLS issues on the endpoints we published) before showing the
// full per-phase evidence.
//
// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil
// is fine: the TLS section is simply omitted. This is how the happyDomain
// cross-checker composition story (see
// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate
// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS
// dashboard.
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)
// Fold downstream TLS probes (published by checker-tls against the
// endpoints we discovered) into the report.
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 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
}
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
// rendered at the bottom of the report. Open when anything is non-OK so
// operators don't need to expand it to see the problem.
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
}
// ── 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; }
`

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

@ -0,0 +1,330 @@
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 []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 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 []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"}}
}
// 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 []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: 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 []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)}}
}

276
internal/dav/tls_related.go Normal file
View file

@ -0,0 +1,276 @@
package dav
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key we expect a TLS checker to publish for
// the endpoints we discover. Matches the cross-checker convention documented
// in happydomain3/docs/checker-discovery-endpoint.md.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally
// only read the fields we need and tolerate missing ones — the TLS checker's
// full schema is owned by that checker.
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"`
}
// address returns the canonical "host:port" string used to match a probe
// against one of our discovered endpoints.
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 normalises the two schema shapes into a single (t, ok) pair so
// callers don't have to know which one the TLS checker emits.
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 decodes a RelatedObservation as a TLS probe, returning nil
// when the payload doesn't look like one.
//
// Two payload shapes are accepted:
//
// 1. {"probes": {"<ref>": <probe>, …}} — the current convention used by
// checker-tls. Each consumer picks its own probe via r.Ref — the value
// is the DiscoveryEntry.Ref that the producer originally emitted,
// preserved by the host along the lineage chain.
// 2. <probe> — a single top-level probe object, kept for back-compat with
// callers that pre-date the keyed map and with unit-test fixtures.
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
}
// TLSSummary is what the HTML report renders for each probed endpoint.
type TLSSummary struct {
Address string
TLSVersion string
Status string // "ok", "warn", "fail", "info"
Detail string
NotAfter time.Time
DaysRemaining int
}
// tlsCallout captures a cross-checker issue we want to foreground in the
// "Action items" section of the HTML report.
type tlsCallout struct {
Severity string // "warn" or "crit"
Title string
Body string
}
// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary
// for rendering, (2) callouts for the top of the report when there's anything
// actionable. Callers pass both through the reportData pipeline.
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
}

View file

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

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"`
}

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
}