532 lines
17 KiB
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}} · probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
|
|
</div>
|
|
|
|
{{if .Remediations}}
|
|
<div class="reme">
|
|
<h2>Most common issues — 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">✓</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">✓</span>{{else}}<span class="check-fail">✗</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">✓ acquired</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
|
{{if .AuthProbe.TargetService}}<dt>TGS ({{.AuthProbe.TargetService}})</dt><dd>{{if .AuthProbe.TGSAcquired}}<span class="check-ok">✓ acquired</span>{{else}}<span class="check-fail">✗</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>`),
|
|
)
|