Compare commits

...

No commits in common. "master" and "v0.1.0" have entirely different histories.

35 changed files with 318 additions and 377 deletions

View file

@ -1,22 +0,0 @@
image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-dav:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

View file

@ -1,187 +0,0 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dav
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dav
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-dav
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-dav
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -11,8 +11,5 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che
FROM scratch FROM scratch
COPY --from=builder /checker /checker COPY --from=builder /checker /checker
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker", "-healthcheck"]
ENTRYPOINT ["/checker"] ENTRYPOINT ["/checker"]

2
NOTICE
View file

@ -1,4 +1,4 @@
checker-dav checker-dummy
Copyright (c) 2026 The happyDomain Authors Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE). This product is licensed under the MIT License (see LICENSE).

View file

@ -38,27 +38,27 @@ view; the default is a JSON metrics dump.
Both checkers accept the same options: Both checkers accept the same options:
- `domain_name` (auto-filled): required - `domain_name` (auto-filled) required
- `username`, `password`: optional Basic credentials; unlock authenticated - `username`, `password` optional Basic credentials; unlock authenticated
checks (principal, home-set, collections, REPORT probe) checks (principal, home-set, collections, REPORT probe)
- `context_url`: optional explicit override, bypasses `/.well-known` + SRV - `context_url` optional explicit override, bypasses `/.well-known` + SRV
- `timeout_seconds`: per-request HTTP timeout, default 10 - `timeout_seconds` per-request HTTP timeout, default 10
## What is checked ## What is checked
1. **Discovery**: `/.well-known/{caldav,carddav}` (must 3xx, not 200), 1. **Discovery** `/.well-known/{caldav,carddav}` (must 3xx, not 200),
`_caldavs._tcp` / `_carddavs._tcp` SRV, TXT `path=` hint. `_caldavs._tcp` / `_carddavs._tcp` SRV, TXT `path=` hint.
2. **Transport**: HTTPS reachable. TLS certificate validation is 2. **Transport** HTTPS reachable. TLS certificate validation is
deliberately out of scope; a dedicated TLS checker covers that. deliberately out of scope a dedicated TLS checker covers that.
3. **OPTIONS**: `DAV:` advertises `calendar-access` or `addressbook`; Allow 3. **OPTIONS** `DAV:` advertises `calendar-access` or `addressbook`; Allow
includes `PROPFIND` and `REPORT`; auth schemes captured for info. includes `PROPFIND` and `REPORT`; auth schemes captured for info.
4. **Principal**: PROPFIND `current-user-principal` (auth required). 4. **Principal** PROPFIND `current-user-principal` (auth required).
5. **Home-set**: `calendar-home-set` / `addressbook-home-set`. 5. **Home-set** `calendar-home-set` / `addressbook-home-set`.
6. **Collections**: enumerate, record properties (`supported-calendar-component-set`, 6. **Collections** enumerate, record properties (`supported-calendar-component-set`,
`supported-address-data`, display name, description, max size). `supported-address-data`, display name, description, max size).
7. **REPORT probe**: issue a minimal `calendar-query` / `addressbook-query` 7. **REPORT probe** issue a minimal `calendar-query` / `addressbook-query`
against the first collection. against the first collection.
8. **Scheduling** (CalDAV only): if `calendar-schedule` is advertised, 8. **Scheduling** (CalDAV only) if `calendar-schedule` is advertised,
verify `schedule-inbox-URL` and `schedule-outbox-URL` on the principal. verify `schedule-inbox-URL` and `schedule-outbox-URL` on the principal.
The HTML report surfaces the most common failures at the top as callouts: The HTML report surfaces the most common failures at the top as callouts:
@ -71,5 +71,5 @@ The HTML report surfaces the most common failures at the top as callouts:
## Dependencies ## Dependencies
- [`github.com/emersion/go-webdav`](https://github.com/emersion/go-webdav): CalDAV/CardDAV client - [`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 - [`git.happydns.org/checker-sdk-go`](https://git.happydns.org/happyDomain/checker-sdk-go) checker SDK

View file

@ -11,9 +11,11 @@ import (
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
) )
// Collect is intentionally resilient: each phase records its outcome and we // Collect runs the full CalDAV probe pipeline for the target domain.
// keep going as long as the next phase has something to work with. Rules //
// later turn the captured state into CheckStates. // 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) { func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain_name") domain, _ := sdk.GetOption[string](opts, "domain_name")
user, _ := sdk.GetOption[string](opts, "username") user, _ := sdk.GetOption[string](opts, "username")
@ -36,13 +38,13 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
anonClient := dav.NewHTTPClient(timeout) anonClient := dav.NewHTTPClient(timeout)
// Phase 1: Discovery // Phase 1 Discovery
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit) obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit)
if obs.Discovery.ContextURL == "" { if obs.Discovery.ContextURL == "" {
return obs, nil return obs, nil
} }
// Phase 2: Transport + OPTIONS (no auth required) // Phase 2 Transport + OPTIONS (no auth required)
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes obs.Options = optsRes
if err != nil { if err != nil {
@ -52,7 +54,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
obs.Transport = dav.TransportResult{Reached: true} obs.Transport = dav.TransportResult{Reached: true}
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule") obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
// Phase 3: Authenticated probes // Phase 3 Authenticated probes
if !obs.HasCredentials { if !obs.HasCredentials {
obs.Principal.Skipped = true obs.Principal.Skipped = true
obs.HomeSet.Skipped = true obs.HomeSet.Skipped = true
@ -61,8 +63,9 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
return obs, nil return obs, nil
} }
authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, user, pass) authClient := dav.WithBasicAuth(anonClient, user, pass)
// Principal.
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL) principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil { if err != nil {
obs.Principal.Error = err.Error() obs.Principal.Error = err.Error()
@ -73,6 +76,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
} }
obs.Principal.URL = principal obs.Principal.URL = principal
// Home-set (via go-webdav's CalDAV client).
cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL) cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
if err != nil { if err != nil {
obs.HomeSet.Error = err.Error() obs.HomeSet.Error = err.Error()
@ -89,6 +93,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
} }
obs.HomeSet.URL = home obs.HomeSet.URL = home
// Collections.
calendars, err := cal.FindCalendars(ctx, home) calendars, err := cal.FindCalendars(ctx, home)
if err != nil { if err != nil {
obs.Collections.Error = err.Error() obs.Collections.Error = err.Error()
@ -105,8 +110,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
} }
} }
// Empty calendar-query against the first calendar: cheapest probe that // Report probe — empty calendar-query against the first calendar.
// still exercises the REPORT pipeline end-to-end.
if len(obs.Collections.Items) > 0 { if len(obs.Collections.Items) > 0 {
first := obs.Collections.Items[0].Path first := obs.Collections.Items[0].Path
obs.Report.ProbePath = first obs.Report.ProbePath = first
@ -127,6 +131,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
obs.Report.Skipped = true obs.Report.Skipped = true
} }
// Scheduling inbox/outbox — only probe if advertised.
if obs.Scheduling.Advertised { if obs.Scheduling.Advertised {
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal) inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
if err != nil { if err != nil {
@ -139,4 +144,6 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
return obs, nil 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 } func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }

View file

@ -7,15 +7,28 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is overridden at link time by the standalone binary via -ldflags. // Version is the checker version reported in CheckerDefinition.Version.
// Overridden at link time by the standalone binary via -ldflags.
var Version = "built-in" var Version = "built-in"
func (p *caldavProvider) Definition() *sdk.CheckerDefinition { // Definition returns the CheckerDefinition for the CalDAV checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "caldav", ID: "caldav",
Name: "CalDAV server", Name: "CalDAV server",
Version: Version, Version: Version,
Availability: sdk.CheckerAvailability{ 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, ApplyToService: true,
LimitToServices: []string{"abstract.CalDAV"}, LimitToServices: []string{"abstract.CalDAV"},
}, },

View file

@ -7,6 +7,9 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" 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) { func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
obs, ok := data.(*dav.Observation) obs, ok := data.(*dav.Observation)
if !ok { if !ok {

View file

@ -7,9 +7,12 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Provider's return value also satisfies CheckerDefinitionProvider, // Provider returns the CalDAV observation provider.
// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for //
// those at runtime. // 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 { func Provider() sdk.ObservationProvider {
return &caldavProvider{} return &caldavProvider{}
} }
@ -18,6 +21,8 @@ type caldavProvider struct{}
func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey } 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) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }
func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {

View file

@ -9,9 +9,16 @@ import (
"git.happydns.org/checker-dav/internal/dav" "git.happydns.org/checker-dav/internal/dav"
) )
// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV // GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
// produce visually identical reports. Downstream TLS probes attached via //
// ctx.Related(dav.TLSRelatedKey) are folded in. // 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) { func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d dav.Observation var d dav.Observation
if err := json.Unmarshal(ctx.Data(), &d); err != nil { if err := json.Unmarshal(ctx.Data(), &d); err != nil {

View file

@ -1,9 +1,17 @@
// Package caldav wires the CalDAV-specific options, collect pipeline, // Package checker (imported as caldav by the standalone binary) implements
// rules, and HTML report on top of the shared helpers in internal/dav. // 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 package caldav
import "git.happydns.org/checker-dav/internal/dav" import "git.happydns.org/checker-dav/internal/dav"
// ObservationKey identifies CalDAV observations in happyDomain's store.
const ObservationKey = "caldav" const ObservationKey = "caldav"
// Data is the persisted observation shape. Callers read it back via
// obs.Get(ctx, ObservationKey, &Data).
type Data = dav.Observation type Data = dav.Observation

View file

@ -32,13 +32,13 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
anonClient := dav.NewHTTPClient(timeout) anonClient := dav.NewHTTPClient(timeout)
// Phase 1: Discovery // Phase 1 Discovery
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit) obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit)
if obs.Discovery.ContextURL == "" { if obs.Discovery.ContextURL == "" {
return obs, nil return obs, nil
} }
// Phase 2: OPTIONS // Phase 2 OPTIONS
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes obs.Options = optsRes
if err != nil { if err != nil {
@ -47,7 +47,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
} }
obs.Transport = dav.TransportResult{Reached: true} obs.Transport = dav.TransportResult{Reached: true}
// Phase 3: Authenticated // Phase 3 Authenticated
if !obs.HasCredentials { if !obs.HasCredentials {
obs.Principal.Skipped = true obs.Principal.Skipped = true
obs.HomeSet.Skipped = true obs.HomeSet.Skipped = true
@ -56,7 +56,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
return obs, nil return obs, nil
} }
authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, user, pass) authClient := dav.WithBasicAuth(anonClient, user, pass)
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL) principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil { if err != nil {

View file

@ -9,12 +9,21 @@ import (
var Version = "built-in" var Version = "built-in"
func (p *carddavProvider) Definition() *sdk.CheckerDefinition { func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{ return &sdk.CheckerDefinition{
ID: "carddav", ID: "carddav",
Name: "CardDAV server", Name: "CardDAV server",
Version: Version, Version: Version,
Availability: sdk.CheckerAvailability{ 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, ApplyToService: true,
LimitToServices: []string{"abstract.CardDAV"}, LimitToServices: []string{"abstract.CardDAV"},
}, },

View file

@ -7,6 +7,8 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" 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) { func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
obs, ok := data.(*dav.Observation) obs, ok := data.(*dav.Observation)
if !ok { if !ok {

View file

@ -12,6 +12,7 @@ func Provider() sdk.ObservationProvider { return &carddavProvider{} }
type carddavProvider struct{} type carddavProvider struct{}
func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey } 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) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() }

View file

@ -9,7 +9,9 @@ import (
"git.happydns.org/checker-dav/internal/dav" "git.happydns.org/checker-dav/internal/dav"
) )
// GetHTMLReport: see the CalDAV sibling. // 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) { func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d dav.Observation var d dav.Observation
if err := json.Unmarshal(ctx.Data(), &d); err != nil { if err := json.Unmarshal(ctx.Data(), &d); err != nil {

View file

@ -1,5 +1,7 @@
// Package carddav: see the CalDAV sibling. The two share everything except // Package checker (imported as carddav) implements the CardDAV compliance
// the protocol-specific home-set and REPORT calls in collect.go. // 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 package carddav
import "git.happydns.org/checker-dav/internal/dav" import "git.happydns.org/checker-dav/internal/dav"

View file

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

View file

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

4
go.mod
View file

@ -3,8 +3,8 @@ module git.happydns.org/checker-dav
go 1.25.0 go 1.25.0
require ( require (
git.happydns.org/checker-sdk-go v1.5.0 git.happydns.org/checker-sdk-go v1.2.0
git.happydns.org/checker-tls v0.6.2 git.happydns.org/checker-tls v0.2.0
) )
require ( require (

8
go.sum
View file

@ -1,7 +1,7 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= 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 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= 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 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA=

View file

@ -2,46 +2,38 @@ package dav
import ( import (
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
) )
// NewHTTPClient uses Go's default TLS validation; cert correctness is the // NewHTTPClient returns an http.Client with a sane default transport for
// dedicated TLS checker's job, not ours. // 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 { func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{ return &http.Client{
Timeout: timeout, Timeout: timeout,
} }
} }
// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect // basicAuthRoundTripper injects HTTP Basic credentials on every request so
// to a different host won't leak credentials to a third party. Matches // callers can pass the same client through go-webdav's own API without losing
// curl's behaviour without --location-trusted. // auth on internal redirects.
type basicAuthRoundTripper struct { type basicAuthRoundTripper struct {
user, pass string user, pass string
host string
next http.RoundTripper next http.RoundTripper
} }
func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if strings.EqualFold(req.URL.Host, b.host) {
req.SetBasicAuth(b.user, b.pass) req.SetBasicAuth(b.user, b.pass)
}
return b.next.RoundTrip(req) return b.next.RoundTrip(req)
} }
// WithBasicAuth attaches credentials scoped to the host of contextURL. // WithBasicAuth clones c and attaches Basic credentials to the transport.
func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client { func WithBasicAuth(c *http.Client, user, pass string) *http.Client {
nc := *c nc := *c
base := c.Transport base := c.Transport
if base == nil { if base == nil {
base = http.DefaultTransport base = http.DefaultTransport
} }
host := "" nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base}
if u, err := url.Parse(contextURL); err == nil {
host = u.Host
}
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, host: host, next: base}
return &nc return &nc
} }

