package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ── HTML report ───────────────────────────────────────────────────────────────
type remediation struct {
Title string
Body template.HTML
}
type probeRow struct {
Target string
Port uint16
Proto string
Role string
OK bool
RTT string
Error string
KrbSeen bool
}
type resolvedHost struct {
Target string
IPv4 []string
IPv6 []string
Error string
}
type enctypeChip struct {
Name string
Weak bool
}
type srvView struct {
Prefix string
Lookup string
Records []SRVRecord
NXDomain bool
Error string
}
type reportData struct {
Realm string
HasStates bool
OverallOK bool
CollectedAt string
ServerTime string
ClockSkew string
ClockSkewBad bool
SRVBuckets []srvView
Resolution []resolvedHost
Probes []probeRow
ASProbe ASProbeResult
ASErrorName string
PreauthReq bool
PKINITOffered bool
PrincipalFound bool
Enctypes []enctypeChip
HasWeakOnly bool
HasMixedCrypto bool
HasEnctypes bool
AuthProbe *AuthProbeResult
Remediations []remediation
}
func fmtDur(d time.Duration) string {
if d == 0 {
return "-"
}
return d.Round(time.Millisecond).String()
}
func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var r KerberosData
if err := json.Unmarshal(rctx.Data(), &r); err != nil {
return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err)
}
// Derive overall OK exclusively from the states the host produced for
// this run. When no states are supplied, render a data-only view with
// no status banner and no remediation hints.
states := rctx.States()
hasStates := len(states) > 0
overallOK := hasStates
for _, s := range states {
if s.Status == sdk.StatusCrit || s.Status == sdk.StatusError || s.Status == sdk.StatusWarn {
overallOK = false
break
}
}
rd := reportData{
Realm: r.Realm,
HasStates: hasStates,
OverallOK: overallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339),
ASProbe: r.AS,
ASErrorName: r.AS.ErrorName,
PreauthReq: r.AS.PreauthReq,
AuthProbe: r.Auth,
}
if !r.AS.ServerTime.IsZero() {
rd.ServerTime = r.AS.ServerTime.Format(time.RFC3339)
}
if r.AS.ClockSkew != 0 {
rd.ClockSkew = fmtDur(r.AS.ClockSkew)
}
// Trust the clock-skew rule's verdict (which honours maxClockSkew)
// rather than re-applying a hardcoded threshold here.
for _, s := range states {
if s.Code == CodeClockSkewBad &&
(s.Status == sdk.StatusCrit || s.Status == sdk.StatusWarn || s.Status == sdk.StatusError) {
rd.ClockSkewBad = true
break
}
}
rd.PKINITOffered = r.AS.PKINITOffered
rd.PrincipalFound = r.AS.PrincipalFound
for _, b := range r.SRV {
rd.SRVBuckets = append(rd.SRVBuckets, srvView{
Prefix: b.Prefix,
Lookup: b.LookupName,
Records: b.Records,
NXDomain: b.NXDomain,
Error: b.Error,
})
}
hosts := make([]string, 0, len(r.Resolution))
for h := range r.Resolution {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
v := r.Resolution[h]
rd.Resolution = append(rd.Resolution, resolvedHost{
Target: v.Target,
IPv4: v.IPv4,
IPv6: v.IPv6,
Error: v.Error,
})
}
for _, p := range r.Probes {
rd.Probes = append(rd.Probes, probeRow{
Target: p.Target, Port: p.Port, Proto: p.Proto, Role: p.Role,
OK: p.OK, RTT: fmtDur(p.RTT), Error: p.Error, KrbSeen: p.KrbSeen,
})
}
// Enctype chips + classification flags.
hasStrong := false
for _, e := range r.Enctypes {
rd.Enctypes = append(rd.Enctypes, enctypeChip{Name: e.Name, Weak: e.Weak})
if !e.Weak {
hasStrong = true
}
}
rd.HasEnctypes = len(r.Enctypes) > 0
if rd.HasEnctypes && !hasStrong {
rd.HasWeakOnly = true
}
if rd.HasEnctypes && hasStrong && len(r.WeakEnctypes) > 0 {
rd.HasMixedCrypto = true
}
// Detect common failures and build the remediation banner. Hints are
// only surfaced when the host supplied rule states for this run.
if hasStates {
rd.Remediations = buildRemediations(&r, rd)
}
var buf strings.Builder
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("failed to render kerberos HTML report: %w", err)
}
return buf.String(), nil
}
// buildRemediations inspects the observation and returns actionable hints
// for the user-visible failures. Only matching hints are appended, so a
// healthy realm shows an empty list (rendered as nothing).
func buildRemediations(r *KerberosData, rd reportData) []remediation {
var out []remediation
hasKDCSRV := false
for _, b := range r.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
hasKDCSRV = true
break
}
}
if !hasKDCSRV {
out = append(out, remediation{
Title: "Publish Kerberos SRV records",
Body: template.HTML(fmt.Sprintf(
`No _kerberos._tcp.%[1]s or _kerberos._udp.%[1]s records exist. Publish at minimum:
_kerberos._tcp.%[1]s. IN SRV 0 0 88 kdc.%[1]s. _kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.`, strings.ToLower(r.Realm))), }) } // SRV targets that don't resolve. var unresolved []string for _, h := range rd.Resolution { if h.Error != "" || (len(h.IPv4) == 0 && len(h.IPv6) == 0) { unresolved = append(unresolved, h.Target) } } if len(unresolved) > 0 { out = append(out, remediation{ Title: "Resolve KDC host names", Body: template.HTML(fmt.Sprintf( `The following SRV target(s) do not resolve to an IP address:
%s. Add A/AAAA records for each host, or correct the SRV target.`,
template.HTMLEscapeString(strings.Join(unresolved, ", ")))),
})
}
// No KDC reachable (port filtered / host down).
reachable := 0
totalKDC := 0
for _, p := range r.Probes {
if p.Role == "kdc" {
totalKDC++
if p.OK {
reachable++
}
}
}
if totalKDC > 0 && reachable == 0 {
out = append(out, remediation{
Title: "Open port 88 on KDC hosts",
Body: template.HTML(`Every KDC endpoint refused or timed out. Ensure your firewall allows inbound TCP 88 and UDP 88, and that the KDC process is listening on the SRV target's IP.`),
})
}
// Clock skew.
if rd.ClockSkewBad {
out = append(out, remediation{
Title: "Synchronize clocks",
Body: template.HTML(fmt.Sprintf(
`KDC clock differs from this checker by %s. Kerberos denies authentication once skew exceeds 5 minutes; run ntpd or chronyd on the KDC and its clients.`,
template.HTMLEscapeString(rd.ClockSkew))),
})
}
// Weak crypto only.
if rd.HasWeakOnly {
out = append(out, remediation{
Title: "Retire DES/RC4 enctypes",
Body: template.HTML(`The KDC only advertises weak encryption types. Configure at least AES:
[libdefaults]
default_tkt_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
default_tgs_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
permitted_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
then rekey principals with kadmin -q "cpw -randkey principal" or equivalent.`),
})
}
// Wrong realm in KDC reply.
if r.AS.ServerRealm != "" && !strings.EqualFold(r.AS.ServerRealm, r.Realm) {
out = append(out, remediation{
Title: "Fix realm mismatch",
Body: template.HTML(fmt.Sprintf(
`The KDC answered for realm %s instead of %s. Align default_realm in krb5.conf and the [realms] stanza in kdc.conf with the SRV-published realm name.`,
template.HTMLEscapeString(r.AS.ServerRealm),
template.HTMLEscapeString(r.Realm))),
})
}
// AS-REP without preauth, AS-REP roasting.
if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
out = append(out, remediation{
Title: "Enable pre-authentication",
Body: template.HTML(`The KDC returned an AS-REP without demanding pre-authentication. This exposes principals to AS-REP roasting. Enable requires_preauth (MIT) or the equivalent flag on every user principal, e.g. kadmin -q "modprinc +requires_preauth user@REALM".`),
})
}
return out
}
var kerberosHTMLTemplate = template.Must(
template.New("kerberos").Parse(`
{{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}| Target | Port | Priority | Weight |
|---|---|---|---|
{{.Target}} |
{{.Port}} | {{.Priority}} | {{.Weight}} |
{{.Error}}
{{else}}No records published.
{{end}}| Host | IPv4 | IPv6 | Status |
|---|---|---|---|
{{.Target}} |
{{range .IPv4}}{{.}} {{end}} |
{{range .IPv6}}{{.}} {{end}} |
{{if .Error}}{{.Error}}{{else}}✓{{end}} |
| Role | Host | Port | Proto | RTT | Detail | |
|---|---|---|---|---|---|---|
| {{if .OK}}✓{{else}}✗{{end}} | {{.Role}} | {{.Target}} |
{{.Port}} | {{.Proto}} | {{.RTT}} | {{if .Error}}{{.Error}}{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}} |
{{.ASProbe.Error}}
{{else}}{{.ASErrorName}} {{if .PreauthReq}}preauth required{{end}}{{if .PrincipalFound}}AS-REP without preauth{{end}}{{.ASProbe.ServerRealm}}Probe not attempted.
{{end}}{{.AuthProbe.Principal}}{{.AuthProbe.ErrorName}}