diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index f892ef7..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -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 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index c56bcc3..0000000 --- a/.drone.yml +++ /dev/null @@ -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 diff --git a/Dockerfile b/Dockerfile index 1922848..91af07b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,5 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che FROM scratch COPY --from=builder /checker /checker -USER 65534:65534 EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/checker", "-healthcheck"] ENTRYPOINT ["/checker"] diff --git a/NOTICE b/NOTICE index 58a20f0..ae61715 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -checker-dav +checker-dummy Copyright (c) 2026 The happyDomain Authors This product is licensed under the MIT License (see LICENSE). diff --git a/README.md b/README.md index 3ebfc8e..a9c67bb 100644 --- a/README.md +++ b/README.md @@ -38,27 +38,27 @@ view; the default is a JSON metrics dump. Both checkers accept the same options: -- `domain_name` (auto-filled): required -- `username`, `password`: optional Basic credentials; unlock authenticated +- `domain_name` (auto-filled) — required +- `username`, `password` — optional Basic credentials; unlock authenticated checks (principal, home-set, collections, REPORT probe) -- `context_url`: optional explicit override, bypasses `/.well-known` + SRV -- `timeout_seconds`: per-request HTTP timeout, default 10 +- `context_url` — optional explicit override, bypasses `/.well-known` + SRV +- `timeout_seconds` — per-request HTTP timeout, default 10 ## What is checked -1. **Discovery**: `/.well-known/{caldav,carddav}` (must 3xx, not 200), +1. **Discovery** — `/.well-known/{caldav,carddav}` (must 3xx, not 200), `_caldavs._tcp` / `_carddavs._tcp` SRV, TXT `path=` hint. -2. **Transport**: HTTPS reachable. TLS certificate validation is - deliberately out of scope; a dedicated TLS checker covers that. -3. **OPTIONS**: `DAV:` advertises `calendar-access` or `addressbook`; Allow +2. **Transport** — HTTPS reachable. TLS certificate validation is + deliberately out of scope — a dedicated TLS checker covers that. +3. **OPTIONS** — `DAV:` advertises `calendar-access` or `addressbook`; Allow includes `PROPFIND` and `REPORT`; auth schemes captured for info. -4. **Principal**: PROPFIND `current-user-principal` (auth required). -5. **Home-set**: `calendar-home-set` / `addressbook-home-set`. -6. **Collections**: enumerate, record properties (`supported-calendar-component-set`, +4. **Principal** — PROPFIND `current-user-principal` (auth required). +5. **Home-set** — `calendar-home-set` / `addressbook-home-set`. +6. **Collections** — enumerate, record properties (`supported-calendar-component-set`, `supported-address-data`, display name, description, max size). -7. **REPORT probe**: issue a minimal `calendar-query` / `addressbook-query` +7. **REPORT probe** — issue a minimal `calendar-query` / `addressbook-query` 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. 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 -- [`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 +- [`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 diff --git a/caldav/collect.go b/caldav/collect.go index 5033c61..04dfc83 100644 --- a/caldav/collect.go +++ b/caldav/collect.go @@ -11,9 +11,11 @@ import ( "github.com/emersion/go-webdav/caldav" ) -// Collect is intentionally resilient: each phase records its outcome and we -// keep going as long as the next phase has something to work with. Rules -// later turn the captured state into CheckStates. +// Collect runs the full CalDAV probe pipeline for the target domain. +// +// The pipeline is deliberately resilient: every phase records its outcome into +// the Observation and we keep going as long as we have something to probe +// with. Rules later translate the captured state into CheckStates. func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { domain, _ := sdk.GetOption[string](opts, "domain_name") user, _ := sdk.GetOption[string](opts, "username") @@ -36,13 +38,13 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( anonClient := dav.NewHTTPClient(timeout) - // Phase 1: Discovery + // Phase 1 — Discovery obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit) if obs.Discovery.ContextURL == "" { return obs, nil } - // Phase 2: Transport + OPTIONS (no auth required) + // Phase 2 — Transport + OPTIONS (no auth required) optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) obs.Options = optsRes if err != nil { @@ -52,7 +54,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( obs.Transport = dav.TransportResult{Reached: true} obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule") - // Phase 3: Authenticated probes + // Phase 3 — Authenticated probes if !obs.HasCredentials { obs.Principal.Skipped = true obs.HomeSet.Skipped = true @@ -61,8 +63,9 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( 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) if err != nil { obs.Principal.Error = err.Error() @@ -73,6 +76,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( } obs.Principal.URL = principal + // Home-set (via go-webdav's CalDAV client). cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL) if err != nil { obs.HomeSet.Error = err.Error() @@ -89,6 +93,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( } obs.HomeSet.URL = home + // Collections. calendars, err := cal.FindCalendars(ctx, home) if err != nil { 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 - // still exercises the REPORT pipeline end-to-end. + // Report probe — empty calendar-query against the first calendar. if len(obs.Collections.Items) > 0 { first := obs.Collections.Items[0].Path obs.Report.ProbePath = first @@ -127,6 +131,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( obs.Report.Skipped = true } + // Scheduling inbox/outbox — only probe if advertised. if obs.Scheduling.Advertised { inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal) if err != nil { @@ -139,4 +144,6 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( 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 } diff --git a/caldav/definition.go b/caldav/definition.go index d516a2e..5899293 100644 --- a/caldav/definition.go +++ b/caldav/definition.go @@ -7,15 +7,28 @@ import ( 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" -func (p *caldavProvider) Definition() *sdk.CheckerDefinition { +// Definition returns the CheckerDefinition for the CalDAV checker. +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "caldav", Name: "CalDAV server", Version: Version, Availability: sdk.CheckerAvailability{ + // The probe itself only needs a domain name (discovery runs on + // the whole domain via /.well-known + SRV), so the checker is + // always offered at domain scope. + ApplyToDomain: true, + + // Also offered at service scope so alerts — including the TLS + // alerts derived from the endpoints we publish — surface on a + // dedicated "CalDAV" service page rather than on the domain + // page. The abstract.CalDAV service type does not exist in the + // happyDomain service catalog yet; until it does, this has no + // visible effect, but makes the intent explicit. ApplyToService: true, LimitToServices: []string{"abstract.CalDAV"}, }, diff --git a/caldav/discovery.go b/caldav/discovery.go index b01a9fb..76e7fc3 100644 --- a/caldav/discovery.go +++ b/caldav/discovery.go @@ -7,6 +7,9 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) +// DiscoverEntries implements sdk.DiscoveryPublisher. The SDK server calls +// this with the native Go value returned by Collect, so we just type-assert +// and delegate to the shared helper. func (p *caldavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { obs, ok := data.(*dav.Observation) if !ok { diff --git a/caldav/provider.go b/caldav/provider.go index 1b6f8b0..45c4fcc 100644 --- a/caldav/provider.go +++ b/caldav/provider.go @@ -7,9 +7,12 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Provider's return value also satisfies CheckerDefinitionProvider, -// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for -// those at runtime. +// Provider returns the CalDAV observation provider. +// +// The returned value implements sdk.ObservationProvider, plus the optional +// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer +// interfaces so the SDK's HTTP server exposes /definition, /evaluate, +// /report, and forwards discovered TLS endpoints to downstream checkers. func Provider() sdk.ObservationProvider { return &caldavProvider{} } @@ -18,6 +21,8 @@ type caldavProvider struct{} func (p *caldavProvider) Key() sdk.ObservationKey { return ObservationKey } +func (p *caldavProvider) Definition() *sdk.CheckerDefinition { return Definition() } + func (p *caldavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() } func (p *caldavProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { diff --git a/caldav/report.go b/caldav/report.go index 6cbdfef..7355c2d 100644 --- a/caldav/report.go +++ b/caldav/report.go @@ -9,9 +9,16 @@ import ( "git.happydns.org/checker-dav/internal/dav" ) -// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV -// produce visually identical reports. Downstream TLS probes attached via -// ctx.Related(dav.TLSRelatedKey) are folded in. +// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider. +// +// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV +// produce visually identical reports; the only differences are the title +// and the set of phases (CalDAV includes Scheduling). +// +// Downstream TLS probes published for the endpoints we discovered are read +// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts + +// dedicated TLS phase) — per +// happydomain3/docs/checker-discovery-endpoint.md. func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d dav.Observation if err := json.Unmarshal(ctx.Data(), &d); err != nil { diff --git a/caldav/types.go b/caldav/types.go index d139b69..1a2ddc3 100644 --- a/caldav/types.go +++ b/caldav/types.go @@ -1,9 +1,17 @@ -// Package caldav wires the CalDAV-specific options, collect pipeline, -// rules, and HTML report on top of the shared helpers in internal/dav. +// Package checker (imported as caldav by the standalone binary) implements +// the CalDAV compliance and health checker for happyDomain. +// +// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting +// helpers live in git.happydns.org/checker-dav/internal/dav, so this package +// only wires the CalDAV-specific options, collect pipeline, rules, and HTML +// report together. package caldav import "git.happydns.org/checker-dav/internal/dav" +// ObservationKey identifies CalDAV observations in happyDomain's store. const ObservationKey = "caldav" +// Data is the persisted observation shape. Callers read it back via +// obs.Get(ctx, ObservationKey, &Data). type Data = dav.Observation diff --git a/carddav/collect.go b/carddav/collect.go index 188dca4..a36ed57 100644 --- a/carddav/collect.go +++ b/carddav/collect.go @@ -32,13 +32,13 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) anonClient := dav.NewHTTPClient(timeout) - // Phase 1: Discovery + // Phase 1 — Discovery obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCardDAV, domain, explicit) if obs.Discovery.ContextURL == "" { return obs, nil } - // Phase 2: OPTIONS + // Phase 2 — OPTIONS optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL) obs.Options = optsRes if err != nil { @@ -47,7 +47,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) } obs.Transport = dav.TransportResult{Reached: true} - // Phase 3: Authenticated + // Phase 3 — Authenticated if !obs.HasCredentials { obs.Principal.Skipped = true obs.HomeSet.Skipped = true @@ -56,7 +56,7 @@ func (p *carddavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) 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) if err != nil { diff --git a/carddav/definition.go b/carddav/definition.go index 085ea08..8ba5d6a 100644 --- a/carddav/definition.go +++ b/carddav/definition.go @@ -9,12 +9,21 @@ import ( var Version = "built-in" -func (p *carddavProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "carddav", Name: "CardDAV server", Version: Version, Availability: sdk.CheckerAvailability{ + // Domain scope for the probe itself (discovery runs across the + // whole domain via /.well-known + SRV). + ApplyToDomain: true, + + // Service scope so downstream TLS alerts attach to a dedicated + // "CardDAV" service page instead of the domain page. See the + // CalDAV sibling for the rationale; abstract.CardDAV is not in + // the happyDomain service catalog yet but the intent is encoded + // here ahead of time. ApplyToService: true, LimitToServices: []string{"abstract.CardDAV"}, }, diff --git a/carddav/discovery.go b/carddav/discovery.go index ba11060..dcb91ce 100644 --- a/carddav/discovery.go +++ b/carddav/discovery.go @@ -7,6 +7,8 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) +// DiscoverEntries implements sdk.DiscoveryPublisher. See the CalDAV sibling +// for the rationale — the shared helper produces the TLS discovery entries. func (p *carddavProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { obs, ok := data.(*dav.Observation) if !ok { diff --git a/carddav/provider.go b/carddav/provider.go index aa4ddc4..6a5e149 100644 --- a/carddav/provider.go +++ b/carddav/provider.go @@ -11,7 +11,8 @@ func Provider() sdk.ObservationProvider { return &carddavProvider{} } 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() } diff --git a/carddav/report.go b/carddav/report.go index 6c40948..33610b7 100644 --- a/carddav/report.go +++ b/carddav/report.go @@ -9,7 +9,9 @@ import ( "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) { var d dav.Observation if err := json.Unmarshal(ctx.Data(), &d); err != nil { diff --git a/carddav/types.go b/carddav/types.go index d02fb74..814e506 100644 --- a/carddav/types.go +++ b/carddav/types.go @@ -1,5 +1,7 @@ -// Package carddav: see the CalDAV sibling. The two share everything except -// the protocol-specific home-set and REPORT calls in collect.go. +// Package checker (imported as carddav) implements the CardDAV compliance +// and health checker for happyDomain. See the CalDAV sibling for the shape; +// the two packages share ~everything except the protocol-specific home-set +// and REPORT calls, which live in collect.go. package carddav import "git.happydns.org/checker-dav/internal/dav" diff --git a/cmd/checker-caldav/main.go b/cmd/checker-caldav/main.go index dc9ae7d..78c1f00 100644 --- a/cmd/checker-caldav/main.go +++ b/cmd/checker-caldav/main.go @@ -5,7 +5,7 @@ import ( "log" "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=...". @@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() caldav.Version = Version - srv := server.New(caldav.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(caldav.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/cmd/checker-carddav/main.go b/cmd/checker-carddav/main.go index 721e84b..19f4c2c 100644 --- a/cmd/checker-carddav/main.go +++ b/cmd/checker-carddav/main.go @@ -5,7 +5,7 @@ import ( "log" "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=...". @@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() carddav.Version = Version - srv := server.New(carddav.Provider()) - if err := srv.ListenAndServe(*listenAddr); err != nil { + server := sdk.NewServer(carddav.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/go.mod b/go.mod index 72ebd10..38eb058 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module git.happydns.org/checker-dav go 1.25.0 require ( - git.happydns.org/checker-sdk-go v1.5.0 - git.happydns.org/checker-tls v0.6.2 + git.happydns.org/checker-sdk-go v1.2.0 + git.happydns.org/checker-tls v0.2.0 ) require ( diff --git a/go.sum b/go.sum index 0b76a53..cfaede5 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= -git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= -git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= -git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM= +git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM= github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHCranzkLb8/zjivwQ9DWWDCQRnxTPfaA= diff --git a/internal/dav/client.go b/internal/dav/client.go index 2142c3b..266c31c 100644 --- a/internal/dav/client.go +++ b/internal/dav/client.go @@ -2,46 +2,38 @@ package dav import ( "net/http" - "net/url" - "strings" "time" ) -// NewHTTPClient uses Go's default TLS validation; cert correctness is the -// dedicated TLS checker's job, not ours. +// NewHTTPClient returns an http.Client with a sane default transport for +// probing DAV servers. TLS certificate validation uses Go's default rules — +// dedicated TLS correctness belongs in a separate checker. func NewHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ Timeout: timeout, } } -// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect -// to a different host won't leak credentials to a third party. Matches -// curl's behaviour without --location-trusted. +// basicAuthRoundTripper injects HTTP Basic credentials on every request so +// callers can pass the same client through go-webdav's own API without losing +// auth on internal redirects. type basicAuthRoundTripper struct { user, pass string - host string next http.RoundTripper } func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - if strings.EqualFold(req.URL.Host, b.host) { - req.SetBasicAuth(b.user, b.pass) - } + req.SetBasicAuth(b.user, b.pass) return b.next.RoundTrip(req) } -// WithBasicAuth attaches credentials scoped to the host of contextURL. -func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client { +// WithBasicAuth clones c and attaches Basic credentials to the transport. +func WithBasicAuth(c *http.Client, user, pass string) *http.Client { nc := *c base := c.Transport if base == nil { base = http.DefaultTransport } - host := "" - if u, err := url.Parse(contextURL); err == nil { - host = u.Host - } - nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, host: host, next: base} + nc.Transport = &basicAuthRoundTripper{user: user, pass: pass, next: base} return &nc } diff --git a/internal/dav/discover.go b/internal/dav/discover.go index 5cdb6e3..ce387d4 100644 --- a/internal/dav/discover.go +++ b/internal/dav/discover.go @@ -10,8 +10,13 @@ import ( "strings" ) -// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded -// in the result even on failure so the report can pinpoint the broken step. +// Discover resolves the DAV context URL for domain following RFC 6764: +// /.well-known/{caldav,carddav} first (cheap and works for the common case), +// then SRV/TXT. An explicit override shortcuts everything. +// +// The returned Observation.Discovery is fully populated with whatever was +// learned along the way, even if every step fails — the report leans on the +// captured evidence to tell the user which leg of the discovery broke. func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult { res := DiscoveryResult{} @@ -21,8 +26,8 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli return res } - // Always probe /.well-known even if SRV would suffice: it's the #1 - // misconfig hotspot and we want to surface it. + // 1. /.well-known — this is the #1 misconfig hotspot, so we always probe + // it even if SRV below might have worked, to surface the mistake. wellKnown := "https://" + domain + kind.WellKnownPath() res.WellKnownURL = wellKnown ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown) @@ -35,6 +40,7 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli res.Source = "well-known" } + // 2. SRV + TXT fallback (also informational even when well-known worked). discoverSRV(ctx, kind, domain, &res) if res.ContextURL == "" && len(res.SecureSRV) > 0 { @@ -53,9 +59,10 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli return res } -// followWellKnown follows up to 5 redirects manually so we can record the -// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200 -// at this position is the misconfig we want to flag. +// followWellKnown issues a GET against path and follows up to 5 redirects +// manually so we can capture the redirect chain. The well-known endpoint +// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we +// want to flag, and 404 means the site-owner forgot to set it up. func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) { chain = make([]string, 0, 5) cur := u @@ -64,8 +71,7 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU if reqErr != nil { return "", chain, firstCode, reqErr } - // Snapshot disables the client's own redirect-following so we can - // record each hop ourselves. + // Use a no-redirect client snapshot so we observe each hop. c := *client c.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } resp, doErr := c.Do(req) @@ -74,6 +80,9 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU } resp.Body.Close() chain = append(chain, fmt.Sprintf("%d %s", resp.StatusCode, cur)) + // We track the *first* response code because the rule cares about + // whether /.well-known itself redirected. A chain like 301→200 is + // correct; a chain starting with 200 is the misconfig we flag. if i == 0 { firstCode = resp.StatusCode } @@ -142,6 +151,7 @@ func discoverSRV(ctx context.Context, kind Kind, domain string, res *DiscoveryRe } res.PlaintextSRV = plainRes.records + // Pull the TXT path hint from whichever SRV target we plan to use. var txtName string if len(res.SecureSRV) > 0 { txtName = kind.ServiceName(true) + "._tcp." + trimTrailingDot(res.SecureSRV[0].Target) diff --git a/internal/dav/endpoints.go b/internal/dav/endpoints.go index 0190f2d..6063633 100644 --- a/internal/dav/endpoints.go +++ b/internal/dav/endpoints.go @@ -9,11 +9,19 @@ import ( tlsct "git.happydns.org/checker-tls/contract" ) -// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets -// are emitted alongside the context URL because they're the names operators -// must actually put on the certificate, and they often differ from the -// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120 -// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split. +// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to +// downstream checkers (notably checker-tls) from a completed Observation. +// +// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, +// so we emit a single tls.endpoint.v1 entry for the resolved context URL's +// host:port. If the endpoint was reached via SRV, we also surface each SRV +// target as its own entry — those are the names operators actually need +// certificates on, and they may differ from the queried domain. +// +// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike +// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target +// split: clients negotiate TLS for the hostname they connect to). We fill +// the field unconditionally so consumers can rely on it being set. func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry { if obs == nil || obs.Discovery.ContextURL == "" { return nil @@ -42,12 +50,13 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry { out = append(out, entry) } + // Primary endpoint: the resolved context URL. if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok { add(host, port) } - // Every SRV target is reachable via priority/weight, so each one needs - // its own valid certificate. + // Secondary endpoints: every TLS SRV target. Clients may connect to any + // of them per weight/priority, and all of them need a valid certificate. for _, r := range obs.Discovery.SecureSRV { port := r.Port if port == 0 { @@ -59,6 +68,9 @@ func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry { return out } +// hostPortFromURL extracts the (host, port) pair from an absolute URL. The +// port defaults to 443 for https and 80 for http. Returns ok=false for +// malformed URLs so callers can silently skip them. func hostPortFromURL(raw string) (host string, port uint16, ok bool) { u, err := url.Parse(raw) if err != nil { diff --git a/internal/dav/endpoints_test.go b/internal/dav/endpoints_test.go index a40c14e..35a1f5d 100644 --- a/internal/dav/endpoints_test.go +++ b/internal/dav/endpoints_test.go @@ -36,7 +36,7 @@ func TestDiscoverEntries_contextURLOnly(t *testing.T) { if got[0].Host != "dav.example.com" || got[0].Port != 443 { t.Errorf("unexpected endpoint: %+v", got[0]) } - // Direct TLS; no STARTTLS upgrade. + // Direct TLS — no STARTTLS upgrade. if got[0].STARTTLS != "" { t.Errorf("STARTTLS = %q, want empty (direct TLS)", got[0].STARTTLS) } diff --git a/internal/dav/options.go b/internal/dav/options.go index b950444..ebb3d91 100644 --- a/internal/dav/options.go +++ b/internal/dav/options.go @@ -7,8 +7,10 @@ import ( "strings" ) -// ProbeOptions never treats a missing/incomplete DAV: header as a transport -// error: severity is the caller rule's decision, not ours. +// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV +// headers. A missing DAV: header, or one that does not contain the kind's +// required capability, is not treated as a transport error here — the caller +// rule decides severity from the parsed values. func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) { res := OptionsResult{} req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil) @@ -40,7 +42,8 @@ func ProbeOptions(ctx context.Context, client *http.Client, url string) (Options 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 { for _, c := range o.DAVClasses { if strings.EqualFold(c, cap) { @@ -50,6 +53,7 @@ func (o OptionsResult) HasCapability(cap string) bool { return false } +// AllowsMethod returns true when the OPTIONS response's Allow: listed m. func (o OptionsResult) AllowsMethod(m string) bool { for _, a := range o.AllowMethods { if strings.EqualFold(a, m) { @@ -59,8 +63,9 @@ func (o OptionsResult) AllowsMethod(m string) bool { return false } -// parseCSVHeader merges repeated headers (net/http keeps them separate) -// into a single split-and-trimmed slice. +// parseCSVHeader splits one or more header values on commas, trims, and drops +// empties. Multiple headers of the same name (net/http preserves them) are +// merged. func parseCSVHeader(values []string) []string { var out []string for _, v := range values { @@ -73,6 +78,8 @@ func parseCSVHeader(values []string) []string { return out } +// authScheme returns the scheme token from a WWW-Authenticate header value +// ("Basic realm=\"x\"" → "Basic"). Empty if the value is malformed. func authScheme(h string) string { h = strings.TrimSpace(h) if h == "" { diff --git a/internal/dav/options_shared.go b/internal/dav/options_shared.go index 31856fa..a63217e 100644 --- a/internal/dav/options_shared.go +++ b/internal/dav/options_shared.go @@ -28,7 +28,7 @@ func UserOptions() []sdk.CheckerOptionDocumentation { Id: "context_url", Type: "string", Label: "Explicit context URL", - Description: "Optional. Bypasses /.well-known and SRV discovery. Use for servers with a non-standard layout.", + Description: "Optional. Bypasses /.well-known and SRV discovery — use for servers with a non-standard layout.", Placeholder: "https://dav.example.com/caldav/", }, } @@ -56,9 +56,10 @@ func RunOptions() []sdk.CheckerOptionDocumentation { } } -// InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the -// standalone /check page. Discovery happens inside Collect, so all the -// human owes us is the domain. +// InteractiveForm returns the fields shown on the standalone /check page. +// Discovery (well-known + SRV) happens inside Collect, so the human only +// needs to provide a domain plus the same optional knobs exposed to +// happyDomain users. func InteractiveForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { @@ -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) { domain := strings.TrimSpace(r.FormValue("domain_name")) if domain == "" { diff --git a/internal/dav/principal.go b/internal/dav/principal.go index e2f6075..3d5750f 100644 --- a/internal/dav/principal.go +++ b/internal/dav/principal.go @@ -10,7 +10,8 @@ import ( "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) { body := ` @@ -60,8 +61,11 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str return inbox, outbox, nil } -// multistatus is intentionally a permissive subset: unknown elements are -// ignored so server-specific extensions don't break parsing. +// ── raw PROPFIND ───────────────────────────────────────────────────────────── + +// multistatus is the subset of the DAV:multistatus XML schema we need to read +// principal URLs and scheduling hrefs. It is intentionally permissive — extra +// elements are ignored, which makes us tolerant of server-specific extensions. type multistatus struct { XMLName xml.Name `xml:"DAV: multistatus"` Response []msResponse `xml:"response"` @@ -84,6 +88,8 @@ type prop struct { type msProp struct { XMLName xml.Name Hrefs []string `xml:"href"` + // A prop may also contain nested , + // which the flat Hrefs slice above captures via xml:"href" descent. } func (p msProp) firstHref() string { @@ -112,7 +118,8 @@ func (m *multistatus) principalHref() []string { 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) { req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body)) if err != nil { @@ -125,9 +132,7 @@ func propFind(ctx context.Context, client *http.Client, url, depth, body string) return nil, err } defer resp.Body.Close() - // 10 MiB cap: probes here read a handful of props on one resource; more - // is either misbehaviour or an attempt at memory exhaustion. - data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) + data, err := io.ReadAll(resp.Body) if err != nil { return nil, err } diff --git a/internal/dav/report.go b/internal/dav/report.go index eef0a89..24dc1e8 100644 --- a/internal/dav/report.go +++ b/internal/dav/report.go @@ -8,10 +8,19 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderReport foregrounds the high-frequency failure modes (well-known -// misconfig, missing DAV class, missing credentials, downstream TLS issues) -// before the full per-phase evidence. tlsRelated is what the host stitched -// from checker-tls; nil simply omits the TLS section. +// RenderReport turns an Observation into a self-contained HTML document. +// +// The report foregrounds action items for the failure modes we see most often +// (well-known misconfig, missing DAV capability, missing credentials, +// downstream TLS issues on the endpoints we published) before showing the +// full per-phase evidence. +// +// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil +// is fine: the TLS section is simply omitted. This is how the happyDomain +// cross-checker composition story (see +// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate +// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS +// dashboard. func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) { data := buildReportData(obs, title, tlsRelated) var buf strings.Builder @@ -63,6 +72,8 @@ func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObser d.Callouts = buildCallouts(o) d.Phases = buildPhases(o) + // Fold downstream TLS probes (published by checker-tls against the + // endpoints we discovered) into the report. tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated) d.TLSSummaries = tlsSummaries for _, c := range tlsCallouts { @@ -104,8 +115,8 @@ func hasSeverity(phases []phaseData, sev string) bool { return false } -// buildCallouts pulls common misconfigurations to the top so operators -// don't have to expand the phase tree to find the fix. +// buildCallouts surfaces the most common misconfigurations at the top of the +// report so operators don't have to read the full phase tree to find the fix. func buildCallouts(o *Observation) []calloutData { var out []calloutData disc := o.Discovery @@ -134,7 +145,7 @@ func buildCallouts(o *Observation) []calloutData { out = append(out, calloutData{ Severity: "crit", Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()), - Body: fmt.Sprintf("The DAV: response header is %q. This endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind), + 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()) { @@ -160,7 +171,7 @@ func exampleContextURL(k Kind) string { func buildPhases(o *Observation) []phaseData { var phases []phaseData - // Phase 1: Discovery + // Phase 1 — Discovery discovery := phaseData{Title: "Discovery"} discovery.Items = append(discovery.Items, itemFor( "/.well-known redirect", @@ -194,7 +205,7 @@ func buildPhases(o *Observation) []phaseData { discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail") phases = append(phases, discovery) - // Phase 2: Transport + OPTIONS + // Phase 2 — Transport + OPTIONS transport := phaseData{Title: "Transport & OPTIONS"} transport.Items = append(transport.Items, itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""), @@ -210,7 +221,7 @@ func buildPhases(o *Observation) []phaseData { transport.Open = hasItemSeverity(transport.Items, "warn", "fail") phases = append(phases, transport) - // Phase 3: Authenticated + // Phase 3 — Authenticated auth := phaseData{Title: "Authenticated probes"} auth.Items = append(auth.Items, 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") phases = append(phases, auth) - // Phase 4: Scheduling (CalDAV only) + // Phase 4 — Scheduling (CalDAV only) if o.Kind == KindCalDAV && o.Scheduling != nil { sched := phaseData{Title: "Scheduling (CalDAV)"} if !o.Scheduling.Advertised { @@ -240,14 +251,15 @@ func buildPhases(o *Observation) []phaseData { return phases } -// buildTLSPhase auto-opens when anything is non-OK so the failure is -// visible without an extra click. +// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase +// rendered at the bottom of the report. Open when anything is non-OK so +// operators don't need to expand it to see the problem. func buildTLSPhase(summaries []TLSSummary) phaseData { p := phaseData{Title: "TLS (from checker-tls)"} for _, s := range summaries { label := s.Address if s.TLSVersion != "" { - label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion) + label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion) } p.Items = append(p.Items, phaseItem{ Label: label, @@ -259,6 +271,8 @@ func buildTLSPhase(summaries []TLSSummary) phaseData { return p } +// ── small helpers used by buildPhases ──────────────────────────────────────── + func wellKnownStatus(d DiscoveryResult) string { if d.Source == "explicit" { return "info" @@ -402,6 +416,8 @@ func summariseSRV(rec []SRVRecord) string { return strings.Join(parts, "; ") } +// ── template ───────────────────────────────────────────────────────────────── + var reportTemplate = template.Must(template.New("dav").Parse(` diff --git a/internal/dav/rules.go b/internal/dav/rules.go index c5d107a..dfad84d 100644 --- a/internal/dav/rules.go +++ b/internal/dav/rules.go @@ -8,7 +8,8 @@ import ( 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 { rules := []sdk.CheckRule{ &discoveryRule{obsKey: obsKey}, @@ -25,8 +26,9 @@ func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule { return rules } -// WorstStatus picks the highest-severity state. Unknown only wins if every -// rule was Unknown. +// WorstStatus is a CheckAggregator that picks the highest-severity state from +// the individual rule outcomes. StatusUnknown does not degrade the result +// unless every rule returned Unknown. type WorstStatus struct{} func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { @@ -58,6 +60,8 @@ func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { return out } +// ── individual rules ───────────────────────────────────────────────────────── + type baseRule struct { obsKey sdk.ObservationKey } @@ -74,8 +78,9 @@ func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observa return &d, sdk.CheckState{} } -// discoveryRule surfaces the #1 user-facing misconfig: a missing or -// non-redirect /.well-known. +// discoveryRule checks that a context URL was resolved and that the +// /.well-known endpoint is configured as a redirect (the #1 user-facing +// misconfig we want to surface). type discoveryRule struct{ obsKey sdk.ObservationKey } func (r *discoveryRule) Name() string { return "dav_discovery" } @@ -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)", }} } - // /.well-known=200 is legal but discouraged; many clients won't follow - // it. Warn, don't crit. + // /.well-known returning 200 is legal per RFC but strongly discouraged — + // many clients won't follow it. Warn, don't crit. if disc.WellKnownCode == 200 && disc.Source != "explicit" { return []sdk.CheckState{{ Status: sdk.StatusWarn, @@ -104,7 +109,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, } if disc.Source == "srv-txt" && disc.WellKnownError != "" { return []sdk.CheckState{{ - Status: sdk.StatusInfo, + Status: sdk.StatusWarn, Code: "well_known_missing", 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 } 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"}} } +// optionsRule verifies the mandatory DAV class is advertised. type optionsRule struct { obsKey sdk.ObservationKey kind Kind @@ -243,7 +250,7 @@ func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette return []sdk.CheckState{{ Status: sdk.StatusWarn, 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)) @@ -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}} } -// 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 } func (r *schedulingRule) Name() string { return "caldav_scheduling" } diff --git a/internal/dav/tls_related.go b/internal/dav/tls_related.go index 805743c..e07cc20 100644 --- a/internal/dav/tls_related.go +++ b/internal/dav/tls_related.go @@ -11,12 +11,14 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// TLSRelatedKey matches the cross-checker convention in -// happydomain3/docs/checker-discovery-endpoint.md. +// TLSRelatedKey is the observation key we expect a TLS checker to publish for +// the endpoints we discover. Matches the cross-checker convention documented +// in happydomain3/docs/checker-discovery-endpoint.md. const TLSRelatedKey sdk.ObservationKey = "tls_probes" -// tlsProbeView decodes only the fields we actually use; the full TLS schema -// belongs to checker-tls and we don't want to track its evolution here. +// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally +// only read the fields we need and tolerate missing ones — the TLS checker's +// full schema is owned by that checker. type tlsProbeView struct { Host string `json:"host,omitempty"` Port uint16 `json:"port,omitempty"` @@ -53,6 +55,8 @@ type tlsProbeView struct { } `json:"issues,omitempty"` } +// address returns the canonical "host:port" string used to match a probe +// against one of our discovered endpoints. func (v *tlsProbeView) address() string { if v.Endpoint != "" { return v.Endpoint @@ -63,7 +67,8 @@ func (v *tlsProbeView) address() string { 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) { if !v.NotAfter.IsZero() { return v.NotAfter, true @@ -94,9 +99,17 @@ func (v *tlsProbeView) chainOK() (bool, bool) { return false, false } -// parseTLSRelated accepts both the keyed {"probes": {"": …}} shape -// (current checker-tls output, picked by r.Ref) and a bare top-level probe -// (legacy/test fixtures). Returns nil for anything else. +// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil +// when the payload doesn't look like one. +// +// Two payload shapes are accepted: +// +// 1. {"probes": {"": , …}} — 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. — a single top-level probe object, kept for back-compat with +// callers that pre-date the keyed map and with unit-test fixtures. func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { var keyed struct { Probes map[string]tlsProbeView `json:"probes"` @@ -114,6 +127,7 @@ func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { return &v } +// TLSSummary is what the HTML report renders for each probed endpoint. type TLSSummary struct { Address string TLSVersion string @@ -123,12 +137,17 @@ type TLSSummary struct { DaysRemaining int } +// tlsCallout captures a cross-checker issue we want to foreground in the +// "Action items" section of the HTML report. type tlsCallout struct { Severity string // "warn" or "crit" Title string Body string } +// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary +// for rendering, (2) callouts for the top of the report when there's anything +// actionable. Callers pass both through the reportData pipeline. func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) { for _, r := range related { v := parseTLSRelated(r) @@ -229,7 +248,7 @@ func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout { out = append(out, tlsCallout{ Severity: "crit", Title: fmt.Sprintf("Certificate on %s has expired", addr), - Body: fmt.Sprintf("Renew it. Clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)), + Body: fmt.Sprintf("Renew it — clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)), }) case days < 14: out = append(out, tlsCallout{ diff --git a/internal/dav/tls_related_test.go b/internal/dav/tls_related_test.go index 5f39554..e9f9124 100644 --- a/internal/dav/tls_related_test.go +++ b/internal/dav/tls_related_test.go @@ -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"}, }, })}) - // 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. if len(callouts) != 1 || callouts[0].Severity != "warn" { t.Fatalf("want single warn callout, got %+v", callouts) diff --git a/internal/dav/types.go b/internal/dav/types.go index 507c726..ce352a8 100644 --- a/internal/dav/types.go +++ b/internal/dav/types.go @@ -1,11 +1,14 @@ -// Package dav holds code shared by the CalDAV and CardDAV checkers: -// discovery, OPTIONS probing, PROPFIND helpers, and report rendering. +// Package dav holds the code shared between the CalDAV and CardDAV checkers: +// discovery (SRV/TXT, /.well-known), OPTIONS probing, PROPFIND helpers, and +// the HTML report CSS. The CalDAV/CardDAV-specific collect pipelines live in +// their own packages and compose these helpers. package dav import "time" -// Kind is carried end-to-end through a run so shared helpers branch on it -// rather than duplicating per-protocol code. +// Kind distinguishes the two protocol flavours. A single Kind value is carried +// end-to-end through a checker run so shared helpers can pick the right +// service names, well-known paths, and required DAV classes. type Kind string const ( @@ -13,8 +16,8 @@ const ( KindCardDAV Kind = "carddav" ) -// ServiceName returns the RFC 6764 SRV label, with the leading "_" but -// without the "_tcp" suffix. +// ServiceName returns the RFC 6764 SRV service label for kind, with the +// leading "_" but without the "_tcp" suffix. func (k Kind) ServiceName(secure bool) string { switch k { case KindCalDAV: @@ -31,12 +34,13 @@ func (k Kind) ServiceName(secure bool) string { return "" } +// WellKnownPath returns the RFC 6764 well-known path for kind. func (k Kind) WellKnownPath() string { return "/.well-known/" + string(k) } -// RequiredCapability is the DAV: header token a compliant server must -// advertise. +// RequiredCapability is the string that must appear in the DAV: response +// header for the server to qualify as a valid implementation. func (k Kind) RequiredCapability() string { switch k { case KindCalDAV: @@ -47,8 +51,9 @@ func (k Kind) RequiredCapability() string { return "" } -// Observation is what each checker persists. Scheduling is CalDAV-only and -// left nil for CardDAV. +// Observation is the root data structure persisted by either checker. The +// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left +// zero-valued for KindCardDAV. type Observation struct { Kind Kind `json:"kind"` Domain string `json:"domain"` @@ -64,6 +69,7 @@ type Observation struct { CollectedAt time.Time `json:"collected_at"` } +// SRVRecord is a flat, JSON-friendly view of a DNS SRV answer. type SRVRecord struct { Target string `json:"target"` Port uint16 `json:"port"` @@ -71,8 +77,9 @@ type SRVRecord struct { Weight uint16 `json:"weight"` } -// DiscoveryResult records every signal seen during lookup, even on failure, -// so the report can pinpoint which leg of discovery broke. +// DiscoveryResult captures every signal we gathered while locating the +// service: SRV secure/plaintext, TXT path hints, well-known redirects, and +// the ultimately-resolved context URL. type DiscoveryResult struct { SecureSRV []SRVRecord `json:"secure_srv,omitempty"` PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"` @@ -88,13 +95,17 @@ type DiscoveryResult struct { Error string `json:"error,omitempty"` } -// TransportResult is intentionally minimal: cert validation is out of scope -// here, a dedicated TLS checker owns it. +// TransportResult records whether the resolved context URL accepts HTTPS +// requests. TLS certificate validation is out of scope (a dedicated TLS +// checker covers it); we only report the raw transport-level error if any. type TransportResult struct { Reached bool `json:"reached"` Error string `json:"error,omitempty"` } +// OptionsResult captures the response to an OPTIONS request against the +// context URL: which DAV: classes are advertised, which HTTP methods are in +// Allow:, and which authentication schemes the server offered. type OptionsResult struct { StatusCode int `json:"status_code"` DAVClasses []string `json:"dav_classes,omitempty"` @@ -104,20 +115,24 @@ type OptionsResult struct { Error string `json:"error,omitempty"` } -// PrincipalResult.Skipped is set when no credentials were supplied; the -// rule turns that into StatusUnknown rather than a failure. +// PrincipalResult holds the `current-user-principal` URL discovered after +// authenticating. Skipped is set to true when no credentials were supplied +// (the rule surfaces this as StatusUnknown). type PrincipalResult struct { Skipped bool `json:"skipped,omitempty"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` } +// HomeSetResult holds the CalDAV `calendar-home-set` or CardDAV +// `addressbook-home-set` URL for the authenticated principal. type HomeSetResult struct { Skipped bool `json:"skipped,omitempty"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` } +// CollectionInfo describes a single discovered calendar or addressbook. type CollectionInfo struct { Path string `json:"path"` Name string `json:"name,omitempty"` @@ -127,12 +142,15 @@ type CollectionInfo struct { SupportedAddressData []string `json:"supported_address_data,omitempty"` // CardDAV only } +// CollectionsResult is the enumerated content of the home-set. type CollectionsResult struct { Skipped bool `json:"skipped,omitempty"` Items []CollectionInfo `json:"items,omitempty"` Error string `json:"error,omitempty"` } +// ReportResult is the outcome of a minimal REPORT probe against the first +// collection found (empty calendar-query/addressbook-query). type ReportResult struct { Skipped bool `json:"skipped,omitempty"` QueryOK bool `json:"query_ok,omitempty"` @@ -140,7 +158,8 @@ type ReportResult struct { 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 { Advertised bool `json:"advertised"` InboxURL string `json:"inbox_url,omitempty"` diff --git a/plugin/caldav/plugin.go b/plugin/caldav/plugin.go index 2b6b057..522d088 100644 --- a/plugin/caldav/plugin.go +++ b/plugin/caldav/plugin.go @@ -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 import ( @@ -10,6 +12,5 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { caldav.Version = Version - prvd := caldav.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return caldav.Definition(), caldav.Provider(), nil } diff --git a/plugin/carddav/plugin.go b/plugin/carddav/plugin.go index 7b5f11e..98a008e 100644 --- a/plugin/carddav/plugin.go +++ b/plugin/carddav/plugin.go @@ -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 import ( @@ -10,6 +12,5 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { carddav.Version = Version - prvd := carddav.Provider() - return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil + return carddav.Definition(), carddav.Provider(), nil }