checker: adopt unified ReportContext reporter signature
Follow the checker-sdk-go interface consolidation: reporter methods now take sdk.ReportContext and read the payload via ctx.Data() instead of the raw json.RawMessage parameter. Backed by the same underlying logic — this is a signature migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
aae1452e12
commit
df4a7a89d1
9 changed files with 480 additions and 32 deletions
|
|
@ -18,7 +18,19 @@ func Definition() *sdk.CheckerDefinition {
|
||||||
Name: "CalDAV server",
|
Name: "CalDAV server",
|
||||||
Version: Version,
|
Version: Version,
|
||||||
Availability: sdk.CheckerAvailability{
|
Availability: sdk.CheckerAvailability{
|
||||||
|
// The probe itself only needs a domain name (discovery runs on
|
||||||
|
// the whole domain via /.well-known + SRV), so the checker is
|
||||||
|
// always offered at domain scope.
|
||||||
ApplyToDomain: true,
|
ApplyToDomain: true,
|
||||||
|
|
||||||
|
// Also offered at service scope so alerts — including the TLS
|
||||||
|
// alerts derived from the endpoints we publish — surface on a
|
||||||
|
// dedicated "CalDAV" service page rather than on the domain
|
||||||
|
// page. The abstract.CalDAV service type does not exist in the
|
||||||
|
// happyDomain service catalog yet; until it does, this has no
|
||||||
|
// visible effect, but makes the intent explicit.
|
||||||
|
ApplyToService: true,
|
||||||
|
LimitToServices: []string{"abstract.CalDAV"},
|
||||||
},
|
},
|
||||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||||
Options: sdk.CheckerOptionsDocumentation{
|
Options: sdk.CheckerOptionsDocumentation{
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,26 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
|
||||||
"git.happydns.org/checker-dav/internal/dav"
|
"git.happydns.org/checker-dav/internal/dav"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
|
// GetHTMLReport implements sdk.CheckerHTMLReporter on *caldavProvider.
|
||||||
//
|
//
|
||||||
// The actual rendering is delegated to the shared renderer in internal/dav so
|
// Delegated to the shared renderer in internal/dav so CalDAV and CardDAV
|
||||||
// CalDAV and CardDAV produce visually identical reports; the only difference
|
// produce visually identical reports; the only differences are the title
|
||||||
// is the title and the set of phases rendered (CalDAV includes Scheduling).
|
// and the set of phases (CalDAV includes Scheduling).
|
||||||
func (p *caldavProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
//
|
||||||
|
// Downstream TLS probes published for the endpoints we discovered are read
|
||||||
|
// via ctx.Related(dav.TLSRelatedKey) and folded into the report (callouts +
|
||||||
|
// dedicated TLS phase) — per
|
||||||
|
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||||
|
func (p *caldavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||||
var d dav.Observation
|
var d dav.Observation
|
||||||
if err := json.Unmarshal(raw, &d); err != nil {
|
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||||
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
|
return "", fmt.Errorf("failed to unmarshal caldav report: %w", err)
|
||||||
}
|
}
|
||||||
d.Kind = dav.KindCalDAV
|
d.Kind = dav.KindCalDAV
|
||||||
return dav.RenderReport(&d, "CalDAV Server")
|
return dav.RenderReport(&d, "CalDAV Server", ctx.Related(dav.TLSRelatedKey))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,17 @@ func Definition() *sdk.CheckerDefinition {
|
||||||
Name: "CardDAV server",
|
Name: "CardDAV server",
|
||||||
Version: Version,
|
Version: Version,
|
||||||
Availability: sdk.CheckerAvailability{
|
Availability: sdk.CheckerAvailability{
|
||||||
|
// Domain scope for the probe itself (discovery runs across the
|
||||||
|
// whole domain via /.well-known + SRV).
|
||||||
ApplyToDomain: true,
|
ApplyToDomain: true,
|
||||||
|
|
||||||
|
// Service scope so downstream TLS alerts attach to a dedicated
|
||||||
|
// "CardDAV" service page instead of the domain page. See the
|
||||||
|
// CalDAV sibling for the rationale; abstract.CardDAV is not in
|
||||||
|
// the happyDomain service catalog yet but the intent is encoded
|
||||||
|
// here ahead of time.
|
||||||
|
ApplyToService: true,
|
||||||
|
LimitToServices: []string{"abstract.CardDAV"},
|
||||||
},
|
},
|
||||||
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
ObservationKeys: []sdk.ObservationKey{ObservationKey},
|
||||||
Options: sdk.CheckerOptionsDocumentation{
|
Options: sdk.CheckerOptionsDocumentation{
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,19 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
|
||||||
"git.happydns.org/checker-dav/internal/dav"
|
"git.happydns.org/checker-dav/internal/dav"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p *carddavProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
|
// GetHTMLReport folds downstream TLS probes (published on our discovered
|
||||||
|
// endpoints) into the CardDAV report via ctx.Related — see the CalDAV
|
||||||
|
// sibling for the rationale.
|
||||||
|
func (p *carddavProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||||
var d dav.Observation
|
var d dav.Observation
|
||||||
if err := json.Unmarshal(raw, &d); err != nil {
|
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||||
return "", fmt.Errorf("failed to unmarshal carddav report: %w", err)
|
return "", fmt.Errorf("failed to unmarshal carddav report: %w", err)
|
||||||
}
|
}
|
||||||
d.Kind = dav.KindCardDAV
|
d.Kind = dav.KindCardDAV
|
||||||
return dav.RenderReport(&d, "CardDAV Server")
|
return dav.RenderReport(&d, "CardDAV Server", ctx.Related(dav.TLSRelatedKey))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ import (
|
||||||
// the endpoint was reached via SRV, we also surface each SRV target as its
|
// the endpoint was reached via SRV, we also surface each SRV target as its
|
||||||
// own endpoint — those are the names operators actually need certificates on,
|
// own endpoint — those are the names operators actually need certificates on,
|
||||||
// and they may differ from the queried domain.
|
// and they may differ from the queried domain.
|
||||||
|
//
|
||||||
|
// SNI is always populated (equal to Host for CalDAV/CardDAV, since — unlike
|
||||||
|
// XMPP (RFC 6120 §13.7.2.1) — there is no mandated source-domain-vs-target
|
||||||
|
// split: clients negotiate TLS for the hostname they connect to). We fill
|
||||||
|
// the field unconditionally so consumers can rely on it being set, matching
|
||||||
|
// the convention already used by the XMPP checker.
|
||||||
func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
|
func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
|
||||||
if obs == nil || obs.Discovery.ContextURL == "" {
|
if obs == nil || obs.Discovery.ContextURL == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -22,7 +28,7 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
|
||||||
var out []sdk.DiscoveredEndpoint
|
var out []sdk.DiscoveredEndpoint
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
|
|
||||||
add := func(host string, port uint16, sni string) {
|
add := func(host string, port uint16) {
|
||||||
if host == "" || port == 0 {
|
if host == "" || port == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -31,20 +37,17 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
ep := sdk.DiscoveredEndpoint{
|
out = append(out, sdk.DiscoveredEndpoint{
|
||||||
Type: "tls",
|
Type: "tls",
|
||||||
Host: host,
|
Host: host,
|
||||||
Port: port,
|
Port: port,
|
||||||
}
|
SNI: host,
|
||||||
if sni != "" && sni != host {
|
})
|
||||||
ep.SNI = sni
|
|
||||||
}
|
|
||||||
out = append(out, ep)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary endpoint: the resolved context URL.
|
// Primary endpoint: the resolved context URL.
|
||||||
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
|
if host, port, ok := hostPortFromURL(obs.Discovery.ContextURL); ok {
|
||||||
add(host, port, "")
|
add(host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary endpoints: every TLS SRV target. Clients may connect to any
|
// Secondary endpoints: every TLS SRV target. Clients may connect to any
|
||||||
|
|
@ -54,7 +57,7 @@ func DiscoverEndpoints(obs *Observation) []sdk.DiscoveredEndpoint {
|
||||||
if port == 0 {
|
if port == 0 {
|
||||||
port = 443
|
port = 443
|
||||||
}
|
}
|
||||||
add(r.Target, port, "")
|
add(r.Target, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ func TestDiscoverEndpoints_contextURLOnly(t *testing.T) {
|
||||||
if got[0].Host != "dav.example.com" || got[0].Port != 443 || got[0].Type != "tls" {
|
if got[0].Host != "dav.example.com" || got[0].Port != 443 || got[0].Type != "tls" {
|
||||||
t.Errorf("unexpected endpoint: %+v", got[0])
|
t.Errorf("unexpected endpoint: %+v", got[0])
|
||||||
}
|
}
|
||||||
|
// SNI must be set unconditionally (uniform with the XMPP checker),
|
||||||
|
// even when it is equal to Host.
|
||||||
|
if got[0].SNI != "dav.example.com" {
|
||||||
|
t.Errorf("SNI = %q, want dav.example.com", got[0].SNI)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) {
|
func TestDiscoverEndpoints_nonDefaultPort(t *testing.T) {
|
||||||
|
|
@ -46,6 +51,9 @@ func TestDiscoverEndpoints_srvTargets(t *testing.T) {
|
||||||
hosts := map[string]bool{}
|
hosts := map[string]bool{}
|
||||||
for _, e := range got {
|
for _, e := range got {
|
||||||
hosts[e.Host] = true
|
hosts[e.Host] = true
|
||||||
|
if e.SNI != e.Host {
|
||||||
|
t.Errorf("endpoint %+v: SNI=%q, want %q (equal to Host)", e, e.SNI, e.Host)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} {
|
for _, want := range []string{"dav.example.com", "dav-backend-1.example.net", "dav-backend-2.example.net"} {
|
||||||
if !hosts[want] {
|
if !hosts[want] {
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,25 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderReport turns an Observation into a self-contained HTML document.
|
// RenderReport turns an Observation into a self-contained HTML document.
|
||||||
//
|
//
|
||||||
// The report foregrounds action items for the failure modes we see most often
|
// The report foregrounds action items for the failure modes we see most often
|
||||||
// (well-known misconfig, missing DAV capability, missing credentials) before
|
// (well-known misconfig, missing DAV capability, missing credentials,
|
||||||
// showing the full per-phase evidence.
|
// downstream TLS issues on the endpoints we published) before showing the
|
||||||
func RenderReport(obs *Observation, title string) (string, error) {
|
// full per-phase evidence.
|
||||||
data := buildReportData(obs, title)
|
//
|
||||||
|
// tlsRelated is the output of ctx.Related(TLSRelatedKey) at report time. Nil
|
||||||
|
// is fine: the TLS section is simply omitted. This is how the happyDomain
|
||||||
|
// cross-checker composition story (see
|
||||||
|
// happydomain3/docs/checker-discovery-endpoint.md) surfaces certificate
|
||||||
|
// alerts on the CalDAV/CardDAV service page rather than in a parallel TLS
|
||||||
|
// dashboard.
|
||||||
|
func RenderReport(obs *Observation, title string, tlsRelated []sdk.RelatedObservation) (string, error) {
|
||||||
|
data := buildReportData(obs, title, tlsRelated)
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
if err := reportTemplate.Execute(&buf, data); err != nil {
|
if err := reportTemplate.Execute(&buf, data); err != nil {
|
||||||
return "", fmt.Errorf("render dav report: %w", err)
|
return "", fmt.Errorf("render dav report: %w", err)
|
||||||
|
|
@ -21,15 +31,16 @@ func RenderReport(obs *Observation, title string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type reportData struct {
|
type reportData struct {
|
||||||
Title string
|
Title string
|
||||||
Domain string
|
Domain string
|
||||||
Verdict string
|
Verdict string
|
||||||
VerdictCls string
|
VerdictCls string
|
||||||
Callouts []calloutData
|
Callouts []calloutData
|
||||||
Phases []phaseData
|
Phases []phaseData
|
||||||
Raw string
|
Raw string
|
||||||
ShowSched bool
|
ShowSched bool
|
||||||
Scheduling *SchedulingResult
|
Scheduling *SchedulingResult
|
||||||
|
TLSSummaries []TLSSummary
|
||||||
}
|
}
|
||||||
|
|
||||||
type calloutData struct {
|
type calloutData struct {
|
||||||
|
|
@ -51,7 +62,7 @@ type phaseItem struct {
|
||||||
Mono string
|
Mono string
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReportData(o *Observation, title string) reportData {
|
func buildReportData(o *Observation, title string, tlsRelated []sdk.RelatedObservation) reportData {
|
||||||
d := reportData{
|
d := reportData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Domain: o.Domain,
|
Domain: o.Domain,
|
||||||
|
|
@ -61,6 +72,21 @@ func buildReportData(o *Observation, title string) reportData {
|
||||||
d.Callouts = buildCallouts(o)
|
d.Callouts = buildCallouts(o)
|
||||||
d.Phases = buildPhases(o)
|
d.Phases = buildPhases(o)
|
||||||
|
|
||||||
|
// Fold downstream TLS probes (published by checker-tls against the
|
||||||
|
// endpoints we discovered) into the report.
|
||||||
|
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 {
|
switch {
|
||||||
case hasSeverity(d.Phases, "fail"):
|
case hasSeverity(d.Phases, "fail"):
|
||||||
d.Verdict = "Critical issues detected"
|
d.Verdict = "Critical issues detected"
|
||||||
|
|
@ -225,6 +251,26 @@ func buildPhases(o *Observation) []phaseData {
|
||||||
return phases
|
return phases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildTLSPhase turns per-endpoint TLS summaries into a collapsible phase
|
||||||
|
// rendered at the bottom of the report. Open when anything is non-OK so
|
||||||
|
// operators don't need to expand it to see the problem.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// ── small helpers used by buildPhases ────────────────────────────────────────
|
// ── small helpers used by buildPhases ────────────────────────────────────────
|
||||||
|
|
||||||
func wellKnownStatus(d DiscoveryResult) string {
|
func wellKnownStatus(d DiscoveryResult) string {
|
||||||
|
|
|
||||||
275
internal/dav/tls_related.go
Normal file
275
internal/dav/tls_related.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
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": {"<endpointId>": <probe>, …}} — the current convention
|
||||||
|
// used by checker-tls. Each consumer picks its own probe via
|
||||||
|
// r.EndpointID so one observation does not leak into another's report.
|
||||||
|
// 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.EndpointID]; 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
|
||||||
|
}
|
||||||
82
internal/dav/tls_related_test.go
Normal file
82
internal/dav/tls_related_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package dav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func relatedFrom(t *testing.T, payload any) sdk.RelatedObservation {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
return sdk.RelatedObservation{Key: TLSRelatedKey, Data: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldTLSRelated_expiringCertProducesCallout(t *testing.T) {
|
||||||
|
exp := time.Now().Add(5 * 24 * time.Hour)
|
||||||
|
related := []sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||||
|
"host": "dav.example.com",
|
||||||
|
"port": 443,
|
||||||
|
"not_after": exp,
|
||||||
|
})}
|
||||||
|
sums, callouts := foldTLSRelated(related)
|
||||||
|
|
||||||
|
if len(sums) != 1 || sums[0].Address != "dav.example.com:443" || sums[0].Status != "warn" {
|
||||||
|
t.Fatalf("summary: %+v", sums)
|
||||||
|
}
|
||||||
|
if len(callouts) != 1 || callouts[0].Severity != "warn" {
|
||||||
|
t.Fatalf("expected a warn callout, got %+v", callouts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(callouts[0].Title, "expires in") {
|
||||||
|
t.Errorf("callout title: %q", callouts[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldTLSRelated_expiredCertCrit(t *testing.T) {
|
||||||
|
exp := time.Now().Add(-2 * 24 * time.Hour)
|
||||||
|
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||||
|
"host": "dav.example.com", "port": 443, "not_after": exp,
|
||||||
|
})})
|
||||||
|
if len(callouts) != 1 || callouts[0].Severity != "crit" {
|
||||||
|
t.Fatalf("expected crit for expired cert, got %+v", callouts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldTLSRelated_chainInvalid(t *testing.T) {
|
||||||
|
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||||
|
"host": "dav.example.com", "port": 443, "chain_valid": false,
|
||||||
|
})})
|
||||||
|
if len(callouts) != 1 || callouts[0].Severity != "crit" {
|
||||||
|
t.Fatalf("expected crit for broken chain, got %+v", callouts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldTLSRelated_explicitIssueWinsOverFlags(t *testing.T) {
|
||||||
|
_, callouts := foldTLSRelated([]sdk.RelatedObservation{relatedFrom(t, map[string]any{
|
||||||
|
"host": "dav.example.com", "port": 443,
|
||||||
|
"chain_valid": false, // would normally synthesize a callout
|
||||||
|
"issues": []map[string]any{
|
||||||
|
{"code": "weak_cipher", "severity": "warn", "message": "TLS 1.0 offered", "fix": "disable TLS <1.2"},
|
||||||
|
},
|
||||||
|
})})
|
||||||
|
// When explicit issues exist, we do not also emit synthesized callouts —
|
||||||
|
// the TLS checker is the source of truth for severity and wording.
|
||||||
|
if len(callouts) != 1 || callouts[0].Severity != "warn" {
|
||||||
|
t.Fatalf("want single warn callout, got %+v", callouts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(callouts[0].Body, "disable TLS") {
|
||||||
|
t.Errorf("fix text lost: %q", callouts[0].Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldTLSRelated_empty(t *testing.T) {
|
||||||
|
if sums, callouts := foldTLSRelated(nil); sums != nil || callouts != nil {
|
||||||
|
t.Errorf("expected nil,nil on nil input, got %+v %+v", sums, callouts)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue