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
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)
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) 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"
}
// Sort findings by severity (crit first) for stable display.
findings := make([]Finding, len(d.Findings))
copy(findings, d.Findings)
sort.SliceStable(findings, func(i, j int) bool {
return severityRank(findings[i].Severity) > severityRank(findings[j].Severity)
})
for _, f := range findings {
rd.Findings = append(rd.Findings, findingRow{
Code: f.Code, Severity: string(f.Severity), Message: f.Message, Fix: f.Fix,
})
switch f.Severity {
case SeverityCrit:
rd.CritCount++
case SeverityWarn:
rd.WarnCount++
case SeverityInfo:
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"
}
if d.Kind == KindOpenPGPKey && d.OpenPGP != nil {
rd.OpenPGP = buildOpenPGPView(d.OpenPGP)
}
if d.Kind == KindSMIMEA && d.SMIMEA != nil {
rd.SMIMEA = buildSMIMEAView(d.SMIMEA)
}
rd.Remediations = buildRemediations(d)
return rd
}
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 detects the most common failure scenarios and
// surfaces a focused, user-actionable card for each. Only matching
// issues produce a remediation; a clean run shows none.
func buildRemediations(d *EmailKeyData) []remediation {
var out []remediation
byCode := map[string]Finding{}
for _, f := range d.Findings {
// Keep the first (most severe after sort) finding per code.
if _, ok := byCode[f.Code]; !ok {
byCode[f.Code] = f
}
}
pick := func(code, title, body string) {
if _, ok := byCode[code]; !ok {
return
}
out = append(out, remediation{Title: title, Body: template.HTML(body)})
}
pick(CodeDNSNoRecord,
"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(CodePGPExpired, "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(CodePGPRevoked, "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(CodePGPWeakKeySize, "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(CodeSMIMEACertExpired, "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(CodeSMIMEANoEmailProtection, "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(CodeSMIMEAWeakSignatureAlg, "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(CodeSMIMEABadUsage, "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(CodeSMIMEAHashOnly, "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 severityRank(s Severity) int { switch s { case SeverityCrit: return 3 case SeverityWarn: return 2 case SeverityInfo: return 1 } return 0 } 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}}Collected at {{.CollectedAt}}