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
da102b5d41
39 changed files with 3179 additions and 0 deletions
459
internal/dav/report.go
Normal file
459
internal/dav/report.go
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
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>`))
|
||||
Loading…
Add table
Add a link
Reference in a new issue