checker-dav/internal/dav/tls_related.go
Pierre-Olivier Mercier 4366ca9058 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-24 14:21:11 +07:00

276 lines
8.2 KiB
Go

package dav
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key we expect a TLS checker to publish for
// the endpoints we discover. Matches the cross-checker convention documented
// in happydomain3/docs/checker-discovery-endpoint.md.
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
// full schema is owned by that checker.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
// Alternative shape used by the reference checker-tls payload sketched
// in the docs: cert.{notAfter, sanMatch, chainValid, daysRemaining}.
Cert *struct {
NotAfter time.Time `json:"notAfter,omitempty"`
SANMatch *bool `json:"sanMatch,omitempty"`
ChainValid *bool `json:"chainValid,omitempty"`
DaysRemaining *int `json:"daysRemaining,omitempty"`
SubjectCN string `json:"subjectCN,omitempty"`
IssuerCN string `json:"issuerCN,omitempty"`
} `json:"cert,omitempty"`
Rules []struct {
Code string `json:"code,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
} `json:"rules,omitempty"`
Issues []struct {
Code string `json:"code,omitempty"`
Severity string `json:"severity,omitempty"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
// address returns the canonical "host:port" string used to match a probe
// against one of our discovered endpoints.
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
// certExpiry normalises the two schema shapes into a single (t, ok) pair so
// callers don't have to know which one the TLS checker emits.
func (v *tlsProbeView) certExpiry() (time.Time, bool) {
if !v.NotAfter.IsZero() {
return v.NotAfter, true
}
if v.Cert != nil && !v.Cert.NotAfter.IsZero() {
return v.Cert.NotAfter, true
}
return time.Time{}, false
}
func (v *tlsProbeView) hostnameOK() (bool, bool) {
if v.HostnameMatch != nil {
return *v.HostnameMatch, true
}
if v.Cert != nil && v.Cert.SANMatch != nil {
return *v.Cert.SANMatch, true
}
return false, false
}
func (v *tlsProbeView) chainOK() (bool, bool) {
if v.ChainValid != nil {
return *v.ChainValid, true
}
if v.Cert != nil && v.Cert.ChainValid != nil {
return *v.Cert.ChainValid, true
}
return false, false
}
// parseTLSRelated decodes a RelatedObservation as a TLS probe, returning nil
// when the payload doesn't look like one.
//
// 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
// 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
// callers that pre-date the keyed map and with unit-test fixtures.
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var keyed struct {
Probes map[string]tlsProbeView `json:"probes"`
}
if err := json.Unmarshal(r.Data, &keyed); err == nil && keyed.Probes != nil {
if p, ok := keyed.Probes[r.Ref]; ok {
return &p
}
return nil
}
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
// TLSSummary is what the HTML report renders for each probed endpoint.
type TLSSummary struct {
Address string
TLSVersion string
Status string // "ok", "warn", "fail", "info"
Detail string
NotAfter time.Time
DaysRemaining int
}
// tlsCallout captures a cross-checker issue we want to foreground in the
// "Action items" section of the HTML report.
type tlsCallout struct {
Severity string // "warn" or "crit"
Title string
Body string
}
// foldTLSRelated walks the TLS probes and returns (1) a per-endpoint summary
// for rendering, (2) callouts for the top of the report when there's anything
// actionable. Callers pass both through the reportData pipeline.
func foldTLSRelated(related []sdk.RelatedObservation) (summaries []TLSSummary, callouts []tlsCallout) {
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
sum := buildTLSSummary(v)
summaries = append(summaries, sum)
callouts = append(callouts, buildTLSCallouts(v, sum.Address)...)
}
return summaries, callouts
}
func buildTLSSummary(v *tlsProbeView) TLSSummary {
s := TLSSummary{Address: v.address(), TLSVersion: v.TLSVersion, Status: "ok"}
if t, ok := v.certExpiry(); ok {
s.NotAfter = t
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
s.DaysRemaining = days
switch {
case days < 0:
s.Status = "fail"
s.Detail = fmt.Sprintf("certificate expired %d day(s) ago", -days)
case days < 14:
s.Status = "warn"
s.Detail = fmt.Sprintf("certificate expires in %d day(s)", days)
default:
s.Detail = fmt.Sprintf("certificate valid for %d day(s)", days)
}
}
if ok, has := v.hostnameOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate does not cover the endpoint hostname"
}
if ok, has := v.chainOK(); has && !ok {
s.Status = "fail"
s.Detail = "certificate chain validation failed"
}
// Explicit issues from the TLS checker outrank our inferred status.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
switch sev {
case "crit":
s.Status = "fail"
case "warn":
if s.Status != "fail" {
s.Status = "warn"
}
}
if iss.Message != "" {
s.Detail = iss.Message
}
}
return s
}
func buildTLSCallouts(v *tlsProbeView, addr string) []tlsCallout {
var out []tlsCallout
// Structured issues from the TLS checker are the preferred source.
for _, iss := range v.Issues {
sev := strings.ToLower(iss.Severity)
if sev != "crit" && sev != "warn" {
continue
}
callout := tlsCallout{
Severity: sev,
Title: fmt.Sprintf("TLS on %s: %s", addr, strings.TrimSpace(iss.Message)),
}
if callout.Title == "TLS on "+addr+": " {
callout.Title = "TLS issue on " + addr
}
if iss.Fix != "" {
callout.Body = iss.Fix
} else {
callout.Body = "See the TLS checker report for details."
}
out = append(out, callout)
}
if len(out) > 0 {
return out
}
// Fallback: synthesize callouts from structured flags.
if t, ok := v.certExpiry(); ok {
days := int(time.Until(t) / (24 * time.Hour))
if v.Cert != nil && v.Cert.DaysRemaining != nil {
days = *v.Cert.DaysRemaining
}
switch {
case days < 0:
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)),
})
case days < 14:
out = append(out, tlsCallout{
Severity: "warn",
Title: fmt.Sprintf("Certificate on %s expires in %d day(s)", addr, days),
Body: fmt.Sprintf("Schedule a renewal. Currently valid until %s.", t.Format(time.RFC3339)),
})
}
}
if ok, has := v.chainOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Broken certificate chain on %s", addr),
Body: "The TLS checker could not validate the chain. Ensure the server sends the full intermediate chain.",
})
}
if ok, has := v.hostnameOK(); has && !ok {
out = append(out, tlsCallout{
Severity: "crit",
Title: fmt.Sprintf("Certificate does not cover %s", addr),
Body: "Add the hostname to the certificate's SANs or point the service at a cert that covers it.",
})
}
return out
}