Compare commits

...

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

35 changed files with 377 additions and 318 deletions

22
.drone-manifest.yml Normal file
View file

@ -0,0 +1,22 @@
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

187
.drone.yml Normal file
View file

@ -0,0 +1,187 @@
---
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,5 +11,8 @@ 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-dummy checker-dav
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,11 +11,9 @@ import (
"github.com/emersion/go-webdav/caldav" "github.com/emersion/go-webdav/caldav"
) )
// Collect runs the full CalDAV probe pipeline for the target domain. // 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
// The pipeline is deliberately resilient: every phase records its outcome into // later turn the captured state into CheckStates.
// 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")
@ -38,13 +36,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 {
@ -54,7 +52,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
@ -63,9 +61,8 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
return obs, nil return obs, nil
} }
authClient := dav.WithBasicAuth(anonClient, user, pass) authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, 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()
@ -76,7 +73,6 @@ 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()
@ -93,7 +89,6 @@ 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()
@ -110,7 +105,8 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
} }
} }
// Report probe — empty calendar-query against the first calendar. // Empty calendar-query against the first calendar: cheapest probe that
// 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
@ -131,7 +127,6 @@ 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 {
@ -144,6 +139,4 @@ 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,28 +7,15 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Version is the checker version reported in CheckerDefinition.Version. // Version is overridden at link time by the standalone binary via -ldflags.
// Overridden at link time by the standalone binary via -ldflags.
var Version = "built-in" var Version = "built-in"
// Definition returns the CheckerDefinition for the CalDAV checker. func (p *caldavProvider) Definition() *sdk.CheckerDefinition {
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,9 +7,6 @@ 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,12 +7,9 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Provider returns the CalDAV observation provider. // Provider's return value also satisfies CheckerDefinitionProvider,
// // CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for
// The returned value implements sdk.ObservationProvider, plus the optional // those at runtime.
// 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{}
} }
@ -21,8 +18,6 @@ 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,16 +9,9 @@ import (
"git.happydns.org/checker-dav/internal/dav" "git.happydns.org/checker-dav/internal/dav"
) )
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider. // GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV
// // produce visually identical reports. Downstream TLS probes attached via
// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV // ctx.Related(dav.TLSRelatedKey) are folded in.
// 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,17 +1,9 @@
// Package checker (imported as caldav by the standalone binary) implements // Package caldav wires the CalDAV-specific options, collect pipeline,
// the CalDAV compliance and health checker for happyDomain. // rules, and HTML report on top of the shared helpers in internal/dav.
//
// 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, user, pass) authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, 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,21 +9,12 @@ import (
var Version = "built-in" var Version = "built-in"
func Definition() *sdk.CheckerDefinition { func (p *carddavProvider) 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,8 +7,6 @@ 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

@ -11,8 +11,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,9 +9,7 @@ import (
"git.happydns.org/checker-dav/internal/dav" "git.happydns.org/checker-dav/internal/dav"
) )
// GetHTMLReport folds downstream TLS probes (published on our discovered // GetHTMLReport: see the CalDAV sibling.
// 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,7 +1,5 @@
// Package checker (imported as carddav) implements the CardDAV compliance // Package carddav: see the CalDAV sibling. The two share everything except
// and health checker for happyDomain. See the CalDAV sibling for the shape; // the protocol-specific home-set and REPORT calls in collect.go.
// 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"
sdk "git.happydns.org/checker-sdk-go/checker" "git.happydns.org/checker-sdk-go/checker/server"
) )
// 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
server := sdk.NewServer(caldav.Provider()) srv := server.New(caldav.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil { if err := srv.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"
sdk "git.happydns.org/checker-sdk-go/checker" "git.happydns.org/checker-sdk-go/checker/server"
) )
// 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
server := sdk.NewServer(carddav.Provider()) srv := server.New(carddav.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil { if err := srv.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.2.0 git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-tls v0.2.0 git.happydns.org/checker-tls v0.6.2
) )
require ( require (

8
go.sum
View file

@ -1,7 +1,7 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM= git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8= 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 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,38 +2,46 @@ package dav
import ( import (
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
) )
// NewHTTPClient returns an http.Client with a sane default transport for // NewHTTPClient uses Go's default TLS validation; cert correctness is the
// probing DAV servers. TLS certificate validation uses Go's default rules — // dedicated TLS checker's job, not ours.
// 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 injects HTTP Basic credentials on every request so // basicAuthRoundTripper scopes Basic auth to a single host, so a redirect
// callers can pass the same client through go-webdav's own API without losing // to a different host won't leak credentials to a third party. Matches
// auth on internal redirects. // curl's behaviour without --location-trusted.
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) {
req.SetBasicAuth(b.user, b.pass) if strings.EqualFold(req.URL.Host, b.host) {
req.SetBasicAuth(b.user, b.pass)
}
return b.next.RoundTrip(req) return b.next.RoundTrip(req)
} }
// WithBasicAuth clones c and attaches Basic credentials to the transport. // WithBasicAuth attaches credentials scoped to the host of contextURL.
func WithBasicAuth(c *http.Client, user, pass string) *http.Client { func WithBasicAuth(c *http.Client, contextURL, 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
} }
nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base} 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 return &nc
} }

