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 7d5535fddf
39 changed files with 3179 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

16
Dockerfile Normal file
View file

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

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

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

142
caldav/collect.go Normal file
View file

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

41
caldav/definition.go Normal file
View file

@ -0,0 +1,41 @@
package caldav
import (
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is overridden at link time by the standalone binary via -ldflags.
var Version = "built-in"
func (p *caldavProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "caldav",
Name: "CalDAV server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
// Service scope keeps downstream TLS alerts on a dedicated
// "CalDAV" page rather than the domain page. abstract.CalDAV
// isn't in the catalog yet, so this is a no-op until it is.
ApplyToService: true,
LimitToServices: []string{"abstract.CalDAV"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: dav.UserOptions(),
DomainOpts: dav.DomainOptions(),
RunOpts: dav.RunOptions(),
},
Rules: dav.Rules(dav.KindCalDAV, ObservationKey),
Aggregator: dav.WorstStatus{},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 1 * time.Hour,
Default: 15 * time.Minute,
},
HasHTMLReport: true,
}
}

16
caldav/discovery.go Normal file
View file

@ -0,0 +1,16 @@
package caldav
import (
"fmt"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
obs, ok := data.(*dav.Observation)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
return dav.DiscoverEntries(obs), nil
}

25
caldav/provider.go Normal file
View file

@ -0,0 +1,25 @@
package caldav
import (
"net/http"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider's return value also satisfies CheckerDefinitionProvider,
// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for
// those at runtime.
func Provider() sdk.ObservationProvider {
return &caldavProvider{}
}
type caldavProvider struct{}
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey }
func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
return dav.ParseInteractiveForm(r)
}

22
caldav/report.go Normal file
View file

@ -0,0 +1,22 @@
package caldav
import (
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-dav/internal/dav"
)
// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV
// produce visually identical reports. Downstream TLS probes attached via
// ctx.Related(dav.TLSRelatedKey) are folded in.
func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d dav.Observation
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
}
d.Kind = dav.KindCalDAV
return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey))
}

9
caldav/types.go Normal file
View file

@ -0,0 +1,9 @@
// Package caldav wires the CalDAV-specific options, collect pipeline,
// rules, and HTML report on top of the shared helpers in internal/dav.
package caldav
import "git.happydns.org/checker-dav/internal/dav"
const ObservationKey = "caldav"
type Data = dav.Observation

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, obs.Discovery.ContextURL, user, pass)
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil {
obs.Principal.Error = err.Error()
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.Principal.URL = principal
card, err := carddav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
home, err := card.FindAddressBookHomeSet(ctx, principal)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.HomeSet.URL = home
books, err := card.FindAddressBooks(ctx, home)
if err != nil {
obs.Collections.Error = err.Error()
obs.Report.Skipped = true
} else {
for _, b := range books {
item := dav.CollectionInfo{
Path: b.Path,
Name: b.Name,
Description: b.Description,
MaxResourceSize: b.MaxResourceSize,
}
for _, d := range b.SupportedAddressData {
item.SupportedAddressData = append(item.SupportedAddressData, d.ContentType+";"+d.Version)
}
obs.Collections.Items = append(obs.Collections.Items, item)
}
}
if len(obs.Collections.Items) > 0 {
first := obs.Collections.Items[0].Path
obs.Report.ProbePath = first
q := &carddav.AddressBookQuery{
DataRequest: carddav.AddressDataRequest{AllProp: true},
Limit: 1,
}
if _, err := card.QueryAddressBook(ctx, first, q); err != nil {
obs.Report.Error = err.Error()
} else {
obs.Report.QueryOK = true
}
} else {
obs.Report.Skipped = true
}
return obs, nil
}
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }

39
carddav/definition.go Normal file
View file

@ -0,0 +1,39 @@
package carddav
import (
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "built-in"
func (p *carddavProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "carddav",
Name: "CardDAV server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
// See caldav/definition.go for the rationale; abstract.CardDAV
// isn't in the catalog yet, so this is a no-op until it is.
ApplyToService: true,
LimitToServices: []string{"abstract.CardDAV"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKey},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: dav.UserOptions(),
DomainOpts: dav.DomainOptions(),
RunOpts: dav.RunOptions(),
},
Rules: dav.Rules(dav.KindCardDAV, ObservationKey),
Aggregator: dav.WorstStatus{},
Interval: &sdk.CheckIntervalSpec{
Min: 1 * time.Minute,
Max: 1 * time.Hour,
Default: 15 * time.Minute,
},
HasHTMLReport: true,
}
}

16
carddav/discovery.go Normal file
View file

