package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// remediation is a single actionable hint shown in the report's
// "most common issues, fix these first" banner. Bodies are rendered
// with template.HTML so each remediation can ship its own markup
// (pre-formatted code snippets, lists, links).
type remediation struct {
Title string
Body template.HTML
}
// findingRow models a single row in the full findings table.
type findingRow struct {
Code string
Severity string
Message string
Fix string
}
// subkeyRow mirrors SubkeyInfo for the template, with pre-formatted
// times and a Capabilities string.
type subkeyRow struct {
Algorithm string
Bits int
Capabilities string
Created string
Expires string
Revoked bool
}
// reportData is the template context.
type reportData struct {
Kind string
Headline string
Badge string // "ok" / "warn" / "fail" / "neutral"
QueriedOwner string
ExpectedOwner string
Resolver string
DNSSEC string // "secure" / "insecure" / "unknown"
RecordCount int
Username string
CollectedAt string
OpenPGP *openPGPView
SMIMEA *smimeaView
Remediations []remediation
Findings []findingRow
HasStates bool // true when rule states were threaded; gates the Findings section
CritCount int
WarnCount int
InfoCount int
}
type openPGPView struct {
Fingerprint string
KeyID string
Algorithm string
Bits int
UIDs []string
Created string
Expires string
Revoked bool
Encrypt bool
Subkeys []subkeyRow
RawSize int
EntityCount int
}
type smimeaView struct {
Usage string
Selector string
MatchingType string
HashOnly bool
HashHex string
Subject string
Issuer string
Serial string
NotBefore string
NotAfter string
SignatureAlgo string
KeyAlgo string
Bits int
Emails []string
DNSNames []string
EmailProtection bool
DigitalSignature bool
KeyEncipherment bool
SelfSigned bool
IsCA bool
}
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *emailKeyProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var data EmailKeyData
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
return "", fmt.Errorf("unmarshal report data: %w", err)
}
rd := buildReportData(&data, ctx.States())
var buf strings.Builder
if err := reportTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("render report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *EmailKeyData, states []sdk.CheckState) reportData {
rd := reportData{
Kind: d.Kind,
QueriedOwner: d.QueriedOwner,
ExpectedOwner: d.ExpectedOwner,
Resolver: d.Resolver,
RecordCount: d.RecordCount,
Username: d.Username,
CollectedAt: d.CollectedAt.UTC().Format(time.RFC3339),
}
switch {
case d.DNSSECSecure == nil:
rd.DNSSEC = "unknown"
case *d.DNSSECSecure:
rd.DNSSEC = "secure"
default:
rd.DNSSEC = "insecure"
}
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
}
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
}
// No rule states threaded through: data-only view.
if len(states) == 0 {
rd.Badge = "neutral"
rd.Headline = "Record details"
return rd
}
rd.HasStates = true
// Pick the states we want on screen: drop bare StatusOK, and drop
// StatusInfo with no message (non-applicable rules). Keep anything
// else.
kept := make([]sdk.CheckState, 0, len(states))
for _, s := range states {
if s.Status == sdk.StatusOK {
continue
}
if s.Status == sdk.StatusInfo && strings.TrimSpace(s.Message) == "" {
continue
}
kept = append(kept, s)
}
// Sort by severity (crit first).
sort.SliceStable(kept, func(i, j int) bool {
return statusRank(kept[i].Status) > statusRank(kept[j].Status)
})
for _, s := range kept {
rd.Findings = append(rd.Findings, findingRow{
Code: s.Code,
Severity: severityLabel(s.Status),
Message: s.Message,
Fix: stateHint(s),
})
switch s.Status {
case sdk.StatusCrit, sdk.StatusError:
rd.CritCount++
case sdk.StatusWarn:
rd.WarnCount++
case sdk.StatusInfo:
rd.InfoCount++
}
}
switch {
case rd.CritCount > 0:
rd.Badge = "fail"
rd.Headline = fmt.Sprintf("%d critical issue(s) found", rd.CritCount)
case rd.WarnCount > 0:
rd.Badge = "warn"
rd.Headline = fmt.Sprintf("%d warning(s)", rd.WarnCount)
case rd.InfoCount > 0:
rd.Badge = "neutral"
rd.Headline = "Informational findings"
default:
rd.Badge = "ok"
rd.Headline = "All checks passed"
}
rd.Remediations = buildRemediations(d, kept)
return rd
}
func stateHint(s sdk.CheckState) string {
if s.Meta == nil {
return ""
}
if v, ok := s.Meta["hint"].(string); ok {
return v
}
return ""
}
func severityLabel(st sdk.Status) string {
switch st {
case sdk.StatusCrit, sdk.StatusError:
return "crit"
case sdk.StatusWarn:
return "warn"
case sdk.StatusInfo:
return "info"
}
return "info"
}
func statusRank(st sdk.Status) int {
switch st {
case sdk.StatusCrit, sdk.StatusError:
return 3
case sdk.StatusWarn:
return 2
case sdk.StatusInfo:
return 1
}
return 0
}
func buildOpenPGPView(o *OpenPGPInfo) *openPGPView {
v := &openPGPView{
Fingerprint: formatFingerprint(o.Fingerprint),
KeyID: o.KeyID,
Algorithm: o.PrimaryAlgorithm,
Bits: o.PrimaryBits,
UIDs: append([]string(nil), o.UIDs...),
Created: fmtTime(o.CreatedAt),
Expires: fmtTime(o.ExpiresAt),
Revoked: o.Revoked,
Encrypt: o.HasEncryptionCapability,
RawSize: o.RawSize,
EntityCount: o.EntityCount,
}
if v.Expires == "" {
v.Expires = "never"
}
sort.Strings(v.UIDs)
for _, sk := range o.Subkeys {
caps := subkeyCaps(sk)
v.Subkeys = append(v.Subkeys, subkeyRow{
Algorithm: sk.Algorithm,
Bits: sk.Bits,
Capabilities: caps,
Created: fmtTime(sk.CreatedAt),
Expires: fmtTimeOrNever(sk.ExpiresAt),
Revoked: sk.Revoked,
})
}
return v
}
func buildSMIMEAView(s *SMIMEAInfo) *smimeaView {
v := &smimeaView{
Usage: smimeaUsageName(s.Usage),
Selector: smimeaSelectorName(s.Selector),
MatchingType: smimeaMatchingTypeName(s.MatchingType),
HashOnly: s.MatchingType != 0,
HashHex: s.HashHex,
}
if s.Certificate != nil {
c := s.Certificate
v.Subject = c.Subject
v.Issuer = c.Issuer
v.Serial = c.SerialHex
v.NotBefore = fmtTime(c.NotBefore)
v.NotAfter = fmtTime(c.NotAfter)
v.SignatureAlgo = c.SignatureAlgorithm
v.KeyAlgo = c.PublicKeyAlgorithm
v.Bits = c.PublicKeyBits
v.Emails = append([]string(nil), c.EmailAddresses...)
v.DNSNames = append([]string(nil), c.DNSNames...)
v.EmailProtection = c.HasEmailProtectionEKU
v.DigitalSignature = c.HasDigitalSignature
v.KeyEncipherment = c.HasKeyEncipherment
v.SelfSigned = c.IsSelfSigned
v.IsCA = c.IsCA
}
if s.PublicKey != nil && v.KeyAlgo == "" {
v.KeyAlgo = s.PublicKey.Algorithm
v.Bits = s.PublicKey.Bits
}
return v
}
// buildRemediations surfaces a focused, user-actionable card for each
// of the most common failure scenarios present in `states`. Only rules
// with a matching state produce a remediation; a clean run shows none.
func buildRemediations(d *EmailKeyData, states []sdk.CheckState) []remediation {
var out []remediation
byCode := map[string]bool{}
for _, s := range states {
byCode[s.Code] = true
}
pick := func(code, title, body string) {
if !byCode[code] {
return
}
out = append(out, remediation{Title: title, Body: template.HTML(body)})
}
pick(RuleDNSNoRecord,
"Publish the record in DNS",
fmt.Sprintf(`No %s record resolves at %s. Publish it in the zone and reload the authoritative servers.
Quick checklist:
sha256(localpart)[0:28] . %s . %s.dig +dnssec %s %s @<auth-ns>.dnssec-policy default; Knot: dnssec-signing: on; BIND/Knot-DNSSEC-policy or NSD+OpenDNSSEC, etc.).hex(sha256(localpart))[:56] (28 bytes). Email agents will never find it because they compute the hash from the recipient address.printf '%s' "local-part" | openssl dgst -sha256 | cut -c 1-56 | tr -d '\n' ; echo "._openpgpkey.domain.tld"Then republish the record at that owner name.`) pick(RulePGPPrimaryExpired, "Renew the expired OpenPGP key", `The primary key's self-signature expired, so clients will refuse to encrypt to it.
gpg --edit-key <fingerprint> gpg> expire ... set a new expiration ... gpg> save gpg --export <fingerprint> | base64Paste the resulting base64 back into the OPENPGPKEY record.`) pick(RulePGPPrimaryRevoked, "Publish a fresh, non-revoked key", `The record carries a revoked primary key; clients will stop encrypting mail to this address as soon as they process the revocation.
gpg --edit-key <fingerprint> gpg> addkey ... choose "RSA (encrypt only)" or "ECC (encrypt only)" ... gpg> saveRe-export and republish.`) pick(RulePGPWeakKeySize, "Rotate away from weak RSA keys", `RSA below 2048 bits is considered broken. Generate a modern key and republish:
gpg --full-generate-key # choose 1 (RSA+RSA) with 3072/4096 bits, # or 9 (ECC+ECC) for Curve25519.`) pick(RuleSMIMEACertExpired, "Renew the S/MIME certificate", `The certificate expired. Issue a fresh one and update the SMIMEA record:
openssl req -new -key user.key -subj "/emailAddress=user@example.org" -out user.csr ... obtain a signed cert from your S/MIME CA ... openssl x509 -in user.crt -outform DER | xxd -p -c256 > smimea.hexSplice the hex payload into the SMIMEA RDATA.`) pick(RuleSMIMEANoEmailProtect, "Add the emailProtection EKU", `Conforming S/MIME agents (RFC 8550/8551) only accept certificates whose Extended Key Usage advertises email protection (OID 1.3.6.1.5.5.7.3.4).
openssl.cnf:[usr_cert] extendedKeyUsage = emailProtection keyUsage = digitalSignature, keyEnciphermentRe-issue the certificate, then update the SMIMEA record.`) pick(RuleSMIMEAWeakSigAlgorithm, "Re-issue with a strong signature algorithm", `MD5 and SHA-1 based signatures are collision-vulnerable and will be rejected by modern mail agents.
openssl x509 -req -sha256 -in user.csr -CA ca.pem -CAkey ca.key -out user.crt`) pick(RuleSMIMEABadUsage, "Pick a valid SMIMEA usage", `SMIMEA usage must be 0 (PKIX-TA), 1 (PKIX-EE), 2 (DANE-TA) or 3 (DANE-EE). For self-hosted end-entity certificates, 3 (DANE-EE) is the right choice: it tells verifiers the record carries the exact certificate to trust and no chain validation is required.`) pick(RuleSMIMEAHashOnly, "Consider publishing the full certificate", `Matching types 1 (SHA-256) and 2 (SHA-512) only transport a digest. Consumers cannot extract the certificate from DNS and must obtain it through a side channel. Matching type 0 (Full) avoids that round trip and is the most interoperable option.`) return out } func smimeaUsageName(u uint8) string { switch u { case 0: return "0 PKIX-TA" case 1: return "1 PKIX-EE" case 2: return "2 DANE-TA" case 3: return "3 DANE-EE" } return fmt.Sprintf("%d (unknown)", u) } func smimeaSelectorName(s uint8) string { switch s { case 0: return "0 Cert" case 1: return "1 SPKI" } return fmt.Sprintf("%d (unknown)", s) } func smimeaMatchingTypeName(m uint8) string { switch m { case 0: return "0 Full" case 1: return "1 SHA-256" case 2: return "2 SHA-512" } return fmt.Sprintf("%d (unknown)", m) } func kindRRType(k string) string { if k == KindSMIMEA { return "SMIMEA" } return "OPENPGPKEY" } func kindPrefix(k string) string { if k == KindSMIMEA { return "_smimecert" } return "_openpgpkey" } func subkeyCaps(sk SubkeyInfo) string { var caps []string if sk.CanSign { caps = append(caps, "sign") } if sk.CanEncrypt { caps = append(caps, "encrypt") } if sk.CanAuth { caps = append(caps, "auth") } if len(caps) == 0 { return "-" } return strings.Join(caps, ", ") } func fmtTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339) } func fmtTimeOrNever(t time.Time) string { s := fmtTime(t) if s == "" { return "never" } return s } func formatFingerprint(fp string) string { if fp == "" { return "" } fp = strings.ToUpper(fp) var b strings.Builder for i, r := range fp { if i > 0 && i%4 == 0 { b.WriteByte(' ') } b.WriteRune(r) } return b.String() } var reportTemplate = template.Must(template.New("openpgpkey").Parse(`
{{.QueriedOwner}}
{{if and .ExpectedOwner (ne .ExpectedOwner .QueriedOwner)}} · expected {{.ExpectedOwner}}{{end}}
{{if .Resolver}} · via {{.Resolver}}{{end}}
{{if eq .DNSSEC "secure"}} · DNSSEC ✓
{{else if eq .DNSSEC "insecure"}} · DNSSEC ✗
{{else}} · DNSSEC ?{{end}}
{{if .Username}} · user {{.Username}}{{end}}
| Algorithm | Bits | Capabilities | Created | Expires | State |
|---|---|---|---|---|---|
| {{.Algorithm}} | {{if .Bits}}{{.Bits}}{{end}} | {{.Capabilities}} | {{.Created}} | {{.Expires}} | {{if .Revoked}}revoked{{else}}ok{{end}} |
{{.}} {{else}}(none){{end}}| Severity | Code | Message | Fix |
|---|---|---|---|
| {{.Severity}} | {{.Code}} |
{{.Message}} | {{.Fix}} |
No issues detected.
{{end}} {{end}}Collected at {{.CollectedAt}}