View file

@ -10,8 +10,13 @@ import (
"strings" "strings"
) )
// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded // Discover resolves the DAV context URL for domain following RFC 6764:
// in the result even on failure so the report can pinpoint the broken step. // /.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 { func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
res := DiscoveryResult{} res := DiscoveryResult{}
@ -21,8 +26,8 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
return res return res
} }
// Always probe /.well-known even if SRV would suffice: it's the #1 // 1. /.well-known — this is the #1 misconfig hotspot, so we always probe
// misconfig hotspot and we want to surface it. // it even if SRV below might have worked, to surface the mistake.
wellKnown := "https://" + domain + kind.WellKnownPath() wellKnown := "https://" + domain + kind.WellKnownPath()
res.WellKnownURL = wellKnown res.WellKnownURL = wellKnown
ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown) ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown)
@ -35,6 +40,7 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
res.Source = "well-known" res.Source = "well-known"
} }
// 2. SRV + TXT fallback (also informational even when well-known worked).
discoverSRV(ctx, kind, domain, &res) discoverSRV(ctx, kind, domain, &res)
if res.ContextURL == "" && len(res.SecureSRV) > 0 { if res.ContextURL == "" && len(res.SecureSRV) > 0 {
@ -53,9 +59,10 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
return res return res
} }
// followWellKnown follows up to 5 redirects manually so we can record the // followWellKnown issues a GET against path and follows up to 5 redirects
// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200 // manually so we can capture the redirect chain. The well-known endpoint
// at this position is the misconfig we want to flag. // 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) { func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) {
chain = make([]string, 0, 5) chain = make([]string, 0, 5)
cur := u cur := u
@ -64,8 +71,7 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU
if reqErr != nil { if reqErr != nil {
return "", chain, firstCode, reqErr return "", chain, firstCode, reqErr
} }
// Snapshot disables the client's own redirect-following so we can // Use a no-redirect client snapshot so we observe each hop.
// record each hop ourselves.
c := *client c := *client
c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }
resp, doErr := c.Do(req) resp, doErr := c.Do(req)
@ -74,6 +80,9 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU
} }
resp.Body.Close() resp.Body.Close()
chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur)) 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 { if i == 0 {
firstCode = resp.StatusCode firstCode = resp.StatusCode
} }
@ -142,6 +151,7 @@ func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryRe
} }
res.PlaintextSRV = plainRes.records res.PlaintextSRV = plainRes.records
// Pull the TXT path hint from whichever SRV target we plan to use.
var txtName string var txtName string
if len(res.SecureSRV) > 0 { if len(res.SecureSRV) > 0 {
txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target) txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target)

