Compare commits

..

No commits in common. "7eb0dbddc740b57c7b83327cf63749d555a27716" and "6e59eb545dd5ec61283b9f858c83b499ffdf0bbc" have entirely different histories.

22 changed files with 61 additions and 61 deletions

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

@ -38,13 +38,13 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
anonClient := dav.NewHTTPClient(timeout)
// Phase 1 Discovery
// Phase 1: Discovery
obs.Discovery = dav.Discover(ctx, anonClient, dav.KindCalDAV, domain, explicit)
if obs.Discovery.ContextURL == "" {
return obs, nil
}
// Phase 2 Transport + OPTIONS (no auth required)
// Phase 2: Transport + OPTIONS (no auth required)
optsRes, err := dav.ProbeOptions(ctx, anonClient, obs.Discovery.ContextURL)
obs.Options = optsRes
if err != nil {
@ -54,7 +54,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
obs.Transport = dav.TransportResult{Reached: true}
obs.Scheduling.Advertised = optsRes.HasCapability("calendar-schedule")
// Phase 3 Authenticated probes
// Phase 3: Authenticated probes
if !obs.HasCredentials {
obs.Principal.Skipped = true
obs.HomeSet.Skipped = true
@ -110,7 +110,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
}
}
// Report probe empty calendar-query against the first calendar.
// 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
@ -131,7 +131,7 @@ func (p *caldavProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (
obs.Report.Skipped = true
}
// Scheduling inbox/outbox only probe if advertised.
// Scheduling inbox/outbox: only probe if advertised.
if obs.Scheduling.Advertised {
inbox, outbox, err := dav.FindScheduleURLs(ctx, authClient, principal)
if err != nil {

View file

@ -23,8 +23,8 @@ func Definition() *sdk.CheckerDefinition {
// 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
// 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

View file

@ -17,7 +17,7 @@ import (
//
// 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
// dedicated TLS phase), per
// happydomain3/docs/checker-discovery-endpoint.md.
func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d 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

View file

@ -8,7 +8,7 @@ import (
)
// DiscoverEntries implements sdk.DiscoveryPublisher. See the CalDAV sibling
// for the rationale the shared helper produces the TLS discovery entries.
// 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

@ -10,7 +10,7 @@ import (
)
// GetHTMLReport folds downstream TLS probes (published on our discovered
// endpoints) into the CardDAV report via ctx.Related see the CalDAV
// endpoints) into the CardDAV report via ctx.Related; see the CalDAV
// sibling for the rationale.
func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d dav.Observation

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

2
go.mod
View file

@ -3,7 +3,7 @@ module git.happydns.org/checker-dav
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.2.0
git.happydns.org/checker-sdk-go v1.3.0
git.happydns.org/checker-tls v0.2.0
)

4
go.sum
View file

@ -1,5 +1,5 @@
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-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=

View file

@ -6,7 +6,7 @@ import (
)
// NewHTTPClient returns an http.Client with a sane default transport for
// probing DAV servers. TLS certificate validation uses Go's default rules
// probing DAV servers. TLS certificate validation uses Go's default rules;
// dedicated TLS correctness belongs in a separate checker.
func NewHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{

View file

@ -15,7 +15,7 @@ import (
// 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
// learned along the way, even if every step fails; the report leans on the
// captured evidence to tell the user which leg of the discovery broke.
func Discover(ctx context.Context, client *http.Client, kind Kind, domain, explicitURL string) DiscoveryResult {
res := DiscoveryResult{}
@ -26,7 +26,7 @@ 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
// 1. /.well-known: this is the #1 misconfig hotspot, so we always probe
// it even if SRV below might have worked, to surface the mistake.
wellKnown := "https://" + domain + kind.WellKnownPath()
res.WellKnownURL = wellKnown

View file

@ -15,11 +15,11 @@ import (
// 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
// 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
// SNI is always populated (equal to Host for CalDAV/CardDAV, since, unlike
// XMPP (RFC 6120 §13.7.2.1), there is no mandated source-domain-vs-target
// split: clients negotiate TLS for the hostname they connect to). We fill
// the field unconditionally so consumers can rely on it being set.
func DiscoverEntries(obs *Observation) []sdk.DiscoveryEntry {

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

@ -9,7 +9,7 @@ import (
// 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
// required capability, is not treated as a transport error here; the caller
// rule decides severity from the parsed values.
func ProbeOptions(ctx context.Context, client *http.Client, url string) (OptionsResult, error) {
res := OptionsResult{}

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

View file

@ -64,7 +64,7 @@ func FindScheduleURLs(ctx context.Context, client *http.Client, principalURL str
// ── 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
// principal URLs and scheduling hrefs. It is intentionally permissive, extra
// elements are ignored, which makes us tolerant of server-specific extensions.
type multistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`

View file

@ -145,7 +145,7 @@ func buildCallouts(o *Observation) []calloutData {
out = append(out, calloutData{
Severity: "crit",
Title: fmt.Sprintf("Server does not advertise %q", o.Kind.RequiredCapability()),
Body: fmt.Sprintf("The DAV: response header is %q — this endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
Body: fmt.Sprintf("The DAV: response header is %q. This endpoint is not a %s server, or a reverse proxy is stripping headers.", strings.Join(o.Options.DAVClasses, ", "), o.Kind),
})
}
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
@ -171,7 +171,7 @@ func exampleContextURL(k Kind) string {
func buildPhases(o *Observation) []phaseData {
var phases []phaseData
// Phase 1 Discovery
// Phase 1: Discovery
discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect",
@ -205,7 +205,7 @@ func buildPhases(o *Observation) []phaseData {
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery)
// Phase 2 Transport + OPTIONS
// Phase 2: Transport + OPTIONS
transport := phaseData{Title: "Transport & OPTIONS"}
transport.Items = append(transport.Items,
itemFor("HTTPS reached", boolStatus(o.Transport.Reached, "crit"), o.Transport.Error, ""),
@ -221,7 +221,7 @@ func buildPhases(o *Observation) []phaseData {
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport)
// Phase 3 Authenticated
// Phase 3: Authenticated
auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
@ -232,7 +232,7 @@ func buildPhases(o *Observation) []phaseData {
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth)
// Phase 4 Scheduling (CalDAV only)
// Phase 4: Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised {
@ -259,7 +259,7 @@ func buildTLSPhase(summaries []TLSSummary) phaseData {
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,

View file

@ -98,7 +98,7 @@ 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
// /.well-known returning 200 is legal per RFC but strongly discouraged;
// many clients won't follow it. Warn, don't crit.
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
return []sdk.CheckState{{
@ -250,7 +250,7 @@ func (r *collectionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "collections_empty",
Message: "home-set is empty the account has no calendars/addressbooks",
Message: "home-set is empty; the account has no calendars/addressbooks",
}}
}
out := make([]sdk.CheckState, 0, len(c.Items))

View file

@ -17,7 +17,7 @@ import (
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
// only read the fields we need and tolerate missing ones; the TLS checker's
// full schema is owned by that checker.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
@ -104,11 +104,11 @@ func (v *tlsProbeView) chainOK() (bool, bool) {
//
// 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
// 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
// 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.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
@ -248,7 +248,7 @@ func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate on %s has expired", addr),
Body: fmt.Sprintf("Renew it — clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)),
Body: fmt.Sprintf("Renew it. Clients will refuse to connect. Expired %d day(s) ago (valid until %s).", -days, t.Format(time.RFC3339)),
})
case days < 14:
out = append(out, tlsCallout{

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)