checker-dav/caldav/collect.go
Pierre-Olivier Mercier 7d5535fddf Initial commit
CalDAV and CardDAV checkers sharing a single Go module. Discovery follows
RFC 6764 (/.well-known + SRV/TXT), authenticated probes cover principal,
home-set, collections and a minimal REPORT query on top of go-webdav.
Common shape in internal/dav/; CalDAV adds a scheduling rule.

Surfaces its context URL (and each secure-SRV target) as TLS endpoints via
the EndpointDiscoverer interface, so the dedicated TLS checker can pick
them up without re-parsing observations.

HTML report foregrounds common misconfigs (well-known returning 200,
missing SRV, plaintext-only SRV, missing DAV capability, skipped auth
phase) as action-item callouts before the full phase breakdown.
2026-04-26 21:47:40 +07:00

142 lines
4 KiB
Go

package caldav
import (
"context"
"net/http"
"time"
"git.happydns.org/checker-dav/internal/dav"
sdk "git.happydns.org/checker-sdk-go/checker"
webdav "github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
)
// Collect is intentionally resilient: each phase records its outcome and we
// keep going as long as the next phase has something to work with. Rules
// later turn the captured state into CheckStates.
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")
pass, _ := sdk.GetOption[string](opts, "password")
explicit, _ := sdk.GetOption[string](opts, "context_url")
timeoutSec := sdk.GetFloatOption(opts, "timeout_seconds", 10)
timeout := time.Duration(timeoutSec * float64(time.Second))
if timeout <= 0 {
timeout = 10 * time.Second
}
obs := &dav.Observation{
Kind: dav.KindCalDAV,
Domain: domain,
HasCredentials: user != "" && pass != "",
CollectedAt: time.Now(),
Scheduling: &dav.SchedulingResult{},
}
anonClient := dav.NewHTTPClient(timeout)
// 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)
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes
if err != nil {
obs.Transport = dav.TransportResult{Error: err.Error()}
return obs, nil
}
obs.Transport = dav.TransportResult{Reached: true}
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
// Phase 3: Authenticated probes
if !obs.HasCredentials {
obs.Principal.Skipped = true
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
authClient := dav.WithBasicAuth(anonClient, obs.Discovery.ContextURL, user, pass)
principal, err := dav.FindPrincipal(ctx, authClient, obs.Discovery.ContextURL)
if err != nil {
obs.Principal.Error = err.Error()
obs.HomeSet.Skipped = true
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.Principal.URL = principal
cal, err := caldav.NewClient(asHTTPClient(authClient), obs.Discovery.ContextURL)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
home, err := cal.FindCalendarHomeSet(ctx, principal)
if err != nil {
obs.HomeSet.Error = err.Error()
obs.Collections.Skipped = true
obs.Report.Skipped = true
return obs, nil
}
obs.HomeSet.URL = home
calendars, err := cal.FindCalendars(ctx, home)
if err != nil {
obs.Collections.Error = err.Error()
obs.Report.Skipped = true
} else {
for _, c := range calendars {
obs.Collections.Items = append(obs.Collections.Items, dav.CollectionInfo{
Path: c.Path,
Name: c.Name,
Description: c.Description,
MaxResourceSize: c.MaxResourceSize,
SupportedComponentSet: c.SupportedComponentSet,
})
}
}
// 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
q := &caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Comps: []caldav.CalendarCompRequest{
{Name: "VEVENT"},
},
},
}
if _, err := cal.QueryCalendar(ctx, first, q); err != nil {
obs.Report.Error = err.Error()
} else {
obs.Report.QueryOK = true
}
} else {
obs.Report.Skipped = true
}
if obs.Scheduling.Advertised {
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
if err != nil {
obs.Scheduling.Error = err.Error()
}
obs.Scheduling.InboxURL = inbox
obs.Scheduling.OutboxURL = outbox
}
return obs, nil
}
func asHTTPClient(c *http.Client) webdav.HTTPClient { return c }