@ -0,0 +1,16 @@
package carddav
import (
"fmt"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
obs, ok := data.(*dav.Observation)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
return dav.DiscoverEntries(obs), nil
}

20
carddav/provider.go Normal file
View file

@ -0,0 +1,20 @@
package carddav
import (
"net/http"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Provider() sdk.ObservationProvider { return &carddavProvider{} }
type carddavProvider struct{}
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey }
func (p *carddavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
func (p *carddavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
return dav.ParseInteractiveForm(r)
}

20
carddav/report.go Normal file
View file

@ -0,0 +1,20 @@
package carddav
import (
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-dav/internal/dav"
)
// GetHTMLReport: see the CalDAV sibling.
func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d dav.Observation
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
return "", fmt.Errorf("failed to unmarshal carddav report: %w", err)
}
d.Kind = dav.KindCardDAV
return dav.RenderReport(&d, "CardDAV Server", ctx.Related(dav.TLSRelatedKey))
}

9
carddav/types.go Normal file
View file

@ -0,0 +1,9 @@
// Package carddav: see the CalDAV sibling. The two share everything except
// the protocol-specific home-set and REPORT calls in collect.go.
package carddav
import "git.happydns.org/checker-dav/internal/dav"
const ObservationKey = "carddav"
type Data = dav.Observation

View file

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

View file

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

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.3.0
git.happydns.org/checker-tls v0.6.2
)
require (
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 // indirect
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 // indirect
github.com/emersion/go-webdav v0.7.0
github.com/teambition/rrule-go v1.8.2 // indirect
)

12
go.sum Normal file
View file

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

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

@ -0,0 +1,47 @@
package dav
import (
"net/http"
"net/url"
"strings"
"time"
)
// NewHTTPClient uses Go's default TLS validation; cert correctness is the
// dedicated TLS checker's job, not ours.
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
}
}
// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect
// to a different host won't leak credentials to a third party. Matches
// curl's behaviour without --location-trusted.
type basicAuthRoundTripper struct {
user, pass string
host string
next http.RoundTripper
}
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if strings.EqualFold(req.URL.Host, b.host) {
req.SetBasicAuth(b.user, b.pass)
}
return b.next.RoundTrip(req)
}
// WithBasicAuth attaches credentials scoped to the host of contextURL.
func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client {
nc := *c
base := c.Transport
if base == nil {
base = http.DefaultTransport
}
host := ""
if u, err := url.Parse(contextURL); err == nil {
host = u.Host
}
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, host: host, next: base}
return &nc
}

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

@ -0,0 +1,209 @@
package dav
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded
// in the result even on failure so the report can pinpoint the broken step.
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
res := DiscoveryResult{}
if explicitURL != "" {
res.ContextURL = explicitURL
res.Source = "explicit"
return res
}
// Always probe /.well-known even if SRV would suffice: it's the #1
// misconfig hotspot and we want to surface it.
wellKnown := "https://" + domain + kind.WellKnownPath()
res.WellKnownURL = wellKnown
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
res.WellKnownCode = code
res.WellKnownChain = chain
if err != nil {
res.WellKnownError = err.Error()
} else if ctxURL != "" {
res.ContextURL = ctxURL
res.Source = "well-known"
}
discoverSRV(ctx, kind, domain, &res)
if res.ContextURL == "" && len(res.SecureSRV) > 0 {
target := res.SecureSRV[0]
path := res.TXTPath
if path == "" {
path = "/"
}
res.ContextURL = srvURL(target, path, true)
res.Source = "srv-txt"
}
if res.ContextURL == "" && res.Error == "" {
res.Error = "could not resolve a context URL via /.well-known or SRV"
}
return res
}
// followWellKnown follows up to 5 redirects manually so we can record the
// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200
// at this position is the misconfig we want to flag.
func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
chain = make([]string, 0, 5)
cur := u
for i := 0; i < 5; i++ {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, cur, nil)
if reqErr != nil {
return "", chain, firstCode, reqErr
}
// Snapshot disables the client's own redirect-following so we can
// record each hop ourselves.
c := *client
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, doErr := c.Do(req)
if doErr != nil {
return "", chain, firstCode, doErr
}
resp.Body.Close()
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur))
if i == 0 {
firstCode = resp.StatusCode
}
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
loc := resp.Header.Get("Location")
if loc == "" {
return "", chain, firstCode, errors.New("redirect with empty Location header")
}
next, parseErr := resolveLocation(cur, loc)
if parseErr != nil {
return "", chain, firstCode, parseErr
}
cur = next
continue
}
if resp.StatusCode == http.StatusOK {
return cur, chain, firstCode, nil
}
return "", chain, firstCode, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return "", chain, firstCode, errors.New("too many redirects")
}
func resolveLocation(base, loc string) (string, error) {
baseURL, err := url.Parse(base)
if err != nil {
return "", err
}
locURL, err := url.Parse(loc)
if err != nil {
return "", err
}
return baseURL.ResolveReference(locURL).String(), nil
}
func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryResult) {
resolver := net.DefaultResolver
type srvResult struct {
records []SRVRecord
err error
}
secureCh := make(chan srvResult, 1)
plainCh := make(chan srvResult, 1)
go func() {
r, err := lookupSRV(ctx, resolver, kind.ServiceName(true), "tcp", domain)
secureCh <- srvResult{r, err}
}()
go func() {
r, err := lookupSRV(ctx, resolver, kind.ServiceName(false), "tcp", domain)
plainCh <- srvResult{r, err}
}()
secureRes := <-secureCh
if secureRes.err != nil && !isNoSuchHost(secureRes.err) {
res.SRVError = secureRes.err.Error()
}
res.SecureSRV = secureRes.records
plainRes := <-plainCh
if plainRes.err != nil && !isNoSuchHost(plainRes.err) && res.SRVError == "" {
res.SRVError = plainRes.err.Error()
}
res.PlaintextSRV = plainRes.records
var txtName string
if len(res.SecureSRV) > 0 {
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)
} else if len(res.PlaintextSRV) > 0 {
txtName = kind.ServiceName(false) + "._tcp." + trimTrailingDot(res.PlaintextSRV[0].Target)
}
if txtName != "" {
txts, err := resolver.LookupTXT(ctx, txtName)
if err != nil && !isNoSuchHost(err) {
res.TXTError = err.Error()
}
for _, t := range txts {
if strings.HasPrefix(t, "path=") {
res.TXTPath = strings.TrimPrefix(t, "path=")
break
}
}
}
}
func lookupSRV(ctx context.Context, r *net.Resolver, service, proto, name string) ([]SRVRecord, error) {
_, addrs, err := r.LookupSRV(ctx, strings.TrimPrefix(service, "_"), proto, name)
if err != nil {
return nil, err
}
out := make([]SRVRecord, 0, len(addrs))
for _, a := range addrs {
out = append(out, SRVRecord{
Target: trimTrailingDot(a.Target),
Port: a.Port,
Priority: a.Priority,
Weight: a.Weight,
})
}
return out, nil
}
func srvURL(r SRVRecord, path string, secure bool) string {
scheme := "https"
defaultPort := uint16(443)
if !secure {
scheme = "http"
defaultPort = 80
}
host := r.Target
if r.Port != defaultPort {
host = fmt.Sprintf("%s:%d", r.Target, r.Port)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return scheme + "://" + host + path
}
func trimTrailingDot(s string) string {
return strings.TrimSuffix(s, ".")
}
func isNoSuchHost(err error) bool {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return dnsErr.IsNotFound
}
return false
}

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

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

@ -0,0 +1,85 @@
package dav
import (
"log"
"net/url"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets
// are emitted alongside the context URL because they're the names operators
// must actually put on the certificate, and they often differ from the
// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120
// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split.
func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
if obs == nil || obs.Discovery.ContextURL == "" {
return nil
}
var out []sdk.DiscoveryEntry
seen := map[string]struct{}{}
add := func(host string, port uint16) {
if host == "" || port == 0 {
return
}
key := host + ":" + strconv.Itoa(int(port))
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: host,
Port: port,
SNI: host,
})
if err != nil {
log.Printf("checker-dav: contract.NewEntry(%s:%d): %v", host, port, err)
return
}
out = append(out, entry)
}
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
add(host, port)
}
// Every SRV target is reachable via priority/weight, so each one needs
// its own valid certificate.
for _, r := range obs.Discovery.SecureSRV {
port := r.Port
if port == 0 {
port = 443
}
add(r.Target, port)
}
return out
}
func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
u, err := url.Parse(raw)
if err != nil {
return "", 0, false
}
host = u.Hostname()
if host == "" {
return "", 0, false
}
if p := u.Port(); p != "" {
n, convErr := strconv.ParseUint(p, 10, 16)
if convErr != nil {
return "", 0, false
}
return host, uint16(n), true
}
switch u.Scheme {
case "https":
return host, 443, true
case "http":
return host, 80, true
}
return "", 0, false
}

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

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

@ -0,0 +1,94 @@
package dav
import (
"context"
"fmt"
"net/http"
"strings"
)
// ProbeOptions never treats a missing/incomplete DAV: header as a transport
// error: severity is the caller rule's decision, not ours.
func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
res := OptionsResult{}
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil)
if err != nil {
res.Error = err.Error()
return res, err
}
resp, err := client.Do(req)
if err != nil {
res.Error = err.Error()
return res, err
}
defer resp.Body.Close()
res.StatusCode = resp.StatusCode
res.Server = resp.Header.Get("Server")
res.DAVClasses = parseCSVHeader(resp.Header.Values("Dav"))
res.AllowMethods = parseCSVHeader(resp.Header.Values("Allow"))
for _, h := range resp.Header.Values("Www-Authenticate") {
if scheme := authScheme(h); scheme != "" {
res.AuthSchemes = appendUnique(res.AuthSchemes, scheme)
}
}
if res.StatusCode >= 400 {
res.Error = fmt.Sprintf("OPTIONS returned HTTP %d", res.StatusCode)
}
return res, nil
}
// HasCapability matches case-insensitively per RFC 4918 §10.1.
func (o OptionsResult) HasCapability(cap string) bool {
for _, c := range o.DAVClasses {
if strings.EqualFold(c, cap) {
return true
}
}
return false
}
func (o OptionsResult) AllowsMethod(m string) bool {
for _, a := range o.AllowMethods {
if strings.EqualFold(a, m) {
return true
}
}
return false
}
// parseCSVHeader merges repeated headers (net/http keeps them separate)
// into a single split-and-trimmed slice.
func parseCSVHeader(values []string) []string {
var out []string
for _, v := range values {
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
}
return out
}
func authScheme(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if i := strings.IndexAny(h, " \t"); i > 0 {
return h[:i]
}
return h
}
func appendUnique(s []string, v string) []string {
for _, x := range s {
if strings.EqualFold(x, v) {
return s
}
}
return append(s, v)
}

View file

@ -0,0 +1,125 @@
package dav
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func UserOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "username",
Type: "string",
Label: "Username",
Description: "Optional. Supplying credentials unlocks authenticated checks (principal, home-set, collections, report probe).",
},
{
Id: "password",
Type: "string",
Label: "Password or token",
Description: "Optional. Paired with the username for HTTP Basic authentication.",
Secret: true,
},
{
Id: "context_url",
Type: "string",
Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery. Use for servers with a non-standard layout.",
Placeholder: "https://dav.example.com/caldav/",
},
}
}
func DomainOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "domain_name",
Label: "Domain name",
AutoFill: sdk.AutoFillDomainName,
},
}
}
func RunOptions() []sdk.CheckerOptionDocumentation {
return []sdk.CheckerOptionDocumentation{
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}
// InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the
// standalone /check page. Discovery happens inside Collect, so all the
// human owes us is the domain.
func InteractiveForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain_name",
Type: "string",
Label: "Domain name",
Placeholder: "example.com",
Required: true,
},
{
Id: "username",
Type: "string",
Label: "Username",
Description: "Optional. Supplying credentials unlocks authenticated checks.",
},
{
Id: "password",
Type: "string",
Label: "Password or token",
Description: "Optional. Paired with the username for HTTP Basic auth.",
Secret: true,
},
{
Id: "context_url",
Type: "string",
Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery.",
Placeholder: "https://dav.example.com/caldav/",
},
{
Id: "timeout_seconds",
Type: "number",
Label: "Timeout (seconds)",
Description: "Per-request HTTP timeout.",
Default: float64(10),
},
}
}
func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain_name"))
if domain == "" {
return nil, errors.New("domain name is required")
}
opts := sdk.CheckerOptions{"domain_name": domain}
if v := strings.TrimSpace(r.FormValue("username")); v != "" {
opts["username"] = v
}
if v := r.FormValue("password"); v != "" {
opts["password"] = v
}
if v := strings.TrimSpace(r.FormValue("context_url")); v != "" {
opts["context_url"] = v
}
if v := strings.TrimSpace(r.FormValue("timeout_seconds")); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout_seconds"] = f
}
return opts, nil
}

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
}

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

@ -0,0 +1,154 @@
package dav
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// FindPrincipal requires authenticated credentials on client.
func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
body := `<?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
}
// multistatus is intentionally a permissive subset: unknown elements are
// ignored so server-specific extensions don't break parsing.
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Response []msResponse `xml:"response"`
}
type msResponse struct {
Href string `xml:"href"`
Propstat []propstat `xml:"propstat"`
}
type propstat struct {
Prop prop `xml:"prop"`
Status string `xml:"status"`
}
type prop struct {
Props []msProp `xml:",any"`
}
type msProp struct {
XMLName xml.Name
Hrefs []string `xml:"href"`
}
func (p msProp) firstHref() string {
if len(p.Hrefs) > 0 {
return p.Hrefs[0]
}
return ""
}
func (m *multistatus) principalHref() []string {
var out []string
for _, r := range m.Response {
for _, ps := range r.Propstat {
if !strings.Contains(ps.Status, "200") {
continue
}
for _, pr := range ps.Prop.Props {
if pr.XMLName.Local == "current-user-principal" {
if h := pr.firstHref(); h != "" {
out = append(out, h)
}
}
}
}
}
return out
}
// propFind is tuned for small single-resource probes; not for large listings.
func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
req.Header.Set("Depth", depth)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 10 MiB cap: probes here read a handful of props on one resource; more
// is either misbehaviour or an attempt at memory exhaustion.
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("PROPFIND returned HTTP %d", resp.StatusCode)
}
var ms multistatus
if err := xml.Unmarshal(data, &ms); err != nil {
return nil, fmt.Errorf("invalid multistatus: %w", err)
}
return &ms, nil
}
func resolveReference(base, ref string) string {
r, err := url.Parse(ref)
if err != nil {
return ref
}
b, err := url.Parse(base)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}

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

@ -0,0 +1,459 @@
package dav
import (
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderReport foregrounds the high-frequency failure modes (well-known
// misconfig, missing DAV class, missing credentials, downstream TLS issues)
// before the full per-phase evidence. tlsRelated is what the host stitched
// from checker-tls; nil simply omits the TLS section.
func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) {
data := buildReportData(obs, title, tlsRelated)
var buf strings.Builder
if err := reportTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render dav report: %w", err)
}
return buf.String(), nil
}
type reportData struct {
Title string
Domain string
Verdict string
VerdictCls string
Callouts []calloutData
Phases []phaseData
Raw string
ShowSched bool
Scheduling *SchedulingResult
TLSSummaries []TLSSummary
}
type calloutData struct {
Title string
Body string
Severity string // "warn" or "crit"
}
type phaseData struct {
Title string
Items []phaseItem
Open bool
}
type phaseItem struct {
Label string
Status string // "ok", "warn", "fail", "unk", "info"
Detail string
Mono string
}
func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData {
d := reportData{
Title: title,
Domain: o.Domain,
ShowSched: o.Kind == KindCalDAV,
Scheduling: o.Scheduling,
}
d.Callouts = buildCallouts(o)
d.Phases = buildPhases(o)
tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
d.TLSSummaries = tlsSummaries
for _, c := range tlsCallouts {
d.Callouts = append(d.Callouts, calloutData{
Severity: c.Severity,
Title: c.Title,
Body: c.Body,
})
}
if len(tlsSummaries) > 0 {
d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries))
}
switch {
case hasSeverity(d.Phases, "fail"):
d.Verdict = "Critical issues detected"
d.VerdictCls = "fail"
case hasSeverity(d.Phases, "warn"):
d.Verdict = "Minor issues detected"
d.VerdictCls = "warn"
case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"):
d.Verdict = "Could not evaluate without credentials"
d.VerdictCls = "unk"
default:
d.Verdict = "All checks passed"
d.VerdictCls = "ok"
}
return d
}
func hasSeverity(phases []phaseData, sev string) bool {
for _, p := range phases {
for _, it := range p.Items {
if it.Status == sev {
return true
}
}
}
return false
}
// buildCallouts pulls common misconfigurations to the top so operators
// don't have to expand the phase tree to find the fix.
func buildCallouts(o *Observation) []calloutData {
var out []calloutData
disc := o.Discovery
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
out = append(out, calloutData{
Severity: "warn",
Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL),
Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)),
})
}
if disc.ContextURL == "" {
out = append(out, calloutData{
Severity: "crit",
Title: "Service discovery failed",
Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain),
})
}
if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 {
out = append(out, calloutData{
Severity: "warn",
Title: "Plaintext SRV record without HTTPS counterpart",
Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)),
})
}
if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "crit",
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()),
Body: fmt.Sprintf("The DAV: response header is %q. This endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
})
}
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "warn",
Title: "Authenticated checks were skipped",
Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.",
})
}
return out
}
func exampleContextURL(k Kind) string {
switch k {
case KindCalDAV:
return "/dav/calendars/"
case KindCardDAV:
return "/dav/addressbooks/"
}
return "/dav/"
}
func buildPhases(o *Observation) []phaseData {
var phases []phaseData
// Phase 1: Discovery
discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect",
wellKnownStatus(o.Discovery),
o.Discovery.WellKnownError,
summariseChain(o.Discovery.WellKnownChain),
))
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)),
srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError),
o.Discovery.SRVError,
summariseSRV(o.Discovery.SecureSRV),
))
if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" {
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)),
plainSRVStatus(o.Discovery.PlaintextSRV),
"",
summariseSRV(o.Discovery.PlaintextSRV),
))
}
if o.Discovery.TXTPath != "" {
discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath))
}
discovery.Items = append(discovery.Items, itemFor(
"Context URL",
contextStatus(o.Discovery.ContextURL),
"",
o.Discovery.ContextURL,
))
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery)
// Phase 2: Transport + OPTIONS
transport := phaseData{Title: "Transport & OPTIONS"}
transport.Items = append(transport.Items,
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")),
itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")),
)
if len(o.Options.AuthSchemes) > 0 {
transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", ")))
}
if o.Options.Server != "" {
transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server))
}
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport)
// Phase 3: Authenticated
auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error),
collectionsItemFor(o.Collections, o.Kind),
reportItemFor(o.Report),
)
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth)
// Phase 4: Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised {
sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised"))
} else {
sched.Items = append(sched.Items,
itemFor("calendar-schedule advertised", "ok", "", "advertised"),
authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error),
authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""),
)
}
sched.Open = hasItemSeverity(sched.Items, "warn", "fail")
phases = append(phases, sched)
}
return phases
}
// buildTLSPhase auto-opens when anything is non-OK so the failure is
// visible without an extra click.
func buildTLSPhase(summaries []TLSSummary) phaseData {
p := phaseData{Title: "TLS (from checker-tls)"}
for _, s := range summaries {
label := s.Address
if s.TLSVersion != "" {
label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion)
}
p.Items = append(p.Items, phaseItem{
Label: label,
Status: s.Status,
Detail: s.Detail,
})
}
p.Open = hasItemSeverity(p.Items, "warn", "fail")
return p
}
func wellKnownStatus(d DiscoveryResult) string {
if d.Source == "explicit" {
return "info"
}
if d.WellKnownCode == 200 {
return "warn"
}
if d.WellKnownCode >= 300 && d.WellKnownCode < 400 {
return "ok"
}
return "fail"
}
func srvStatus(rec []SRVRecord, errStr string) string {
if len(rec) > 0 {
return "ok"
}
if errStr != "" {
return "fail"
}
return "warn"
}
func plainSRVStatus(rec []SRVRecord) string {
if len(rec) > 0 {
return "warn" // plaintext SRV is legacy / discouraged
}
return "ok"
}
func contextStatus(u string) string {
if u == "" {
return "fail"
}
return "ok"
}
func davStatus(o *Observation, r OptionsResult) string {
if r.HasCapability(o.Kind.RequiredCapability()) {
return "ok"
}
return "fail"
}
func methodsStatus(r OptionsResult) string {
if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") {
return "ok"
}
return "warn"
}
func boolStatus(ok bool, failSev string) string {
if ok {
return "ok"
}
return failSev
}
func authItemFor(label, value string, skipped bool, errStr string) phaseItem {
switch {
case skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case errStr != "":
return phaseItem{Label: label, Status: "fail", Detail: errStr}
case value == "":
return phaseItem{Label: label, Status: "warn", Detail: "not returned"}
default:
return phaseItem{Label: label, Status: "ok", Mono: value}
}
}
func collectionsItemFor(c CollectionsResult, k Kind) phaseItem {
label := "Calendars"
if k == KindCardDAV {
label = "Address books"
}
switch {
case c.Skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case c.Error != "":
return phaseItem{Label: label, Status: "fail", Detail: c.Error}
case len(c.Items) == 0:
return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"}
default:
names := make([]string, 0, len(c.Items))
for _, it := range c.Items {
n := it.Name
if n == "" {
n = it.Path
}
names = append(names, n)
}
return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")}
}
}
func reportItemFor(r ReportResult) phaseItem {
switch {
case r.Skipped:
return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"}
case r.Error != "":
return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error}
case !r.QueryOK:
return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"}
default:
return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath}
}
}
func itemFor(label, status, errStr, mono string) phaseItem {
it := phaseItem{Label: label, Status: status, Mono: mono}
if errStr != "" {
it.Detail = errStr
}
return it
}
func hasItemSeverity(items []phaseItem, sevs ...string) bool {
for _, it := range items {
for _, s := range sevs {
if it.Status == s {
return true
}
}
}
return false
}
func summariseChain(chain []string) string {
return strings.Join(chain, " → ")
}
func summariseSRV(rec []SRVRecord) string {
if len(rec) == 0 {
return ""
}
parts := make([]string, 0, len(rec))
for _, r := range rec {
parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight))
}
return strings.Join(parts, "; ")
}
var reportTemplate = template.Must(template.New("dav").Parse(`<!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; }
`

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

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

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

@ -0,0 +1,257 @@
package dav
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey matches the cross-checker convention in
// happydomain3/docs/checker-discovery-endpoint.md.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView decodes only the fields we actually use; the full TLS schema
// belongs to checker-tls and we don't want to track its evolution here.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
// Alternative shape used by the reference checker-tls payload sketched
// in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}.
Cert *struct {
NotAfter time.Time `json:"notAfter,omitempty"`
SANMatch *bool `json:"sanMatch,omitempty"`
ChainValid *bool `json:"chainValid,omitempty"`
DaysRemaining *int `json:"daysRemaining,omitempty"`
SubjectCN string `json:"subjectCN,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
} `json:"cert,omitempty"`
Rules []struct {
Code string `json:"code,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
} `json:"rules,omitempty"`
Issues []struct {
Code string `json:"code,omitempty"`
Severity string `json:"severity,omitempty"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
// certExpiry hides the two payload shapes from callers.
func (v *tlsProbeView) certExpiry() (time.Time, bool) {
if !v.NotAfter.IsZero() {
return v.NotAfter, true
}
if v.Cert != nil && !v.Cert.NotAfter.IsZero() {
return v.Cert.NotAfter, true
}
return time.Time{}, false
}
func (v *tlsProbeView) hostnameOK() (bool, bool) {
if v.HostnameMatch != nil {
return *v.HostnameMatch, true
}
if v.Cert != nil && v.Cert.SANMatch != nil {
return *v.Cert.SANMatch, true
}
return false, false
}
func (v *tlsProbeView) chainOK() (bool, bool) {
if v.ChainValid != nil {
return *v.ChainValid, true
}
if v.Cert != nil && v.Cert.ChainValid != nil {
return *v.Cert.ChainValid, true
}
return false, false
}
// parseTLSRelated accepts both the keyed {"probes": {"<ref>": …}} shape
// (current checker-tls output, picked by r.Ref) and a bare top-level probe
// (legacy/test fixtures). Returns nil for anything else.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if p, ok := keyed.Probes[r.Ref]; ok {
return &p
}
return nil
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
type TLSSummary struct {
Address string
TLSVersion string
Status string // "ok", "warn", "fail", "info"
Detail string
NotAfter time.Time
DaysRemaining int
}
type tlsCallout struct {
Severity string // "warn" or "crit"
Title string
Body string
}
func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
sum := buildTLSSummary(v)
summaries = append(summaries, sum)
callouts = append(callouts, buildTLSCallouts(v, sum.Address)...)
}
return summaries, callouts
}
func buildTLSSummary(v *tlsProbeView) TLSSummary {
s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"}
if t, ok := v.certExpiry(); ok {
s.NotAfter = t
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
s.DaysRemaining = days
switch {
case days < 0:
s.Status = "fail"
s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days)
case days < 14:
s.Status = "warn"
s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days)
default:
s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days)
}
}
if ok, has := v.hostnameOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate does not cover the endpoint hostname"
}
if ok, has := v.chainOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate chain validation failed"
}
// Explicit issues from the TLS checker outrank our inferred status.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
switch sev {
case "crit":
s.Status = "fail"
case "warn":
if s.Status != "fail" {
s.Status = "warn"
}
}
if iss.Message != "" {
s.Detail = iss.Message
}
}
return s
}
func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
var out []tlsCallout
// Structured issues from the TLS checker are the preferred source.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
if sev != "crit" && sev != "warn" {
continue
}
callout := tlsCallout{
Severity: sev,
Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)),
}
if callout.Title == "TLS on "+addr+": " {
callout.Title = "TLS issue on " + addr
}
if iss.Fix != "" {
callout.Body = iss.Fix
} else {
callout.Body = "See the TLS checker report for details."
}
out = append(out, callout)
}
if len(out) > 0 {
return out
}
// Fallback: synthesize callouts from structured flags.
if t, ok := v.certExpiry(); ok {
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
switch {
case days < 0:
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate on %s has expired", addr),
Body: fmt.Sprintf("Renew it. Clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)),
})
case days < 14:
out = append(out, tlsCallout{
Severity: "warn",
Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days),
Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)),
})
}
}
if ok, has := v.chainOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Broken certificate chain on %s", addr),
Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.",
})
}
if ok, has := v.hostnameOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate does not cover %s", addr),
Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.",
})
}
return out
}

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

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

