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
7ec1dfd735
39 changed files with 3179 additions and 0 deletions
257
internal/dav/tls_related.go
Normal file
257
internal/dav/tls_related.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
package dav
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// TLSRelatedKey matches the cross-checker convention in
|
||||
// happydomain3/docs/checker-discovery-endpoint.md.
|
||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||
|
||||
// tlsProbeView decodes only the fields we actually use; the full TLS schema
|
||||
// belongs to checker-tls and we don't want to track its evolution here.
|
||||
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"`
|
||||
}
|
||||
|
||||
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 hides the two payload shapes from callers.
|
||||
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 accepts both the keyed {"probes": {"<ref>": …}} shape
|
||||
// (current checker-tls output, picked by r.Ref) and a bare top-level probe
|
||||
// (legacy/test fixtures). Returns nil for anything else.
|
||||
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
|
||||
}
|
||||
|
||||
type TLSSummary struct {
|
||||
Address string
|
||||
TLSVersion string
|
||||
Status string // "ok", "warn", "fail", "info"
|
||||
Detail string
|
||||
NotAfter time.Time
|
||||
DaysRemaining int
|
||||
}
|
||||
|
||||
type tlsCallout struct {
|
||||
Severity string // "warn" or "crit"
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue