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(` {{.Title}} Report

{{.Title}}

{{.Verdict}} {{if .Domain}}
Domain: {{.Domain}}
{{end}}
{{if .Callouts}}
{{range .Callouts}}

{{.Title}}

{{.Body}}

{{end}}
{{end}} {{range .Phases}} {{.Title}}
{{range .Items}} {{end}}
{{if eq .Status "ok"}} {{else if eq .Status "warn"}} {{else if eq .Status "fail"}} {{else if eq .Status "unk"}}? {{else}}i{{end}} {{.Label}} {{if .Mono}}{{.Mono}}{{end}} {{if .Detail}}
{{.Detail}}
{{end}}
{{end}} `))