View file

@ -10,13 +10,8 @@ import (
"strings" "strings"
) )
// Discover resolves the DAV context URL for domain following RFC 6764: // Discover resolves the DAV context URL per RFC 6764. Every leg is recorded
// /.well-known/{caldav,carddav} first (cheap and works for the common case), // in the result even on failure so the report can pinpoint the broken step.
// 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{}
@ -26,8 +21,8 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
return res return res
} }
// 1. /.well-known — this is the #1 misconfig hotspot, so we always probe // Always probe /.well-known even if SRV would suffice: it's the #1
// it even if SRV below might have worked, to surface the mistake. // misconfig hotspot and we want to surface it.
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)
@ -40,7 +35,6 @@ 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 {
@ -59,10 +53,9 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli
return res return res
} }
// followWellKnown issues a GET against path and follows up to 5 redirects // followWellKnown follows up to 5 redirects manually so we can record the
// manually so we can capture the redirect chain. The well-known endpoint // chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200
// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we // at this position is the misconfig we want to flag.
// 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
@ -71,7 +64,8 @@ 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
} }
// Use a no-redirect client snapshot so we observe each hop. // Snapshot disables the client's own redirect-following so we can
// 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)
@ -80,9 +74,6 @@ 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
} }
@ -151,7 +142,6 @@ 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,19 +9,11 @@ import (
tlsct "git.happydns.org/checker-tls/contract" tlsct "git.happydns.org/checker-tls/contract"
) )
// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to // DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets
// downstream checkers (notably checker-tls) from a completed Observation. // are emitted alongside the context URL because they're the names operators
// // must actually put on the certificate, and they often differ from the
// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, // queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120
// so we emit a single tls.endpoint.v1 entry for the resolved context URL's // §13.7.2.1), CalDAV/CardDAV has no source-vs-target split.
// 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
@ -50,13 +42,12 @@ 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)
} }
// Secondary endpoints: every TLS SRV target. Clients may connect to any // Every SRV target is reachable via priority/weight, so each one needs
// of them per weight/priority, and all of them need a valid certificate. // its own 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 {
@ -68,9 +59,6 @@ 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,10 +7,8 @@ import (
"strings" "strings"
) )
// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV // ProbeOptions never treats a missing/incomplete DAV: header as a transport
// headers. A missing DAV: header, or one that does not contain the kind's // error: severity is the caller rule's decision, not ours.
// 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)
@ -42,8 +40,7 @@ func ProbeOptions(ctx context.Context, client *http.Client, url string) (Options
return res, nil return res, nil
} }
// HasCapability returns true when the OPTIONS response advertised cap in the // HasCapability matches case-insensitively per RFC 4918 §10.1.
// 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) {
@ -53,7 +50,6 @@ 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) {
@ -63,9 +59,8 @@ func (o OptionsResult) AllowsMethod(m string) bool {
return false return false
} }
// parseCSVHeader splits one or more header values on commas, trims, and drops // parseCSVHeader merges repeated headers (net/http keeps them separate)
// empties. Multiple headers of the same name (net/http preserves them) are // into a single split-and-trimmed slice.
// 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 {
@ -78,8 +73,6 @@ 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,10 +56,9 @@ func RunOptions() []sdk.CheckerOptionDocumentation {
} }
} }
// InteractiveForm returns the fields shown on the standalone /check page. // InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the
// Discovery (well-known + SRV) happens inside Collect, so the human only // standalone /check page. Discovery happens inside Collect, so all the
// needs to provide a domain plus the same optional knobs exposed to // human owes us is the domain.
// happyDomain users.
func InteractiveForm() []sdk.CheckerOptionField { func InteractiveForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{ return []sdk.CheckerOptionField{
{ {
@ -99,9 +98,6 @@ 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,8 +10,7 @@ import (
"strings" "strings"
) )
// FindPrincipal requires credentials on client; a 401/403 from the server // FindPrincipal requires authenticated credentials on client.
// 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:">
@ -61,11 +60,8 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str
return inbox, outbox, nil return inbox, outbox, nil
} }
// ── raw PROPFIND ───────────────────────────────────────────────────────────── // multistatus is intentionally a permissive subset: unknown elements are
// 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"`
@ -88,8 +84,6 @@ 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 {
@ -118,8 +112,7 @@ func (m *multistatus) principalHref() []string {
return out return out
} }
// propFind is a small PROPFIND helper tuned for small single-resource probes. // propFind is tuned for small single-resource probes; not for large listings.
// 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 {
@ -132,7 +125,9 @@ 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()
data, err := io.ReadAll(resp.Body) // 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -8,19 +8,10 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// RenderReport turns an Observation into a self-contained HTML document. // RenderReport foregrounds the high-frequency failure modes (well-known
// // misconfig, missing DAV class, missing credentials, downstream TLS issues)
// The report foregrounds action items for the failure modes we see most often // before the full per-phase evidence. tlsRelated is what the host stitched
// (well-known misconfig, missing DAV capability, missing credentials, // from checker-tls; nil simply omits the TLS section.
// 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
@ -72,8 +63,6 @@ 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 {
@ -115,8 +104,8 @@ func hasSeverity(phases []phaseData, sev string) bool {
return false return false
} }
// buildCallouts surfaces the most common misconfigurations at the top of the // buildCallouts pulls common misconfigurations to the top so operators
// report so operators don't have to read the full phase tree to find the fix. // don't have to expand the 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
@ -145,7 +134,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()) {
@ -171,7 +160,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",
@ -205,7 +194,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, ""),
@ -221,7 +210,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),
@ -232,7 +221,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 {
@ -251,15 +240,14 @@ func buildPhases(o *Observation) []phaseData {
return phases return phases
} }
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase // buildTLSPhase auto-opens when anything is non-OK so the failure is
// rendered at the bottom of the report. Open when anything is non-OK so // visible without an extra click.
// 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,
@ -271,8 +259,6 @@ 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"
@ -416,8 +402,6 @@ 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,8 +8,7 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// Rules returns the default rule set for kind. CardDAV gets the full set // Rules omits scheduling for CardDAV (CalDAV-only).
// 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},
@ -26,9 +25,8 @@ func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule {
return rules return rules
} }
// WorstStatus is a CheckAggregator that picks the highest-severity state from // WorstStatus picks the highest-severity state. Unknown only wins if every
// the individual rule outcomes. StatusUnknown does not degrade the result // rule was Unknown.
// 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 {
@ -60,8 +58,6 @@ 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
} }
@ -78,9 +74,8 @@ func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observa
return &d, sdk.CheckState{} return &d, sdk.CheckState{}
} }
// discoveryRule checks that a context URL was resolved and that the // discoveryRule surfaces the #1 user-facing misconfig: a missing or
// /.well-known endpoint is configured as a redirect (the #1 user-facing // non-redirect /.well-known.
// 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" }
@ -98,8 +93,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 returning 200 is legal per RFC but strongly discouraged — // /.well-known=200 is legal but discouraged; many clients won't follow
// many clients won't follow it. Warn, don't crit. // 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,
@ -109,7 +104,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.StatusWarn, Status: sdk.StatusInfo,
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),
}} }}
@ -121,8 +116,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
}} }}
} }
// transportRule reports only whether the context URL accepts HTTPS requests. // transportRule covers reachability only; cert specifics are out of scope.
// 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" }
@ -142,7 +136,6 @@ 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
@ -250,7 +243,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))
@ -296,8 +289,7 @@ 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: if the server advertises calendar-schedule, // schedulingRule is CalDAV-only.
// 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,14 +11,12 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// TLSRelatedKey is the observation key we expect a TLS checker to publish for // TLSRelatedKey matches the cross-checker convention in
// the endpoints we discover. Matches the cross-checker convention documented // happydomain3/docs/checker-discovery-endpoint.md.
// in happydomain3/docs/checker-discovery-endpoint.md.
const TLSRelatedKey sdk.ObservationKey = "tls_probes" const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally // tlsProbeView decodes only the fields we actually use; the full TLS schema
// only read the fields we need and tolerate missing ones — the TLS checker's // belongs to checker-tls and we don't want to track its evolution here.
// 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"`
@ -55,8 +53,6 @@ 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
@ -67,8 +63,7 @@ func (v *tlsProbeView) address() string {
return "" return ""
} }
// certExpiry normalises the two schema shapes into a single (t, ok) pair so // certExpiry hides the two payload shapes from callers.
// 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
@ -99,17 +94,9 @@ func (v *tlsProbeView) chainOK() (bool, bool) {
return false, false return false, false
} }
// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil // parseTLSRelated accepts both the keyed {"probes": {"<ref>": …}} shape
// when the payload doesn't look like one. // (current checker-tls output, picked by r.Ref) and a bare top-level probe
// // (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"`
@ -127,7 +114,6 @@ 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
@ -137,17 +123,12 @@ 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)
@ -248,7 +229,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,14 +1,11 @@
// Package dav holds the code shared between the CalDAV and CardDAV checkers: // Package dav holds code shared by the CalDAV and CardDAV checkers:
// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and // discovery, OPTIONS probing, PROPFIND helpers, and report rendering.
// 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 distinguishes the two protocol flavours. A single Kind value is carried // Kind is carried end-to-end through a run so shared helpers branch on it
// end-to-end through a checker run so shared helpers can pick the right // rather than duplicating per-protocol code.
// service names, well-known paths, and required DAV classes.
type Kind string type Kind string
const ( const (
@ -16,8 +13,8 @@ const (
KindCardDAV Kind = "carddav" KindCardDAV Kind = "carddav"
) )
// ServiceName returns the RFC 6764 SRV service label for kind, with the // ServiceName returns the RFC 6764 SRV label, with the leading "_" but
// leading "_" but without the "_tcp" suffix. // 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:
@ -34,13 +31,12 @@ 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 string that must appear in the DAV: response // RequiredCapability is the DAV: header token a compliant server must
// header for the server to qualify as a valid implementation. // advertise.
func (k Kind) RequiredCapability() string { func (k Kind) RequiredCapability() string {
switch k { switch k {
case KindCalDAV: case KindCalDAV:
@ -51,9 +47,8 @@ func (k Kind) RequiredCapability() string {
return "" return ""
} }
// Observation is the root data structure persisted by either checker. The // Observation is what each checker persists. Scheduling is CalDAV-only and
// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left // left nil for CardDAV.
// 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"`
@ -69,7 +64,6 @@ 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"`
@ -77,9 +71,8 @@ type SRVRecord struct {
Weight uint16 `json:"weight"` Weight uint16 `json:"weight"`
} }
// DiscoveryResult captures every signal we gathered while locating the // DiscoveryResult records every signal seen during lookup, even on failure,
// service: SRV secure/plaintext, TXT path hints, well-known redirects, and // so the report can pinpoint which leg of discovery broke.
// 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"`
@ -95,17 +88,13 @@ type DiscoveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// TransportResult records whether the resolved context URL accepts HTTPS // TransportResult is intentionally minimal: cert validation is out of scope
// requests. TLS certificate validation is out of scope (a dedicated TLS // here, a dedicated TLS checker owns it.
// 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"`
@ -115,24 +104,20 @@ type OptionsResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// PrincipalResult holds the `current-user-principal` URL discovered after // PrincipalResult.Skipped is set when no credentials were supplied; the
// authenticating. Skipped is set to true when no credentials were supplied // rule turns that into StatusUnknown rather than a failure.
// (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"`
@ -142,15 +127,12 @@ 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"`
@ -158,8 +140,7 @@ type ReportResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server // SchedulingResult is CalDAV-only.
// 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,6 +1,4 @@
// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV // Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain.
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
// happyDomain.
package main package main
import ( import (
@ -12,5 +10,6 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
caldav.Version = Version caldav.Version = Version
return caldav.Definition(), caldav.Provider(), nil prvd := caldav.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }

View file

@ -1,6 +1,4 @@
// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV // Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain.
// checker. Built with `go build -buildmode=plugin` and loaded at runtime by
// happyDomain.
package main package main
import ( import (
@ -12,5 +10,6 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
carddav.Version = Version carddav.Version = Version
return carddav.Definition(), carddav.Provider(), nil prvd := carddav.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
} }