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.
This commit is contained in:
commit
7eb0dbddc7
39 changed files with 3324 additions and 0 deletions
149
caldav/collect.go
Normal file
149
caldav/collect.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
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 runs the full CalDAV probe pipeline for the target domain.
|
||||
//
|
||||
// The pipeline is deliberately resilient: every phase records its outcome into
|
||||
// the Observation and we keep going as long as we have something to probe
|
||||
// with. Rules later translate the captured state into CheckStates.
|
||||
func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
domain, _ := sdk.GetOption[string](opts, "domain_name")
|
||||
user, _ := sdk.GetOption[string](opts, "username")
|
||||
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, user, pass)
|
||||
|
||||
// Principal.
|
||||
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
|
||||
|
||||
// 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()
|
||||
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
|
||||
|
||||
// Collections.
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Report probe — empty calendar-query against the first calendar.
|
||||
if len(obs.Collections.Items) > 0 {
|
||||
first := obs.Collections.Items[0].Path
|
||||
obs.Report.ProbePath = first
|
||||
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
|
||||
}
|
||||
|
||||
// Scheduling inbox/outbox — only probe if advertised.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 }
|
||||
50
caldav/definition.go
Normal file
50
caldav/definition.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
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.
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the CalDAV checker.
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "caldav",
|
||||
Name: "CalDAV server",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
// The probe itself only needs a domain name (discovery runs on
|
||||
// the whole domain via /.well-known + SRV), so the checker is
|
||||
// always offered at domain scope.
|
||||
ApplyToDomain: true,
|
||||
|
||||
// Also offered at service scope so alerts — including the TLS
|
||||
// alerts derived from the endpoints we publish — surface on a
|
||||
// dedicated "CalDAV" service page rather than on the domain
|
||||
// page. The abstract.CalDAV service type does not exist in the
|
||||
// happyDomain service catalog yet; until it does, this has no
|
||||
// visible effect, but makes the intent explicit.
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.CalDAV"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: dav.UserOptions(),
|
||||
DomainOpts: dav.DomainOptions(),
|
||||
RunOpts: dav.RunOptions(),
|
||||
},
|
||||
Rules: dav.Rules(dav.KindCalDAV, ObservationKey),
|
||||
Aggregator: dav.WorstStatus{},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Minute,
|
||||
Max: 1 * time.Hour,
|
||||
Default: 15 * time.Minute,
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
}
|
||||
}
|
||||
19
caldav/discovery.go
Normal file
19
caldav/discovery.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
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 {
|
||||
return nil, fmt.Errorf("unexpected data type %T", data)
|
||||
}
|
||||
return dav.DiscoverEntries(obs), nil
|
||||
}
|
||||
30
caldav/provider.go
Normal file
30
caldav/provider.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.happydns.org/checker-dav/internal/dav"
|
||||
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.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &caldavProvider{}
|
||||
}
|
||||
|
||||
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) {
|
||||
return dav.ParseInteractiveForm(r)
|
||||
}
|
||||
29
caldav/report.go
Normal file
29
caldav/report.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package caldav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
"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.
|
||||
func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var d dav.Observation
|
||||
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
|
||||
}
|
||||
d.Kind = dav.KindCalDAV
|
||||
return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey))
|
||||
}
|
||||
17
caldav/types.go
Normal file
17
caldav/types.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// Package checker (imported as caldav by the standalone binary) implements
|
||||
// the CalDAV compliance and health checker for happyDomain.
|
||||
//
|
||||
// It is deliberately kept thin: discovery, OPTIONS, PROPFIND, and reporting
|
||||
// helpers live in git.happydns.org/checker-dav/internal/dav, so this package
|
||||
// only wires the CalDAV-specific options, collect pipeline, rules, and HTML
|
||||
// report together.
|
||||
package caldav
|
||||
|
||||
import "git.happydns.org/checker-dav/internal/dav"
|
||||
|
||||
// ObservationKey identifies CalDAV observations in happyDomain's store.
|
||||
const ObservationKey = "caldav"
|
||||
|
||||
// Data is the persisted observation shape. Callers read it back via
|
||||
// obs.Get(ctx, ObservationKey, &Data).
|
||||
type Data = dav.Observation
|
||||
Loading…
Add table
Add a link
Reference in a new issue