View file

@ -9,11 +9,19 @@ import (
tlsct "git.happydns.org/checker-tls/contract" tlsct "git.happydns.org/checker-tls/contract"
) )
// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets // DiscoverEntries derives TLS DiscoveryEntry records worth handing off to
// are emitted alongside the context URL because they're the names operators // downstream checkers (notably checker-tls) from a completed Observation.
// must actually put on the certificate, and they often differ from the //
// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120 // A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint,
// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split. // 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 { func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
if obs == nil || obs.Discovery.ContextURL == "" { if obs == nil || obs.Discovery.ContextURL == "" {
return nil return nil
@ -42,12 +50,13 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
out = append(out, entry) out = append(out, entry)
} }
// Primary endpoint: the resolved context URL.
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok { if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
add(host, port) add(host, port)
} }
// Every SRV target is reachable via priority/weight, so each one needs // Secondary endpoints: every TLS SRV target. Clients may connect to any
// its own valid certificate. // of them per weight/priority, and all of them need a valid certificate.
for _, r := range obs.Discovery.SecureSRV { for _, r := range obs.Discovery.SecureSRV {
port := r.Port port := r.Port
if port == 0 { if port == 0 {
@ -59,6 +68,9 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {
return out 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) { func hostPortFromURL(raw string) (host string, port uint16, ok bool) {
u, err := url.Parse(raw) u, err := url.Parse(raw)
if err != nil { if err != nil {

View file

@ -36,7 +36,7 @@ func TestDiscoverEntries_contextURLOnly(t *testing.T) {
if got[0].Host != "dav.example.com" || got[0].Port != 443 { if got[0].Host != "dav.example.com" || got[0].Port != 443 {
t.Errorf("unexpected endpoint: %+v", got[0]) t.Errorf("unexpected endpoint: %+v", got[0])
} }
// Direct TLS; no STARTTLS upgrade. // Direct TLS no STARTTLS upgrade.
if got[0].STARTTLS != "" { if got[0].STARTTLS != "" {
t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS) t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS)
} }

View file

@ -7,8 +7,10 @@ import (
"strings" "strings"
) )
// ProbeOptions never treats a missing/incomplete DAV: header as a transport // ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV
// error: severity is the caller rule's decision, not ours. // 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) { func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
res := OptionsResult{} res := OptionsResult{}
req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil)
@ -40,7 +42,8 @@ func ProbeOptions(ctx context.Context, client *http.Client, url string) (Options
return res, nil return res, nil
} }
// HasCapability matches case-insensitively per RFC 4918 §10.1. // 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 { func (o OptionsResult) HasCapability(cap string) bool {
for _, c := range o.DAVClasses { for _, c := range o.DAVClasses {
if strings.EqualFold(c, cap) { if strings.EqualFold(c, cap) {
@ -50,6 +53,7 @@ func (o OptionsResult) HasCapability(cap string) bool {
return false return false
} }
// AllowsMethod returns true when the OPTIONS response's Allow: listed m.
func (o OptionsResult) AllowsMethod(m string) bool { func (o OptionsResult) AllowsMethod(m string) bool {
for _, a := range o.AllowMethods { for _, a := range o.AllowMethods {
if strings.EqualFold(a, m) { if strings.EqualFold(a, m) {
@ -59,8 +63,9 @@ func (o OptionsResult) AllowsMethod(m string) bool {
return false return false
} }
// parseCSVHeader merges repeated headers (net/http keeps them separate) // parseCSVHeader splits one or more header values on commas, trims, and drops
// into a single split-and-trimmed slice. // empties. Multiple headers of the same name (net/http preserves them) are
// merged.
func parseCSVHeader(values []string) []string { func parseCSVHeader(values []string) []string {
var out []string var out []string
for _, v := range values { for _, v := range values {
@ -73,6 +78,8 @@ func parseCSVHeader(values []string) []string {
return out 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 { func authScheme(h string) string {
h = strings.TrimSpace(h) h = strings.TrimSpace(h)
if h == "" { if h == "" {

View file

@ -28,7 +28,7 @@ func UserOptions() []sdk.CheckerOptionDocumentation {
Id: "context_url", Id: "context_url",
Type: "string", Type: "string",
Label: "Explicit context URL", Label: "Explicit context URL",
Description: "Optional. Bypasses /.well-known and SRV discovery. Use for servers with a non-standard layout.", Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.",
Placeholder: "https://dav.example.com/caldav/", Placeholder: "https://dav.example.com/caldav/",
}, },
} }
@ -56,9 +56,10 @@ func RunOptions() []sdk.CheckerOptionDocumentation {
} }
} }
// InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the // InteractiveForm returns the fields shown on the standalone /check page.
// standalone /check page. Discovery happens inside Collect, so all the // Discovery (well-known + SRV) happens inside Collect, so the human only
// human owes us is the domain. // needs to provide a domain plus the same optional knobs exposed to
// happyDomain users.
func InteractiveForm() []sdk.CheckerOptionField { func InteractiveForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{ return []sdk.CheckerOptionField{
{ {
@ -98,6 +99,9 @@ func InteractiveForm() []sdk.CheckerOptionField {
} }
} }
// 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) { func ParseInteractiveForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain_name")) domain := strings.TrimSpace(r.FormValue("domain_name"))
if domain == "" { if domain == "" {

View file

@ -10,7 +10,8 @@ import (
"strings" "strings"
) )
// FindPrincipal requires authenticated credentials on client. // 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) { func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) {
body := `<?xml version="1.0" encoding="utf-8"?> body := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:"> <d:propfind xmlns:d="DAV:">
@ -60,8 +61,11 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str
return inbox, outbox, nil return inbox, outbox, nil
} }
// multistatus is intentionally a permissive subset: unknown elements are // ── raw PROPFIND ─────────────────────────────────────────────────────────────
// ignored so server-specific extensions don't break parsing.
// 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 { type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"` XMLName xml.Name `xml:"DAV: multistatus"`
Response []msResponse `xml:"response"` Response []msResponse `xml:"response"`
@ -84,6 +88,8 @@ type prop struct {
type msProp struct { type msProp struct {
XMLName xml.Name XMLName xml.Name
Hrefs []string `xml:"href"` 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 { func (p msProp) firstHref() string {
@ -112,7 +118,8 @@ func (m *multistatus) principalHref() []string {
return out return out
} }
// propFind is tuned for small single-resource probes; not for large listings. // 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) { func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) {
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body)) req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body))
if err != nil { if err != nil {
@ -125,9 +132,7 @@ func propFind(ctx context.Context, client *http.Client, url, depth, body string)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
// 10 MiB cap: probes here read a handful of props on one resource; more data, err := io.ReadAll(resp.Body)
// is either misbehaviour or an attempt at memory exhaustion.
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -8,10 +8,19 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// RenderReport foregrounds the high-frequency failure modes (well-known // RenderReport turns an Observation into a self-contained HTML document.
// misconfig, missing DAV class, missing credentials, downstream TLS issues) //
// before the full per-phase evidence. tlsRelated is what the host stitched // The report foregrounds action items for the failure modes we see most often
// from checker-tls; nil simply omits the TLS section. // (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) { func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) {
data := buildReportData(obs, title, tlsRelated) data := buildReportData(obs, title, tlsRelated)
var buf strings.Builder var buf strings.Builder
@ -63,6 +72,8 @@ func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObser
d.Callouts = buildCallouts(o) d.Callouts = buildCallouts(o)
d.Phases = buildPhases(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) tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
d.TLSSummaries = tlsSummaries d.TLSSummaries = tlsSummaries
for _, c := range tlsCallouts { for _, c := range tlsCallouts {
@ -104,8 +115,8 @@ func hasSeverity(phases []phaseData, sev string) bool {
return false return false
} }
// buildCallouts pulls common misconfigurations to the top so operators // buildCallouts surfaces the most common misconfigurations at the top of the
// don't have to expand the phase tree to find the fix. // report so operators don't have to read the full phase tree to find the fix.
func buildCallouts(o *Observation) []calloutData { func buildCallouts(o *Observation) []calloutData {
var out []calloutData var out []calloutData
disc := o.Discovery disc := o.Discovery
@ -134,7 +145,7 @@ func buildCallouts(o *Observation) []calloutData {
out = append(out, calloutData{ out = append(out, calloutData{
Severity: "crit", Severity: "crit",
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()), 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), 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()) { if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
@ -160,7 +171,7 @@ func exampleContextURL(k Kind) string {
func buildPhases(o *Observation) []phaseData { func buildPhases(o *Observation) []phaseData {
var phases []phaseData var phases []phaseData
// Phase 1: Discovery // Phase 1 Discovery
discovery := phaseData{Title: "Discovery"} discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor( discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect", "/.well-known redirect",
@ -194,7 +205,7 @@ func buildPhases(o *Observation) []phaseData {
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail") discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery) phases = append(phases, discovery)
// Phase 2: Transport + OPTIONS // Phase 2 Transport + OPTIONS
transport := phaseData{Title: "Transport & OPTIONS"} transport := phaseData{Title: "Transport & OPTIONS"}
transport.Items = append(transport.Items, transport.Items = append(transport.Items,
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""), itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
@ -210,7 +221,7 @@ func buildPhases(o *Observation) []phaseData {
transport.Open = hasItemSeverity(transport.Items, "warn", "fail") transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport) phases = append(phases, transport)
// Phase 3: Authenticated // Phase 3 Authenticated
auth := phaseData{Title: "Authenticated probes"} auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items, auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error), authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
@ -221,7 +232,7 @@ func buildPhases(o *Observation) []phaseData {
auth.Open = hasItemSeverity(auth.Items, "warn", "fail") auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth) phases = append(phases, auth)
// Phase 4: Scheduling (CalDAV only) // Phase 4 Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil { if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"} sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised { if !o.Scheduling.Advertised {
@ -240,14 +251,15 @@ func buildPhases(o *Observation) []phaseData {
return phases return phases
} }
// buildTLSPhase auto-opens when anything is non-OK so the failure is // buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
// visible without an extra click. // 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 { func buildTLSPhase(summaries []TLSSummary) phaseData {
p := phaseData{Title: "TLS (from checker-tls)"} p := phaseData{Title: "TLS (from checker-tls)"}
for _, s := range summaries { for _, s := range summaries {
label := s.Address label := s.Address
if s.TLSVersion != "" { if s.TLSVersion != "" {
label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion) label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion)
} }
p.Items = append(p.Items, phaseItem{ p.Items = append(p.Items, phaseItem{
Label: label, Label: label,
@ -259,6 +271,8 @@ func buildTLSPhase(summaries []TLSSummary) phaseData {
return p return p
} }
// ── small helpers used by buildPhases ────────────────────────────────────────
func wellKnownStatus(d DiscoveryResult) string { func wellKnownStatus(d DiscoveryResult) string {
if d.Source == "explicit" { if d.Source == "explicit" {
return "info" return "info"
@ -402,6 +416,8 @@ func summariseSRV(rec []SRVRecord) string {
return strings.Join(parts, "; ") return strings.Join(parts, "; ")
} }
// ── template ─────────────────────────────────────────────────────────────────
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html> var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>

View file

@ -8,7 +8,8 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules omits scheduling for CardDAV (CalDAV-only). // 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 { func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
rules := []sdk.CheckRule{ rules := []sdk.CheckRule{
&discoveryRule{obsKey: obsKey}, &discoveryRule{obsKey: obsKey},
@ -25,8 +26,9 @@ func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
return rules return rules
} }
// WorstStatus picks the highest-severity state. Unknown only wins if every // WorstStatus is a CheckAggregator that picks the highest-severity state from
// rule was Unknown. // the individual rule outcomes. StatusUnknown does not degrade the result
// unless every rule returned Unknown.
type WorstStatus struct{} type WorstStatus struct{}
func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
@ -58,6 +60,8 @@ func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState {
return out return out
} }
// ── individual rules ─────────────────────────────────────────────────────────
type baseRule struct { type baseRule struct {
obsKey sdk.ObservationKey obsKey sdk.ObservationKey
} }
@ -74,8 +78,9 @@ func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observa
return &d, sdk.CheckState{} return &d, sdk.CheckState{}
} }
// discoveryRule surfaces the #1 user-facing misconfig: a missing or // discoveryRule checks that a context URL was resolved and that the
// non-redirect /.well-known. // /.well-known endpoint is configured as a redirect (the #1 user-facing
// misconfig we want to surface).
type discoveryRule struct{ obsKey sdk.ObservationKey } type discoveryRule struct{ obsKey sdk.ObservationKey }
func (r *discoveryRule) Name() string { return "dav_discovery" } func (r *discoveryRule) Name() string { return "dav_discovery" }
@ -93,8 +98,8 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
Message: "could not resolve a context URL (no /.well-known redirect and no SRV record)", 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 // /.well-known returning 200 is legal per RFC but strongly discouraged —
// it. Warn, don't crit. // many clients won't follow it. Warn, don't crit.
if disc.WellKnownCode == 200 && disc.Source != "explicit" { if disc.WellKnownCode == 200 && disc.Source != "explicit" {
return []sdk.CheckState{{ return []sdk.CheckState{{
Status: sdk.StatusWarn, Status: sdk.StatusWarn,
@ -104,7 +109,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
} }
if disc.Source == "srv-txt" && disc.WellKnownError != "" { if disc.Source == "srv-txt" && disc.WellKnownError != "" {
return []sdk.CheckState{{ return []sdk.CheckState{{
Status: sdk.StatusInfo, Status: sdk.StatusWarn,
Code: "well_known_missing", Code: "well_known_missing",
Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError), Message: fmt.Sprintf("context URL resolved via SRV but /.well-known is broken: %s", disc.WellKnownError),
}} }}
@ -116,7 +121,8 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
}} }}
} }
// transportRule covers reachability only; cert specifics are out of scope. // 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 } type transportRule struct{ obsKey sdk.ObservationKey }
func (r *transportRule) Name() string { return "dav_transport" } func (r *transportRule) Name() string { return "dav_transport" }
@ -136,6 +142,7 @@ func (r *transportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}} return []sdk.CheckState{{Status: sdk.StatusOK, Code: "transport_ok", Message: "HTTPS reachable"}}
} }
// optionsRule verifies the mandatory DAV class is advertised.
type optionsRule struct { type optionsRule struct {
obsKey sdk.ObservationKey obsKey sdk.ObservationKey
kind Kind kind Kind
@ -243,7 +250,7 @@ func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
return []sdk.CheckState{{ return []sdk.CheckState{{
Status: sdk.StatusWarn, Status: sdk.StatusWarn,
Code: "collections_empty", Code: "collections_empty",
Message: "home-set is empty; the account has no calendars/addressbooks", Message: "home-set is empty the account has no calendars/addressbooks",
}} }}
} }
out := make([]sdk.CheckState, 0, len(c.Items)) out := make([]sdk.CheckState, 0, len(c.Items))
@ -289,7 +296,8 @@ func (r *reportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _
return []sdk.CheckState{{Status: sdk.StatusOK, Code: "report_ok", Message: fmt.Sprintf("REPORT ok on %s", rep.ProbePath), 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. // schedulingRule is CalDAV-only: if the server advertises calendar-schedule,
// the principal should expose inbox/outbox URLs.
type schedulingRule struct{ obsKey sdk.ObservationKey } type schedulingRule struct{ obsKey sdk.ObservationKey }
func (r *schedulingRule) Name() string { return "caldav_scheduling" } func (r *schedulingRule) Name() string { return "caldav_scheduling" }

View file

@ -11,12 +11,14 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// TLSRelatedKey matches the cross-checker convention in // TLSRelatedKey is the observation key we expect a TLS checker to publish for
// happydomain3/docs/checker-discovery-endpoint.md. // the endpoints we discover. Matches the cross-checker convention documented
// in happydomain3/docs/checker-discovery-endpoint.md.
const TLSRelatedKey sdk.ObservationKey = "tls_probes" const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView decodes only the fields we actually use; the full TLS schema // tlsProbeView is a permissive decode of a TLS probe payload. We intentionally
// belongs to checker-tls and we don't want to track its evolution here. // only read the fields we need and tolerate missing ones — the TLS checker's
// full schema is owned by that checker.
type tlsProbeView struct { type tlsProbeView struct {
Host string `json:"host,omitempty"` Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"` Port uint16 `json:"port,omitempty"`
@ -53,6 +55,8 @@ type tlsProbeView struct {
} `json:"issues,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 { func (v *tlsProbeView) address() string {
if v.Endpoint != "" { if v.Endpoint != "" {
return v.Endpoint return v.Endpoint
@ -63,7 +67,8 @@ func (v *tlsProbeView) address() string {
return "" return ""
} }
// certExpiry hides the two payload shapes from callers. // 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) { func (v *tlsProbeView) certExpiry() (time.Time, bool) {
if !v.NotAfter.IsZero() { if !v.NotAfter.IsZero() {
return v.NotAfter, true return v.NotAfter, true
@ -94,9 +99,17 @@ func (v *tlsProbeView) chainOK() (bool, bool) {
return false, false return false, false
} }
// parseTLSRelated accepts both the keyed {"probes": {"<ref>": …}} shape // parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil
// (current checker-tls output, picked by r.Ref) and a bare top-level probe // when the payload doesn't look like one.
// (legacy/test fixtures). Returns nil for anything else. //
// 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 { func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct { var keyed struct {
Probes map[string]tlsProbeView `json:"probes"` Probes map[string]tlsProbeView `json:"probes"`
@ -114,6 +127,7 @@ func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
return &v return &v
} }
// TLSSummary is what the HTML report renders for each probed endpoint.
type TLSSummary struct { type TLSSummary struct {
Address string Address string
TLSVersion string TLSVersion string
@ -123,12 +137,17 @@ type TLSSummary struct {
DaysRemaining int DaysRemaining int
} }
// tlsCallout captures a cross-checker issue we want to foreground in the
// "Action items" section of the HTML report.
type tlsCallout struct { type tlsCallout struct {
Severity string // "warn" or "crit" Severity string // "warn" or "crit"
Title string Title string
Body 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) { func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
for _, r := range related { for _, r := range related {
v := parseTLSRelated(r) v := parseTLSRelated(r)
@ -229,7 +248,7 @@ func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
out = append(out, tlsCallout{ out = append(out, tlsCallout{
Severity: "crit", Severity: "crit",
Title: fmt.Sprintf("Certificate on %s has expired", addr), 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)), 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: case days < 14:
out = append(out, tlsCallout{ out = append(out, tlsCallout{

View file

@ -65,7 +65,7 @@ func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
{"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"}, {"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; // When explicit issues exist, we do not also emit synthesized callouts
// the TLS checker is the source of truth for severity and wording. // the TLS checker is the source of truth for severity and wording.
if len(callouts) != 1 || callouts[0].Severity != "warn" { if len(callouts) != 1 || callouts[0].Severity != "warn" {
t.Fatalf("want single warn callout, got %+v", callouts) t.Fatalf("want single warn callout, got %+v", callouts)

View file

@ -1,11 +1,14 @@
// Package dav holds code shared by the CalDAV and CardDAV checkers: // Package dav holds the code shared between the CalDAV and CardDAV checkers:
// discovery, OPTIONS probing, PROPFIND helpers, and report rendering. // 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 package dav
import "time" import "time"
// Kind is carried end-to-end through a run so shared helpers branch on it // Kind distinguishes the two protocol flavours. A single Kind value is carried
// rather than duplicating per-protocol code. // 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 type Kind string
const ( const (
@ -13,8 +16,8 @@ const (
KindCardDAV Kind = "carddav" KindCardDAV Kind = "carddav"
) )
// ServiceName returns the RFC 6764 SRV label, with the leading "_" but // ServiceName returns the RFC 6764 SRV service label for kind, with the
// without the "_tcp" suffix. // leading "_" but without the "_tcp" suffix.
func (k Kind) ServiceName(secure bool) string { func (k Kind) ServiceName(secure bool) string {
switch k { switch k {
case KindCalDAV: case KindCalDAV:
@ -31,12 +34,13 @@ func (k Kind) ServiceName(secure bool) string {
return "" return ""
} }
// WellKnownPath returns the RFC 6764 well-known path for kind.
func (k Kind) WellKnownPath() string { func (k Kind) WellKnownPath() string {
return "/.well-known/" + string(k) return "/.well-known/" + string(k)
} }
// RequiredCapability is the DAV: header token a compliant server must // RequiredCapability is the string that must appear in the DAV: response
// advertise. // header for the server to qualify as a valid implementation.
func (k Kind) RequiredCapability() string { func (k Kind) RequiredCapability() string {
switch k { switch k {
case KindCalDAV: case KindCalDAV:
@ -47,8 +51,9 @@ func (k Kind) RequiredCapability() string {
return "" return ""
} }
// Observation is what each checker persists. Scheduling is CalDAV-only and // Observation is the root data structure persisted by either checker. The
// left nil for CardDAV. // CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left
// zero-valued for KindCardDAV.
type Observation struct { type Observation struct {
Kind Kind `json:"kind"` Kind Kind `json:"kind"`
Domain string `json:"domain"` Domain string `json:"domain"`
@ -64,6 +69,7 @@ type Observation struct {
CollectedAt time.Time `json:"collected_at"` CollectedAt time.Time `json:"collected_at"`
} }
// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer.
type SRVRecord struct { type SRVRecord struct {
Target string `json:"target"` Target string `json:"target"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
@ -71,8 +77,9 @@ type SRVRecord struct {
Weight uint16 `json:"weight"` Weight uint16 `json:"weight"`
} }
// DiscoveryResult records every signal seen during lookup, even on failure, // DiscoveryResult captures every signal we gathered while locating the
// so the report can pinpoint which leg of discovery broke. // service: SRV secure/plaintext, TXT path hints, well-known redirects, and
// the ultimately-resolved context URL.
type DiscoveryResult struct { type DiscoveryResult struct {
SecureSRV []SRVRecord `json:"secure_srv,omitempty"` SecureSRV []SRVRecord `json:"secure_srv,omitempty"`
PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"` PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"`
@ -88,13 +95,17 @@ type DiscoveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// TransportResult is intentionally minimal: cert validation is out of scope // TransportResult records whether the resolved context URL accepts HTTPS
// here, a dedicated TLS checker owns it. // 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 { type TransportResult struct {
Reached bool `json:"reached"` Reached bool `json:"reached"`
Error string `json:"error,omitempty"` 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 { type OptionsResult struct {
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
DAVClasses []string `json:"dav_classes,omitempty"` DAVClasses []string `json:"dav_classes,omitempty"`
@ -104,20 +115,24 @@ type OptionsResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// PrincipalResult.Skipped is set when no credentials were supplied; the // PrincipalResult holds the `current-user-principal` URL discovered after
// rule turns that into StatusUnknown rather than a failure. // authenticating. Skipped is set to true when no credentials were supplied
// (the rule surfaces this as StatusUnknown).
type PrincipalResult struct { type PrincipalResult struct {
Skipped bool `json:"skipped,omitempty"` Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Error string `json:"error,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 { type HomeSetResult struct {
Skipped bool `json:"skipped,omitempty"` Skipped bool `json:"skipped,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// CollectionInfo describes a single discovered calendar or addressbook.
type CollectionInfo struct { type CollectionInfo struct {
Path string `json:"path"` Path string `json:"path"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@ -127,12 +142,15 @@ type CollectionInfo struct {
SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only
} }
// CollectionsResult is the enumerated content of the home-set.
type CollectionsResult struct { type CollectionsResult struct {
Skipped bool `json:"skipped,omitempty"` Skipped bool `json:"skipped,omitempty"`
Items []CollectionInfo `json:"items,omitempty"` Items []CollectionInfo `json:"items,omitempty"`
Error string `json:"error,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 { type ReportResult struct {
Skipped bool `json:"skipped,omitempty"` Skipped bool `json:"skipped,omitempty"`
QueryOK bool `json:"query_ok,omitempty"` QueryOK bool `json:"query_ok,omitempty"`
@ -140,7 +158,8 @@ type ReportResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// SchedulingResult is CalDAV-only. // SchedulingResult is CalDAV-only: presence of inbox/outbox when the server
// advertises the `calendar-schedule` capability.
type SchedulingResult struct { type SchedulingResult struct {
Advertised bool `json:"advertised"` Advertised bool `json:"advertised"`
InboxURL string `json:"inbox_url,omitempty"` InboxURL string `json:"inbox_url,omitempty"`

View file

@ -1,4 +1,6 @@
// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain. // 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 package main
import ( import (
@ -10,6 +12,5 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
caldav.Version = Version caldav.Version = Version
prvd := caldav.Provider() return caldav.Definition(), caldav.Provider(), nil
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }

View file

@ -1,4 +1,6 @@
// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain. // 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 package main
import ( import (
@ -10,6 +12,5 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
carddav.Version = Version carddav.Version = Version
prvd := carddav.Provider() return carddav.Definition(), carddav.Provider(), nil
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }