checker-dav/internal/dav/report.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

459 lines
13 KiB
Go

package dav
import (
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// 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
if err := reportTemplate.Execute(&buf, data); err != nil {
return "", fmt.Errorf("render dav report: %w", err)
}
return buf.String(), nil
}
type reportData struct {
Title string
Domain string
Verdict string
VerdictCls string
Callouts []calloutData
Phases []phaseData
Raw string
ShowSched bool
Scheduling *SchedulingResult
TLSSummaries []TLSSummary
}
type calloutData struct {
Title string
Body string
Severity string // "warn" or "crit"
}
type phaseData struct {
Title string
Items []phaseItem
Open bool
}
type phaseItem struct {
Label string
Status string // "ok", "warn", "fail", "unk", "info"
Detail string
Mono string
}
func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData {
d := reportData{
Title: title,
Domain: o.Domain,
ShowSched: o.Kind == KindCalDAV,
Scheduling: o.Scheduling,
}
d.Callouts = buildCallouts(o)
d.Phases = buildPhases(o)
tlsSummaries, tlsCallouts := foldTLSRelated(tlsRelated)
d.TLSSummaries = tlsSummaries
for _, c := range tlsCallouts {
d.Callouts = append(d.Callouts, calloutData{
Severity: c.Severity,
Title: c.Title,
Body: c.Body,
})
}
if len(tlsSummaries) > 0 {
d.Phases = append(d.Phases, buildTLSPhase(tlsSummaries))
}
switch {
case hasSeverity(d.Phases, "fail"):
d.Verdict = "Critical issues detected"
d.VerdictCls = "fail"
case hasSeverity(d.Phases, "warn"):
d.Verdict = "Minor issues detected"
d.VerdictCls = "warn"
case hasSeverity(d.Phases, "unk") && !hasSeverity(d.Phases, "ok"):
d.Verdict = "Could not evaluate without credentials"
d.VerdictCls = "unk"
default:
d.Verdict = "All checks passed"
d.VerdictCls = "ok"
}
return d
}
func hasSeverity(phases []phaseData, sev string) bool {
for _, p := range phases {
for _, it := range p.Items {
if it.Status == sev {
return true
}
}
}
return false
}
// 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
if disc.WellKnownCode == 200 && disc.Source != "explicit" {
out = append(out, calloutData{
Severity: "warn",
Title: fmt.Sprintf("%s returned 200 instead of a redirect", disc.WellKnownURL),
Body: fmt.Sprintf("RFC 6764 expects the well-known endpoint to redirect (301/302) to your service's context URL, e.g. %s. Many clients will refuse to follow a 200 here.", exampleContextURL(o.Kind)),
})
}
if disc.ContextURL == "" {
out = append(out, calloutData{
Severity: "crit",
Title: "Service discovery failed",
Body: fmt.Sprintf("No %s or SRV record (%s._tcp.%s) was found. Publish either a redirect at the well-known URL, or an SRV record pointing at your service.", disc.WellKnownURL, o.Kind.ServiceName(true), o.Domain),
})
}
if len(disc.PlaintextSRV) > 0 && len(disc.SecureSRV) == 0 {
out = append(out, calloutData{
Severity: "warn",
Title: "Plaintext SRV record without HTTPS counterpart",
Body: fmt.Sprintf("Clients should prefer %s._tcp SRV records. Add an %s._tcp record pointing at your TLS endpoint.", o.Kind.ServiceName(false), o.Kind.ServiceName(true)),
})
}
if o.Options.StatusCode != 0 && !o.Options.HasCapability(o.Kind.RequiredCapability()) {
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),
})
}
if !o.HasCredentials && o.Discovery.ContextURL != "" && o.Options.HasCapability(o.Kind.RequiredCapability()) {
out = append(out, calloutData{
Severity: "warn",
Title: "Authenticated checks were skipped",
Body: "Provide a username and password in the checker settings to probe principals, home-sets, collection properties, and REPORT behaviour.",
})
}
return out
}
func exampleContextURL(k Kind) string {
switch k {
case KindCalDAV:
return "/dav/calendars/"
case KindCardDAV:
return "/dav/addressbooks/"
}
return "/dav/"
}
func buildPhases(o *Observation) []phaseData {
var phases []phaseData
// Phase 1: Discovery
discovery := phaseData{Title: "Discovery"}
discovery.Items = append(discovery.Items, itemFor(
"/.well-known redirect",
wellKnownStatus(o.Discovery),
o.Discovery.WellKnownError,
summariseChain(o.Discovery.WellKnownChain),
))
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (TLS)", o.Kind.ServiceName(true)),
srvStatus(o.Discovery.SecureSRV, o.Discovery.SRVError),
o.Discovery.SRVError,
summariseSRV(o.Discovery.SecureSRV),
))
if len(o.Discovery.PlaintextSRV) > 0 || o.Discovery.SRVError == "" {
discovery.Items = append(discovery.Items, itemFor(
fmt.Sprintf("SRV %s._tcp (plaintext)", o.Kind.ServiceName(false)),
plainSRVStatus(o.Discovery.PlaintextSRV),
"",
summariseSRV(o.Discovery.PlaintextSRV),
))
}
if o.Discovery.TXTPath != "" {
discovery.Items = append(discovery.Items, itemFor("TXT path hint", "ok", "", o.Discovery.TXTPath))
}
discovery.Items = append(discovery.Items, itemFor(
"Context URL",
contextStatus(o.Discovery.ContextURL),
"",
o.Discovery.ContextURL,
))
discovery.Open = hasItemSeverity(discovery.Items, "warn", "fail")
phases = append(phases, discovery)
// 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, ""),
itemFor("DAV classes", davStatus(o, o.Options), "", strings.Join(o.Options.DAVClasses, ", ")),
itemFor("Allow methods", methodsStatus(o.Options), "", strings.Join(o.Options.AllowMethods, ", ")),
)
if len(o.Options.AuthSchemes) > 0 {
transport.Items = append(transport.Items, itemFor("Auth schemes", "info", "", strings.Join(o.Options.AuthSchemes, ", ")))
}
if o.Options.Server != "" {
transport.Items = append(transport.Items, itemFor("Server header", "info", "", o.Options.Server))
}
transport.Open = hasItemSeverity(transport.Items, "warn", "fail")
phases = append(phases, transport)
// Phase 3: Authenticated
auth := phaseData{Title: "Authenticated probes"}
auth.Items = append(auth.Items,
authItemFor("Principal", o.Principal.URL, o.Principal.Skipped, o.Principal.Error),
authItemFor("Home-set", o.HomeSet.URL, o.HomeSet.Skipped, o.HomeSet.Error),
collectionsItemFor(o.Collections, o.Kind),
reportItemFor(o.Report),
)
auth.Open = hasItemSeverity(auth.Items, "warn", "fail")
phases = append(phases, auth)
// Phase 4: Scheduling (CalDAV only)
if o.Kind == KindCalDAV && o.Scheduling != nil {
sched := phaseData{Title: "Scheduling (CalDAV)"}
if !o.Scheduling.Advertised {
sched.Items = append(sched.Items, itemFor("calendar-schedule advertised", "info", "", "not advertised"))
} else {
sched.Items = append(sched.Items,
itemFor("calendar-schedule advertised", "ok", "", "advertised"),
authItemFor("schedule-inbox-URL", o.Scheduling.InboxURL, o.Principal.Skipped, o.Scheduling.Error),
authItemFor("schedule-outbox-URL", o.Scheduling.OutboxURL, o.Principal.Skipped, ""),
)
}
sched.Open = hasItemSeverity(sched.Items, "warn", "fail")
phases = append(phases, sched)
}
return phases
}
// 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)
}
p.Items = append(p.Items, phaseItem{
Label: label,
Status: s.Status,
Detail: s.Detail,
})
}
p.Open = hasItemSeverity(p.Items, "warn", "fail")
return p
}
func wellKnownStatus(d DiscoveryResult) string {
if d.Source == "explicit" {
return "info"
}
if d.WellKnownCode == 200 {
return "warn"
}
if d.WellKnownCode >= 300 && d.WellKnownCode < 400 {
return "ok"
}
return "fail"
}
func srvStatus(rec []SRVRecord, errStr string) string {
if len(rec) > 0 {
return "ok"
}
if errStr != "" {
return "fail"
}
return "warn"
}
func plainSRVStatus(rec []SRVRecord) string {
if len(rec) > 0 {
return "warn" // plaintext SRV is legacy / discouraged
}
return "ok"
}
func contextStatus(u string) string {
if u == "" {
return "fail"
}
return "ok"
}
func davStatus(o *Observation, r OptionsResult) string {
if r.HasCapability(o.Kind.RequiredCapability()) {
return "ok"
}
return "fail"
}
func methodsStatus(r OptionsResult) string {
if r.AllowsMethod("PROPFIND") && r.AllowsMethod("REPORT") {
return "ok"
}
return "warn"
}
func boolStatus(ok bool, failSev string) string {
if ok {
return "ok"
}
return failSev
}
func authItemFor(label, value string, skipped bool, errStr string) phaseItem {
switch {
case skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case errStr != "":
return phaseItem{Label: label, Status: "fail", Detail: errStr}
case value == "":
return phaseItem{Label: label, Status: "warn", Detail: "not returned"}
default:
return phaseItem{Label: label, Status: "ok", Mono: value}
}
}
func collectionsItemFor(c CollectionsResult, k Kind) phaseItem {
label := "Calendars"
if k == KindCardDAV {
label = "Address books"
}
switch {
case c.Skipped:
return phaseItem{Label: label, Status: "unk", Detail: "no credentials supplied"}
case c.Error != "":
return phaseItem{Label: label, Status: "fail", Detail: c.Error}
case len(c.Items) == 0:
return phaseItem{Label: label, Status: "warn", Detail: "home-set is empty"}
default:
names := make([]string, 0, len(c.Items))
for _, it := range c.Items {
n := it.Name
if n == "" {
n = it.Path
}
names = append(names, n)
}
return phaseItem{Label: label, Status: "ok", Detail: fmt.Sprintf("%d found", len(c.Items)), Mono: strings.Join(names, ", ")}
}
}
func reportItemFor(r ReportResult) phaseItem {
switch {
case r.Skipped:
return phaseItem{Label: "REPORT query", Status: "unk", Detail: "skipped"}
case r.Error != "":
return phaseItem{Label: "REPORT query", Status: "fail", Detail: r.Error}
case !r.QueryOK:
return phaseItem{Label: "REPORT query", Status: "warn", Detail: "unexpected response"}
default:
return phaseItem{Label: "REPORT query", Status: "ok", Mono: r.ProbePath}
}
}
func itemFor(label, status, errStr, mono string) phaseItem {
it := phaseItem{Label: label, Status: status, Mono: mono}
if errStr != "" {
it.Detail = errStr
}
return it
}
func hasItemSeverity(items []phaseItem, sevs ...string) bool {
for _, it := range items {
for _, s := range sevs {
if it.Status == s {
return true
}
}
}
return false
}
func summariseChain(chain []string) string {
return strings.Join(chain, " → ")
}
func summariseSRV(rec []SRVRecord) string {
if len(rec) == 0 {
return ""
}
parts := make([]string, 0, len(rec))
for _, r := range rec {
parts = append(parts, fmt.Sprintf("%s:%d (prio %d, weight %d)", r.Target, r.Port, r.Priority, r.Weight))
}
return strings.Join(parts, "; ")
}
var reportTemplate = template.Must(template.New("dav").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} Report</title>
<style>` + ReportCSS + `</style>
</head>
<body>
<div class="hd">
<h1>{{.Title}}</h1>
<span class="badge {{.VerdictCls}}">{{.Verdict}}</span>
{{if .Domain}}<div class="verdict">Domain: <code>{{.Domain}}</code></div>{{end}}
</div>
{{if .Callouts}}
<div class="callouts">
{{range .Callouts}}
<div class="callout {{if eq .Severity "crit"}}crit{{end}}">
<h3>{{.Title}}</h3>
<p>{{.Body}}</p>
</div>
{{end}}
</div>
{{end}}
{{range .Phases}}
<details{{if .Open}} open{{end}}>
<summary><span class="phase-title">{{.Title}}</span></summary>
<div class="details-body">
<table>
{{range .Items}}
<tr>
<td style="width:1.5rem">
{{if eq .Status "ok"}}<span class="check-ok">&#10003;</span>
{{else if eq .Status "warn"}}<span class="check-warn">&#9888;</span>
{{else if eq .Status "fail"}}<span class="check-fail">&#10007;</span>
{{else if eq .Status "unk"}}<span class="check-unk">?</span>
{{else}}<span class="check-unk">i</span>{{end}}
</td>
<td style="width:45%">{{.Label}}</td>
<td>
{{if .Mono}}<code>{{.Mono}}</code>{{end}}
{{if .Detail}}<div class="note">{{.Detail}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
</div>
</details>
{{end}}
</body>
</html>`))