@ -0,0 +1,149 @@
// Package dav holds code shared by the CalDAV and CardDAV checkers:
// discovery, OPTIONS probing, PROPFIND helpers, and report rendering.
package dav
import "time"
// Kind is carried end-to-end through a run so shared helpers branch on it
// rather than duplicating per-protocol code.
type Kind string
const (
KindCalDAV Kind = "caldav"
KindCardDAV Kind = "carddav"
)
// ServiceName returns the RFC 6764 SRV label, with the leading "_" but
// without the "_tcp" suffix.
func (k Kind) ServiceName(secure bool) string {
switch k {
case KindCalDAV:
if secure {
return "_caldavs"
}
return "_caldav"
case KindCardDAV:
if secure {
return "_carddavs"
}
return "_carddav"
}
return ""
}
func (k Kind) WellKnownPath() string {
return "/.well-known/" + string(k)
}
// RequiredCapability is the DAV: header token a compliant server must
// advertise.
func (k Kind) RequiredCapability() string {
switch k {
case KindCalDAV:
return "calendar-access"
case KindCardDAV:
return "addressbook"
}
return ""
}
// Observation is what each checker persists. Scheduling is CalDAV-only and
// left nil for CardDAV.
type Observation struct {
Kind Kind `json:"kind"`
Domain string `json:"domain"`
HasCredentials bool `json:"has_credentials"`
Discovery DiscoveryResult `json:"discovery"`
Transport TransportResult `json:"transport"`
Options OptionsResult `json:"options"`
Principal PrincipalResult `json:"principal"`
HomeSet HomeSetResult `json:"home_set"`
Collections CollectionsResult `json:"collections"`
Report ReportResult `json:"report"`
Scheduling *SchedulingResult `json:"scheduling,omitempty"`
CollectedAt time.Time `json:"collected_at"`
}
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
}
// DiscoveryResult records every signal seen during lookup, even on failure,
// so the report can pinpoint which leg of discovery broke.
type DiscoveryResult struct {
SecureSRV []SRVRecord `json:"secure_srv,omitempty"`
PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"`
SRVError string `json:"srv_error,omitempty"`
TXTPath string `json:"txt_path,omitempty"`
TXTError string `json:"txt_error,omitempty"`
WellKnownURL string `json:"well_known_url,omitempty"`
WellKnownCode int `json:"well_known_code,omitempty"`
WellKnownChain []string `json:"well_known_chain,omitempty"`
WellKnownError string `json:"well_known_error,omitempty"`
ContextURL string `json:"context_url,omitempty"`
Source string `json:"source,omitempty"` // "explicit", "well-known", "srv-txt"
Error string `json:"error,omitempty"`
}
// TransportResult is intentionally minimal: cert validation is out of scope
// here, a dedicated TLS checker owns it.
type TransportResult struct {
Reached bool `json:"reached"`
Error string `json:"error,omitempty"`
}
type OptionsResult struct {
StatusCode int `json:"status_code"`
DAVClasses []string `json:"dav_classes,omitempty"`
AllowMethods []string `json:"allow_methods,omitempty"`
AuthSchemes []string `json:"auth_schemes,omitempty"`
Server string `json:"server,omitempty"`
Error string `json:"error,omitempty"`
}
// PrincipalResult.Skipped is set when no credentials were supplied; the
// rule turns that into StatusUnknown rather than a failure.
type PrincipalResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
type HomeSetResult struct {
Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"`
}
type CollectionInfo struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
MaxResourceSize int64 `json:"max_resource_size,omitempty"`
SupportedComponentSet []string `json:"supported_component_set,omitempty"` // CalDAV only
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
}
type CollectionsResult struct {
Skipped bool `json:"skipped,omitempty"`
Items []CollectionInfo `json:"items,omitempty"`
Error string `json:"error,omitempty"`
}
type ReportResult struct {
Skipped bool `json:"skipped,omitempty"`
QueryOK bool `json:"query_ok,omitempty"`
ProbePath string `json:"probe_path,omitempty"`
Error string `json:"error,omitempty"`
}
// SchedulingResult is CalDAV-only.
type SchedulingResult struct {
Advertised bool `json:"advertised"`
InboxURL string `json:"inbox_url,omitempty"`
OutboxURL string `json:"outbox_url,omitempty"`
Error string `json:"error,omitempty"`
}

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

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

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

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