diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..f892ef7 --- /dev/null +++ b/.drone-manifest.yml @@ -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 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..c56bcc3 --- /dev/null +++ b/.drone.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 91af07b..1922848 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,5 +11,8 @@ 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 ae61715..58a20f0 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -checker-dummy +checker-dav 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 a9c67bb..3ebfc8e 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 04dfc83..5033c61 100644 --- a/caldav/collect.go +++ b/caldav/collect.go @@ -11,11 +11,9 @@ import ( "github.com/emersion/go-webdav/caldav" ) -// Collect runs the full CalDAV probe pipeline for the target domain. -// -// The pipeline is deliberately resilient: every phase records its outcome into -// the Observation and we keep going as long as we have something to probe -// with. Rules later translate the captured state into CheckStates. +// Collect is intentionally resilient: each phase records its outcome and we +// keep going as long as the next phase has something to work with. Rules +// later turn the captured state into CheckStates. func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { domain, _ := sdk.GetOption[string](opts, "domain_name") user, _ := sdk.GetOption[string](opts, "username") @@ -38,13 +36,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 { @@ -54,7 +52,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 @@ -63,9 +61,8 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) ( 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) if err != nil { obs.Principal.Error = err.Error() @@ -76,7 +73,6 @@ 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() @@ -93,7 +89,6 @@ 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() @@ -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 { first := obs.Collections.Items[0].Path obs.Report.ProbePath = first @@ -131,7 +127,6 @@ 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 { @@ -144,6 +139,4 @@ 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 5899293..d516a2e 100644 --- a/caldav/definition.go +++ b/caldav/definition.go @@ -7,28 +7,15 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Version is the checker version reported in CheckerDefinition.Version. -// Overridden at link time by the standalone binary via -ldflags. +// Version is overridden at link time by the standalone binary via -ldflags. var Version = "built-in" -// Definition returns the CheckerDefinition for the CalDAV checker. -func Definition() *sdk.CheckerDefinition { +func (p *caldavProvider) 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 76e7fc3..b01a9fb 100644 --- a/caldav/discovery.go +++ b/caldav/discovery.go @@ -7,9 +7,6 @@ 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 45c4fcc..1b6f8b0 100644 --- a/caldav/provider.go +++ b/caldav/provider.go @@ -7,12 +7,9 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Provider returns the CalDAV observation provider. -// -// The returned value implements sdk.ObservationProvider, plus the optional -// CheckerDefinitionProvider, CheckerHTMLReporter, and EndpointDiscoverer -// interfaces so the SDK's HTTP server exposes /definition, /evaluate, -// /report, and forwards discovered TLS endpoints to downstream checkers. +// Provider's return value also satisfies CheckerDefinitionProvider, +// CheckerHTMLReporter, and EndpointDiscoverer; the SDK server probes for +// those at runtime. func Provider() sdk.ObservationProvider { return &caldavProvider{} } @@ -21,8 +18,6 @@ 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 7355c2d..6cbdfef 100644 --- a/caldav/report.go +++ b/caldav/report.go @@ -9,16 +9,9 @@ import ( "git.happydns.org/checker-dav/internal/dav" ) -// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider. -// -// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV -// produce visually identical reports; the only differences are the title -// and the set of phases (CalDAV includes Scheduling). -// -// Downstream TLS probes published for the endpoints we discovered are read -// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts + -// dedicated TLS phase) — per -// happydomain3/docs/checker-discovery-endpoint.md. +// GetHTMLReport delegates to the shared renderer so CalDAV and CardDAV +// produce visually identical reports. Downstream TLS probes attached via +// ctx.Related(dav.TLSRelatedKey) are folded in. func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d dav.Observation if err := json.Unmarshal(ctx.Data(), &d); err != nil { diff --git a/caldav/types.go b/caldav/types.go index 1a2ddc3..d139b69 100644 --- a/caldav/types.go +++ b/caldav/types.go @@ -1,17 +1,9 @@ -// 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 wires the CalDAV-specific options, collect pipeline, +// rules, and HTML report on top of the shared helpers in internal/dav. package caldav import "git.happydns.org/checker-dav/internal/dav" -// 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 a36ed57..188dca4 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, user, pass) + authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, 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 8ba5d6a..085ea08 100644 --- a/carddav/definition.go +++ b/carddav/definition.go @@ -9,21 +9,12 @@ import ( var Version = "built-in" -func Definition() *sdk.CheckerDefinition { +func (p *carddavProvider) 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 dcb91ce..ba11060 100644 --- a/carddav/discovery.go +++ b/carddav/discovery.go @@ -7,8 +7,6 @@ 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 6a5e149..aa4ddc4 100644 --- a/carddav/provider.go +++ b/carddav/provider.go @@ -11,8 +11,7 @@ func Provider() sdk.ObservationProvider { return &carddavProvider{} } type carddavProvider struct{} -func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey } -func (p *carddavProvider) Definition() *sdk.CheckerDefinition { return Definition() } +func (p *carddavProvider) Key() sdk.ObservationKey { return ObservationKey } func (p *carddavProvider) RenderForm() []sdk.CheckerOptionField { return dav.InteractiveForm() } diff --git a/carddav/report.go b/carddav/report.go index 33610b7..6c40948 100644 --- a/carddav/report.go +++ b/carddav/report.go @@ -9,9 +9,7 @@ import ( "git.happydns.org/checker-dav/internal/dav" ) -// GetHTMLReport folds downstream TLS probes (published on our discovered -// endpoints) into the CardDAV report via ctx.Related — see the CalDAV -// sibling for the rationale. +// GetHTMLReport: see the CalDAV sibling. func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d dav.Observation if err := json.Unmarshal(ctx.Data(), &d); err != nil { diff --git a/carddav/types.go b/carddav/types.go index 814e506..d02fb74 100644 --- a/carddav/types.go +++ b/carddav/types.go @@ -1,7 +1,5 @@ -// 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: see the CalDAV sibling. The two share everything except +// the protocol-specific home-set and REPORT calls in collect.go. package carddav import "git.happydns.org/checker-dav/internal/dav" diff --git a/cmd/checker-caldav/main.go b/cmd/checker-caldav/main.go index 78c1f00..dc9ae7d 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" - 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=...". @@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() caldav.Version = Version - server := sdk.NewServer(caldav.Provider()) - if err := server.ListenAndServe(*listenAddr); err != nil { + srv := server.New(caldav.Provider()) + if err := srv.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 19f4c2c..721e84b 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" - 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=...". @@ -16,8 +16,8 @@ var listenAddr = flag.String("listen", ":8080", "HTTP listen address") func main() { flag.Parse() carddav.Version = Version - server := sdk.NewServer(carddav.Provider()) - if err := server.ListenAndServe(*listenAddr); err != nil { + srv := server.New(carddav.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { log.Fatalf("server error: %v", err) } } diff --git a/go.mod b/go.mod index 38eb058..72ebd10 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.2.0 - git.happydns.org/checker-tls v0.2.0 + git.happydns.org/checker-sdk-go v1.5.0 + git.happydns.org/checker-tls v0.6.2 ) require ( diff --git a/go.sum b/go.sum index cfaede5..0b76a53 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -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= +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= 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 266c31c..2142c3b 100644 --- a/internal/dav/client.go +++ b/internal/dav/client.go @@ -2,38 +2,46 @@ package dav import ( "net/http" + "net/url" + "strings" "time" ) -// NewHTTPClient returns an http.Client with a sane default transport for -// probing DAV servers. TLS certificate validation uses Go's default rules — -// dedicated TLS correctness belongs in a separate checker. +// NewHTTPClient uses Go's default TLS validation; cert correctness is the +// dedicated TLS checker's job, not ours. func NewHTTPClient(timeout time.Duration) *http.Client { return &http.Client{ Timeout: timeout, } } -// basicAuthRoundTripper 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. +// basicAuthRoundTripper scopes Basic auth to a single host, so a redirect +// to a different host won't leak credentials to a third party. Matches +// curl's behaviour without --location-trusted. type basicAuthRoundTripper struct { user, pass string + host string next http.RoundTripper } func (b *basicAuthRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - 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) } -// WithBasicAuth clones c and attaches Basic credentials to the transport. -func WithBasicAuth(c *http.Client, user, pass string) *http.Client { +// WithBasicAuth attaches credentials scoped to the host of contextURL. +func WithBasicAuth(c *http.Client, contextURL, user, pass string) *http.Client { nc := *c base := c.Transport if base == nil { base = http.DefaultTransport } - 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 } diff --git a/internal/dav/discover.go b/internal/dav/discover.go index ce387d4..5cdb6e3 100644 --- a/internal/dav/discover.go +++ b/internal/dav/discover.go @@ -10,13 +10,8 @@ import ( "strings" ) -// Discover resolves the DAV context URL for domain following RFC 6764: -// /.well-known/{caldav,carddav} first (cheap and works for the common case), -// then SRV/TXT. An explicit override shortcuts everything. -// -// The returned Observation.Discovery is fully populated with whatever was -// learned along the way, even if every step fails — the report leans on the -// captured evidence to tell the user which leg of the discovery broke. +// Discover resolves the DAV context URL per RFC 6764. Every leg is recorded +// in the result even on failure so the report can pinpoint the broken step. func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult { res := DiscoveryResult{} @@ -26,8 +21,8 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli return res } - // 1. /.well-known — this is the #1 misconfig hotspot, so we always probe - // it even if SRV below might have worked, to surface the mistake. + // Always probe /.well-known even if SRV would suffice: it's the #1 + // misconfig hotspot and we want to surface it. wellKnown := "https://" + domain + kind.WellKnownPath() res.WellKnownURL = wellKnown ctxURL, chain, code, err := followWellKnown(ctx, client, wellKnown) @@ -40,7 +35,6 @@ 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 { @@ -59,10 +53,9 @@ func Discover(ctx context.Context, client *http.Client, kind Kind, domain, expli return res } -// followWellKnown issues a GET against path and follows up to 5 redirects -// manually so we can capture the redirect chain. The well-known endpoint -// SHOULD return a 3xx (RFC 6764 §5); returning 200 is a common misconfig we -// want to flag, and 404 means the site-owner forgot to set it up. +// followWellKnown follows up to 5 redirects manually so we can record the +// chain and the *first* status, since RFC 6764 §5 expects a 3xx and a 200 +// at this position is the misconfig we want to flag. func followWellKnown(ctx context.Context, client *http.Client, u string) (finalURL string, chain []string, firstCode int, err error) { chain = make([]string, 0, 5) cur := u @@ -71,7 +64,8 @@ func followWellKnown(ctx context.Context, client *http.Client, u string) (finalU if reqErr != nil { 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.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } resp, doErr := c.Do(req) @@ -80,9 +74,6 @@ 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 } @@ -151,7 +142,6 @@ 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 6063633..0190f2d 100644 --- a/internal/dav/endpoints.go +++ b/internal/dav/endpoints.go @@ -9,19 +9,11 @@ import ( tlsct "git.happydns.org/checker-tls/contract" ) -// DiscoverEntries derives TLS DiscoveryEntry records worth handing off to -// downstream checkers (notably checker-tls) from a completed Observation. -// -// A CalDAV/CardDAV context URL always implies a direct-TLS HTTPS endpoint, -// so we emit a single tls.endpoint.v1 entry for the resolved context URL's -// host:port. If the endpoint was reached via SRV, we also surface each SRV -// target as its own entry — those are the names operators actually need -// certificates on, and they may differ from the queried domain. -// -// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike -// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target -// split: clients negotiate TLS for the hostname they connect to). We fill -// the field unconditionally so consumers can rely on it being set. +// DiscoverEntries hands TLS endpoints to downstream checkers. SRV targets +// are emitted alongside the context URL because they're the names operators +// must actually put on the certificate, and they often differ from the +// queried domain. SNI is always equal to Host: unlike XMPP (RFC 6120 +// §13.7.2.1), CalDAV/CardDAV has no source-vs-target split. func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry { if obs == nil || obs.Discovery.ContextURL == "" { return nil @@ -50,13 +42,12 @@ 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) } - // Secondary endpoints: every TLS SRV target. Clients may connect to any - // of them per weight/priority, and all of them need a valid certificate. + // Every SRV target is reachable via priority/weight, so each one needs + // its own valid certificate. for _, r := range obs.Discovery.SecureSRV { port := r.Port if port == 0 { @@ -68,9 +59,6 @@ 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 35a1f5d..a40c14e 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 ebb3d91..b950444 100644 --- a/internal/dav/options.go +++ b/internal/dav/options.go @@ -7,10 +7,8 @@ import ( "strings" ) -// ProbeOptions issues an HTTP OPTIONS against url and reports the parsed DAV -// headers. A missing DAV: header, or one that does not contain the kind's -// required capability, is not treated as a transport error here — the caller -// rule decides severity from the parsed values. +// ProbeOptions never treats a missing/incomplete DAV: header as a transport +// error: severity is the caller rule's decision, not ours. func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) { res := OptionsResult{} req, err := http.NewRequestWithContext(ctx, http.MethodOptions, url, nil) @@ -42,8 +40,7 @@ func ProbeOptions(ctx context.Context, client *http.Client, url string) (Options return res, nil } -// HasCapability returns true when the OPTIONS response advertised cap in the -// DAV: header. Matching is case-insensitive, per RFC 4918 §10.1. +// HasCapability matches case-insensitively per RFC 4918 §10.1. func (o OptionsResult) HasCapability(cap string) bool { for _, c := range o.DAVClasses { if strings.EqualFold(c, cap) { @@ -53,7 +50,6 @@ 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) { @@ -63,9 +59,8 @@ func (o OptionsResult) AllowsMethod(m string) bool { return false } -// parseCSVHeader splits one or more header values on commas, trims, and drops -// empties. Multiple headers of the same name (net/http preserves them) are -// merged. +// parseCSVHeader merges repeated headers (net/http keeps them separate) +// into a single split-and-trimmed slice. func parseCSVHeader(values []string) []string { var out []string for _, v := range values { @@ -78,8 +73,6 @@ 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 a63217e..31856fa 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,10 +56,9 @@ func RunOptions() []sdk.CheckerOptionDocumentation { } } -// 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. +// InteractiveForm mirrors UserOptions+DomainOptions+RunOptions for the +// standalone /check page. Discovery happens inside Collect, so all the +// human owes us is the domain. func InteractiveForm() []sdk.CheckerOptionField { return []sdk.CheckerOptionField{ { @@ -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) { domain := strings.TrimSpace(r.FormValue("domain_name")) if domain == "" { diff --git a/internal/dav/principal.go b/internal/dav/principal.go index 3d5750f..e2f6075 100644 --- a/internal/dav/principal.go +++ b/internal/dav/principal.go @@ -10,8 +10,7 @@ import ( "strings" ) -// FindPrincipal requires credentials on client; a 401/403 from the server -// bubbles up as the returned error. +// FindPrincipal requires authenticated credentials on client. func FindPrincipal(ctx context.Context, client *http.Client, contextURL string) (string, error) { body := ` @@ -61,11 +60,8 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str return inbox, outbox, nil } -// ── raw PROPFIND ───────────────────────────────────────────────────────────── - -// multistatus is the subset of the DAV:multistatus XML schema we need to read -// principal URLs and scheduling hrefs. It is intentionally permissive — extra -// elements are ignored, which makes us tolerant of server-specific extensions. +// multistatus is intentionally a permissive subset: unknown elements are +// ignored so server-specific extensions don't break parsing. type multistatus struct { XMLName xml.Name `xml:"DAV: multistatus"` Response []msResponse `xml:"response"` @@ -88,8 +84,6 @@ 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 { @@ -118,8 +112,7 @@ func (m *multistatus) principalHref() []string { return out } -// propFind is a small PROPFIND helper tuned for small single-resource probes. -// It returns a parsed multistatus; transport-level failures bubble up as err. +// propFind is tuned for small single-resource probes; not for large listings. func propFind(ctx context.Context, client *http.Client, url, depth, body string) (*multistatus, error) { req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, strings.NewReader(body)) if err != nil { @@ -132,7 +125,9 @@ func propFind(ctx context.Context, client *http.Client, url, depth, body string) return nil, err } 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 { return nil, err } diff --git a/internal/dav/report.go b/internal/dav/report.go index 24dc1e8..eef0a89 100644 --- a/internal/dav/report.go +++ b/internal/dav/report.go @@ -8,19 +8,10 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// RenderReport turns an Observation into a self-contained HTML document. -// -// The report foregrounds action items for the failure modes we see most often -// (well-known misconfig, missing DAV capability, missing credentials, -// downstream TLS issues on the endpoints we published) before showing the -// full per-phase evidence. -// -// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil -// is fine: the TLS section is simply omitted. This is how the happyDomain -// cross-checker composition story (see -// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate -// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS -// dashboard. +// RenderReport foregrounds the high-frequency failure modes (well-known +// misconfig, missing DAV class, missing credentials, downstream TLS issues) +// before the full per-phase evidence. tlsRelated is what the host stitched +// from checker-tls; nil simply omits the TLS section. func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) { data := buildReportData(obs, title, tlsRelated) var buf strings.Builder @@ -72,8 +63,6 @@ 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 { @@ -115,8 +104,8 @@ func hasSeverity(phases []phaseData, sev string) bool { return false } -// buildCallouts surfaces the most common misconfigurations at the top of the -// report so operators don't have to read the full phase tree to find the fix. +// buildCallouts pulls common misconfigurations to the top so operators +// don't have to expand the phase tree to find the fix. func buildCallouts(o *Observation) []calloutData { var out []calloutData disc := o.Discovery @@ -145,7 +134,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()) { @@ -171,7 +160,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", @@ -205,7 +194,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, ""), @@ -221,7 +210,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), @@ -232,7 +221,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 { @@ -251,15 +240,14 @@ func buildPhases(o *Observation) []phaseData { return phases } -// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase -// rendered at the bottom of the report. Open when anything is non-OK so -// operators don't need to expand it to see the problem. +// buildTLSPhase auto-opens when anything is non-OK so the failure is +// visible without an extra click. func buildTLSPhase(summaries []TLSSummary) phaseData { p := phaseData{Title: "TLS (from checker-tls)"} for _, s := range summaries { label := s.Address if s.TLSVersion != "" { - label = fmt.Sprintf("%s — %s", s.Address, s.TLSVersion) + label = fmt.Sprintf("%s (%s)", s.Address, s.TLSVersion) } p.Items = append(p.Items, phaseItem{ Label: label, @@ -271,8 +259,6 @@ func buildTLSPhase(summaries []TLSSummary) phaseData { return p } -// ── small helpers used by buildPhases ──────────────────────────────────────── - func wellKnownStatus(d DiscoveryResult) string { if d.Source == "explicit" { return "info" @@ -416,8 +402,6 @@ 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 dfad84d..c5d107a 100644 --- a/internal/dav/rules.go +++ b/internal/dav/rules.go @@ -8,8 +8,7 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// Rules returns the default rule set for kind. CardDAV gets the full set -// except `scheduling`, which only applies to CalDAV. +// Rules omits scheduling for CardDAV (CalDAV-only). func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule { rules := []sdk.CheckRule{ &discoveryRule{obsKey: obsKey}, @@ -26,9 +25,8 @@ func Rules(kind Kind, obsKey sdk.ObservationKey) []sdk.CheckRule { return rules } -// WorstStatus is a CheckAggregator that picks the highest-severity state from -// the individual rule outcomes. StatusUnknown does not degrade the result -// unless every rule returned Unknown. +// WorstStatus picks the highest-severity state. Unknown only wins if every +// rule was Unknown. type WorstStatus struct{} func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { @@ -60,8 +58,6 @@ func (WorstStatus) Aggregate(states []sdk.CheckState) sdk.CheckState { return out } -// ── individual rules ───────────────────────────────────────────────────────── - type baseRule struct { obsKey sdk.ObservationKey } @@ -78,9 +74,8 @@ func (r *baseRule) get(ctx context.Context, obs sdk.ObservationGetter) (*Observa return &d, sdk.CheckState{} } -// discoveryRule checks that a context URL was resolved and that the -// /.well-known endpoint is configured as a redirect (the #1 user-facing -// misconfig we want to surface). +// discoveryRule surfaces the #1 user-facing misconfig: a missing or +// non-redirect /.well-known. type discoveryRule struct{ obsKey sdk.ObservationKey } func (r *discoveryRule) Name() string { return "dav_discovery" } @@ -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)", }} } - // /.well-known returning 200 is legal per RFC but strongly discouraged — - // many clients won't follow it. Warn, don't crit. + // /.well-known=200 is legal but discouraged; many clients won't follow + // it. Warn, don't crit. if disc.WellKnownCode == 200 && disc.Source != "explicit" { return []sdk.CheckState{{ Status: sdk.StatusWarn, @@ -109,7 +104,7 @@ func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, } if disc.Source == "srv-txt" && disc.WellKnownError != "" { return []sdk.CheckState{{ - Status: sdk.StatusWarn, + Status: sdk.StatusInfo, Code: "well_known_missing", 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. -// TLS specifics (cert chain, version) are explicitly out of scope. +// transportRule covers reachability only; cert specifics are out of scope. type transportRule struct{ obsKey sdk.ObservationKey } 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"}} } -// optionsRule verifies the mandatory DAV class is advertised. type optionsRule struct { obsKey sdk.ObservationKey kind Kind @@ -250,7 +243,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)) @@ -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}} } -// schedulingRule is CalDAV-only: if the server advertises calendar-schedule, -// the principal should expose inbox/outbox URLs. +// schedulingRule is CalDAV-only. 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 e07cc20..805743c 100644 --- a/internal/dav/tls_related.go +++ b/internal/dav/tls_related.go @@ -11,14 +11,12 @@ import ( sdk "git.happydns.org/checker-sdk-go/checker" ) -// TLSRelatedKey is the observation key we expect a TLS checker to publish for -// the endpoints we discover. Matches the cross-checker convention documented -// in happydomain3/docs/checker-discovery-endpoint.md. +// TLSRelatedKey matches the cross-checker convention in +// happydomain3/docs/checker-discovery-endpoint.md. const TLSRelatedKey sdk.ObservationKey = "tls_probes" -// tlsProbeView is a permissive decode of a TLS probe payload. We intentionally -// only read the fields we need and tolerate missing ones — the TLS checker's -// full schema is owned by that checker. +// tlsProbeView decodes only the fields we actually use; the full TLS schema +// belongs to checker-tls and we don't want to track its evolution here. type tlsProbeView struct { Host string `json:"host,omitempty"` Port uint16 `json:"port,omitempty"` @@ -55,8 +53,6 @@ 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 @@ -67,8 +63,7 @@ func (v *tlsProbeView) address() string { return "" } -// certExpiry normalises the two schema shapes into a single (t, ok) pair so -// callers don't have to know which one the TLS checker emits. +// certExpiry hides the two payload shapes from callers. func (v *tlsProbeView) certExpiry() (time.Time, bool) { if !v.NotAfter.IsZero() { return v.NotAfter, true @@ -99,17 +94,9 @@ func (v *tlsProbeView) chainOK() (bool, bool) { return false, false } -// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil -// when the payload doesn't look like one. -// -// Two payload shapes are accepted: -// -// 1. {"probes": {"": , …}} — 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. +// 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. func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { var keyed struct { Probes map[string]tlsProbeView `json:"probes"` @@ -127,7 +114,6 @@ 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 @@ -137,17 +123,12 @@ 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) @@ -248,7 +229,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 e9f9124..5f39554 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 ce352a8..507c726 100644 --- a/internal/dav/types.go +++ b/internal/dav/types.go @@ -1,14 +1,11 @@ -// 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 holds code shared by the CalDAV and CardDAV checkers: +// discovery, OPTIONS probing, PROPFIND helpers, and report rendering. package dav import "time" -// Kind distinguishes the two protocol flavours. A single Kind value is carried -// end-to-end through a checker run so shared helpers can pick the right -// service names, well-known paths, and required DAV classes. +// Kind is carried end-to-end through a run so shared helpers branch on it +// rather than duplicating per-protocol code. type Kind string const ( @@ -16,8 +13,8 @@ const ( KindCardDAV Kind = "carddav" ) -// ServiceName returns the RFC 6764 SRV service label for kind, with the -// leading "_" but without the "_tcp" suffix. +// ServiceName returns the RFC 6764 SRV label, with the leading "_" but +// without the "_tcp" suffix. func (k Kind) ServiceName(secure bool) string { switch k { case KindCalDAV: @@ -34,13 +31,12 @@ 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 string that must appear in the DAV: response -// header for the server to qualify as a valid implementation. +// RequiredCapability is the DAV: header token a compliant server must +// advertise. func (k Kind) RequiredCapability() string { switch k { case KindCalDAV: @@ -51,9 +47,8 @@ func (k Kind) RequiredCapability() string { return "" } -// Observation is the root data structure persisted by either checker. The -// CalDAV-only fields (Scheduling) are populated for KindCalDAV runs and left -// zero-valued for KindCardDAV. +// Observation is what each checker persists. Scheduling is CalDAV-only and +// left nil for CardDAV. type Observation struct { Kind Kind `json:"kind"` Domain string `json:"domain"` @@ -69,7 +64,6 @@ 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"` @@ -77,9 +71,8 @@ type SRVRecord struct { Weight uint16 `json:"weight"` } -// DiscoveryResult captures every signal we gathered while locating the -// service: SRV secure/plaintext, TXT path hints, well-known redirects, and -// the ultimately-resolved context URL. +// DiscoveryResult records every signal seen during lookup, even on failure, +// so the report can pinpoint which leg of discovery broke. type DiscoveryResult struct { SecureSRV []SRVRecord `json:"secure_srv,omitempty"` PlaintextSRV []SRVRecord `json:"plaintext_srv,omitempty"` @@ -95,17 +88,13 @@ type DiscoveryResult struct { Error string `json:"error,omitempty"` } -// TransportResult records whether the resolved context URL accepts HTTPS -// requests. TLS certificate validation is out of scope (a dedicated TLS -// checker covers it); we only report the raw transport-level error if any. +// TransportResult is intentionally minimal: cert validation is out of scope +// here, a dedicated TLS checker owns it. type TransportResult struct { Reached bool `json:"reached"` Error string `json:"error,omitempty"` } -// 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"` @@ -115,24 +104,20 @@ type OptionsResult struct { Error string `json:"error,omitempty"` } -// PrincipalResult holds the `current-user-principal` URL discovered after -// authenticating. Skipped is set to true when no credentials were supplied -// (the rule surfaces this as StatusUnknown). +// PrincipalResult.Skipped is set when no credentials were supplied; the +// rule turns that into StatusUnknown rather than a failure. type PrincipalResult struct { Skipped bool `json:"skipped,omitempty"` URL string `json:"url,omitempty"` Error string `json:"error,omitempty"` } -// 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"` @@ -142,15 +127,12 @@ 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"` @@ -158,8 +140,7 @@ type ReportResult struct { Error string `json:"error,omitempty"` } -// SchedulingResult is CalDAV-only: presence of inbox/outbox when the server -// advertises the `calendar-schedule` capability. +// SchedulingResult is CalDAV-only. 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 522d088..2b6b057 100644 --- a/plugin/caldav/plugin.go +++ b/plugin/caldav/plugin.go @@ -1,6 +1,4 @@ -// Command plugin is the happyDomain Go-plugin entrypoint for the CalDAV -// checker. Built with `go build -buildmode=plugin` and loaded at runtime by -// happyDomain. +// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain. package main import ( @@ -12,5 +10,6 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { caldav.Version = Version - return caldav.Definition(), caldav.Provider(), nil + prvd := caldav.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil } diff --git a/plugin/carddav/plugin.go b/plugin/carddav/plugin.go index 98a008e..7b5f11e 100644 --- a/plugin/carddav/plugin.go +++ b/plugin/carddav/plugin.go @@ -1,6 +1,4 @@ -// Command plugin is the happyDomain Go-plugin entrypoint for the CardDAV -// checker. Built with `go build -buildmode=plugin` and loaded at runtime by -// happyDomain. +// Built with `go build -buildmode=plugin` and loaded at runtime by happyDomain. package main import ( @@ -12,5 +10,6 @@ var Version = "custom-build" func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { carddav.Version = Version - return carddav.Definition(), carddav.Provider(), nil + prvd := carddav.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil }