checker-xmpp/checker/tls_related.go

172 lines
5.1 KiB
Go

package checker
import (
"encoding/json"
"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 the happyDomain plan.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is our local, permissive view of a TLS checker's payload.
// We read only 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"`
Issues []struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
// address returns the canonical "host:port" used as our matching key against
// XMPP endpoints. Falls back to Endpoint when host/port are unset.
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 ""
}
// parseTLSRelated decodes a RelatedObservation as a TLS probe, gracefully
// 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. The consumer picks its own probe via r.Ref so one
// observation does not leak into another's report.
// 2. <probe>: a single top-level probe object, kept for back-compat.
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
}
// tlsIssuesFromRelated converts downstream TLS observations into Issue
// entries that slot into our own aggregation. When a TLS checker publishes
// its own structured issues we forward them with a code prefix so the
// origin is obvious. When it only exposes structured flags, we synthesize
// one issue per probe.
func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
var out []Issue
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if len(v.Issues) > 0 {
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
switch sev {
case SeverityCrit, SeverityWarn, SeverityInfo:
default:
continue
}
code := is.Code
if code == "" {
code = "tls.unknown"
}
out = append(out, Issue{
Code: "xmpp.tls." + code,
Severity: sev,
Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message),
Fix: is.Fix,
Endpoint: addr,
})
}
continue
}
// Flag-only payload: synthesize a single summary issue.
sev := v.worstSeverity()
if sev == "" {
continue
}
msg := "TLS issue reported on " + addr
switch {
case v.ChainValid != nil && !*v.ChainValid:
msg = "Invalid certificate chain on " + addr
case v.HostnameMatch != nil && !*v.HostnameMatch:
msg = "Certificate does not cover the domain on " + addr
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
}
out = append(out, Issue{
Code: "xmpp.tls.probe",
Severity: sev,
Message: msg,
Fix: "See the TLS checker report for details.",
Endpoint: addr,
})
}
return out
}
// worstSeverity returns "crit" > "warn" > "info" across the TLS issues.
func (v *tlsProbeView) worstSeverity() string {
worst := ""
for _, is := range v.Issues {
switch strings.ToLower(is.Severity) {
case SeverityCrit:
return SeverityCrit
case SeverityWarn:
if worst != SeverityCrit {
worst = SeverityWarn
}
case SeverityInfo:
if worst == "" {
worst = SeverityInfo
}
}
}
// Synthesize a worst severity from structured flags if no explicit
// issues list was given (defensive against minimalist TLS checkers).
if v.ChainValid != nil && !*v.ChainValid {
return SeverityCrit
}
if v.HostnameMatch != nil && !*v.HostnameMatch {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
if worst != SeverityCrit {
return SeverityWarn
}
}
return worst
}