checker-kerberos/checker/report.go

532 lines
17 KiB
Go

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 <code>_kerberos._tcp.%[1]s</code> or <code>_kerberos._udp.%[1]s</code> records exist. Publish at minimum:<br>
<pre>_kerberos._tcp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.
_kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.</pre>`, 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: <code>%s</code>. 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 <code>TCP 88</code> and <code>UDP 88</code>, 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 <strong>%s</strong>. Kerberos denies authentication once skew exceeds 5 minutes; run <code>ntpd</code> or <code>chronyd</code> 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:
<pre>[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</pre>
then rekey principals with <code>kadmin -q "cpw -randkey principal"</code> 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 <code>%s</code> instead of <code>%s</code>. Align <code>default_realm</code> in <code>krb5.conf</code> and the <code>[realms]</code> stanza in <code>kdc.conf</code> 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 <strong>AS-REP roasting</strong>. Enable <code>requires_preauth</code> (MIT) or the equivalent flag on every user principal, e.g. <code>kadmin -q "modprinc +requires_preauth user@REALM"</code>.`),
})
}
return out
}
var kerberosHTMLTemplate = template.Must(
template.New("kerberos").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kerberos Realm Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
pre {
font-family: ui-monospace, monospace; font-size: .82em;
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px;
padding: .55rem .7rem; overflow-x: auto; margin: .35rem 0 0;
}
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff; border-radius: 10px;
padding: 1rem 1.25rem; margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.neutral { background: #e5e7eb; color: #374151; }
.realm { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.realm code { color: #111827; }
.section {
background: #fff; border-radius: 8px;
padding: .85rem 1rem; margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
.reme {
background: #fff7ed; border: 1px solid #fdba74; border-left: 4px solid #f97316;
border-radius: 8px; padding: .75rem 1rem; margin-bottom: .6rem;
}
.reme h2 { color: #9a3412; }
.reme-item { padding: .55rem 0; border-top: 1px dashed #fdba74; }
.reme-item:first-of-type { border-top: none; padding-top: .25rem; }
.reme-item h3 { color: #9a3412; margin-bottom: .25rem; }
details {
background: #fff; border-radius: 8px; margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;
}
.section details {
box-shadow: none; border-radius: 6px;
border: 1px solid #e5e7eb; margin-bottom: .4rem;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .55rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶"; font-size: .65rem; color: #9ca3af;
transition: transform .15s; flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.srv-title { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .88rem; }
.details-body { padding: .55rem 1rem .8rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.note { color: #6b7280; font-size: .85rem; }
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
.chip {
display: inline-block; padding: .15em .6em; margin: .12em .2em .12em 0;
border-radius: 9999px; font-size: .78rem; font-weight: 600;
background: #e0e7ff; color: #3730a3;
}
.chip.weak { background: #fee2e2; color: #991b1b; }
.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: .8rem; row-gap: .1rem; font-size: .85rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
</style>
</head>
<body>
<div class="hd">
<h1>Kerberos Realm</h1>
{{if .HasStates}}
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
{{else}}<span class="badge warn">Needs attention</span>{{end}}
{{end}}
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} &middot; probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
</div>
{{if .Remediations}}
<div class="reme">
<h2>Most common issues &mdash; fix these first</h2>
{{range .Remediations}}
<div class="reme-item">
<h3>{{.Title}}</h3>
<div>{{.Body}}</div>
</div>
{{end}}
</div>
{{end}}
<div class="section">
<h2>SRV records</h2>
{{range .SRVBuckets}}
<details{{if and .Error (not .Records)}} open{{end}}>
<summary>
<span class="srv-title">{{.Lookup}}</span>
{{if .Records}}<span class="badge ok">{{len .Records}}</span>
{{else if .NXDomain}}<span class="badge neutral">none</span>
{{else if .Error}}<span class="badge fail">error</span>
{{else}}<span class="badge neutral">empty</span>{{end}}
</summary>
<div class="details-body">
{{if .Records}}
<table>
<tr><th>Target</th><th>Port</th><th>Priority</th><th>Weight</th></tr>
{{range .Records}}
<tr>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}}</td>
<td>{{.Weight}}</td>
</tr>
{{end}}
</table>
{{else if .Error}}<p class="errmsg">{{.Error}}</p>
{{else}}<p class="note">No records published.</p>{{end}}
</div>
</details>
{{end}}
</div>
{{if .Resolution}}
<div class="section">
<h2>Host resolution</h2>
<table>
<tr><th>Host</th><th>IPv4</th><th>IPv6</th><th>Status</th></tr>
{{range .Resolution}}
<tr>
<td><code>{{.Target}}</code></td>
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
<td>{{if .Error}}<span class="check-fail">{{.Error}}</span>{{else}}<span class="check-ok">&#10003;</span>{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .Probes}}
<div class="section">
<h2>Connectivity probes</h2>
<table>
<tr><th></th><th>Role</th><th>Host</th><th>Port</th><th>Proto</th><th>RTT</th><th>Detail</th></tr>
{{range .Probes}}
<tr>
<td>{{if .OK}}<span class="check-ok">&#10003;</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</td>
<td>{{.Role}}</td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Proto}}</td>
<td>{{.RTT}}</td>
<td>{{if .Error}}<span class="errmsg">{{.Error}}</span>{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
<div class="section">
<h2>AS-REQ probe</h2>
{{if .ASProbe.Attempted}}
{{if .ASProbe.Error}}
<p class="errmsg">{{.ASProbe.Error}}</p>
{{else}}
<dl class="kv">
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
</dl>
{{end}}
{{else}}
<p class="note">Probe not attempted.</p>
{{end}}
</div>
{{if .HasEnctypes}}
<div class="section">
<h2>Advertised enctypes {{if .HasWeakOnly}}<span class="badge fail">weak only</span>{{else if .HasMixedCrypto}}<span class="badge warn">mixed</span>{{else}}<span class="badge ok">strong</span>{{end}}</h2>
{{range .Enctypes}}<span class="chip{{if .Weak}} weak{{end}}">{{.Name}}</span>{{end}}
</div>
{{end}}
{{if .AuthProbe}}
<div class="section">
<h2>Authenticated round-trip</h2>
<dl class="kv">
<dt>Principal</dt><dd><code>{{.AuthProbe.Principal}}</code></dd>
<dt>TGT</dt><dd>{{if .AuthProbe.TGTAcquired}}<span class="check-ok">&#10003; acquired</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if .AuthProbe.TargetService}}<dt>TGS ({{.AuthProbe.TargetService}})</dt><dd>{{if .AuthProbe.TGSAcquired}}<span class="check-ok">&#10003; acquired</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</dd>{{end}}
{{if .AuthProbe.ErrorName}}<dt>KDC error</dt><dd><code>{{.AuthProbe.ErrorName}}</code></dd>{{end}}
{{if .AuthProbe.Error}}<dt>Detail</dt><dd><span class="errmsg">{{.AuthProbe.Error}}</span></dd>{{end}}
</dl>
</div>
{{end}}
</body>
</html>`),
)