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(` Kerberos Realm Report

Kerberos Realm

{{if .HasStates}} {{if .OverallOK}}Realm OK {{else if .Remediations}}Issues detected {{else}}Needs attention{{end}} {{end}}
Realm: {{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}
{{if .Remediations}}

Most common issues — fix these first

{{range .Remediations}}

{{.Title}}

{{.Body}}
{{end}}
{{end}}

SRV records

{{range .SRVBuckets}} {{.Lookup}} {{if .Records}}{{len .Records}} {{else if .NXDomain}}none {{else if .Error}}error {{else}}empty{{end}}
{{if .Records}} {{range .Records}} {{end}}
TargetPortPriorityWeight
{{.Target}} {{.Port}} {{.Priority}} {{.Weight}}
{{else if .Error}}

{{.Error}}

{{else}}

No records published.

{{end}}
{{end}}
{{if .Resolution}}

Host resolution

{{range .Resolution}} {{end}}
HostIPv4IPv6Status
{{.Target}} {{range .IPv4}}{{.}} {{end}} {{range .IPv6}}{{.}} {{end}} {{if .Error}}{{.Error}}{{else}}{{end}}
{{end}} {{if .Probes}}

Connectivity probes

{{range .Probes}} {{end}}
RoleHostPortProtoRTTDetail
{{if .OK}}{{else}}{{end}} {{.Role}} {{.Target}} {{.Port}} {{.Proto}} {{.RTT}} {{if .Error}}{{.Error}}{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}}
{{end}}

AS-REQ probe

{{if .ASProbe.Attempted}} {{if .ASProbe.Error}}

{{.ASProbe.Error}}

{{else}}
Response
{{.ASErrorName}} {{if .PreauthReq}}preauth required{{end}}{{if .PrincipalFound}}AS-REP without preauth{{end}}
Realm echoed
{{.ASProbe.ServerRealm}}
Server time
{{.ServerTime}}
Clock skew
{{if .ClockSkewBad}}{{.ClockSkew}}{{else}}{{.ClockSkew}}{{end}}
PKINIT offered
{{if .PKINITOffered}}yes{{else}}no{{end}}
{{end}} {{else}}

Probe not attempted.

{{end}}
{{if .HasEnctypes}}

Advertised enctypes {{if .HasWeakOnly}}weak only{{else if .HasMixedCrypto}}mixed{{else}}strong{{end}}

{{range .Enctypes}}{{.Name}}{{end}}
{{end}} {{if .AuthProbe}}

Authenticated round-trip

Principal
{{.AuthProbe.Principal}}
TGT
{{if .AuthProbe.TGTAcquired}}✓ acquired{{else}}✗ failed{{end}}
{{if .AuthProbe.TargetService}}
TGS ({{.AuthProbe.TargetService}})
{{if .AuthProbe.TGSAcquired}}✓ acquired{{else}}{{end}}
{{end}} {{if .AuthProbe.ErrorName}}
KDC error
{{.AuthProbe.ErrorName}}
{{end}} {{if .AuthProbe.Error}}
Detail
{{.AuthProbe.Error}}
{{end}}
{{end}} `), )