Compare commits

...

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

35 changed files with 377 additions and 318 deletions

22
.drone-manifest.yml Normal file
View file

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

187
.drone.yml Normal file
View file

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

View file

@ -11,5 +11,8 @@ RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /che
FROM scratch
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"]

2
NOTICE
View file

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

View file

@ -38,27 +38,27 @@ view; the default is a JSON metrics dump.
Both checkers accept the same options:
- `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

View file

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

View file

@ -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"},
},

View file

@ -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 {

View file

@ -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) {

View file

@ -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 {

View file

@ -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

View file

@ -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 {

View file

@ -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"},
},

View file

@ -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 {

View file

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

View file

@ -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 {

View file

@ -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"

View file

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

View file

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

4
go.mod
View file

@ -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 (

8
go.sum
View file

@ -1,7 +1,7 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.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=

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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 == "" {

View file

@ -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 == "" {

View file

@ -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 := `<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:">
@ -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 <current-user-principal><href>…</href></current-user-principal>,
// 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
}

View file

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>

View file

@ -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" }

View file

@ -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": {"<ref>": <probe>, …}} — the current convention used by
// checker-tls. Each consumer picks its own probe via r.Ref — the value
// is the DiscoveryEntry.Ref that the producer originally emitted,
// preserved by the host along the lineage chain.
// 2. <probe> — a single top-level probe object, kept for back-compat with
// callers that pre-date the keyed map and with unit-test fixtures.
// parseTLSRelated accepts both the keyed {"probes": {"<ref>": …}} shape
// (current checker-tls output, picked by r.Ref) and a bare top-level probe
// (legacy/test fixtures). Returns nil for anything else.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
@ -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{

View file

@ -65,7 +65,7 @@ func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
{"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"},
},
})})
// 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)

View file

@ -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"`

View file

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

View file

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