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.
459 lines
13 KiB
Go
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">✓</span>
|
|
{{else if eq .Status "warn"}}<span class="check-warn">⚠</span>
|
|
{{else if eq .Status "fail"}}<span class="check-fail">✗</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>`))
|