Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:41:50 +07:00
commit 40a4cf285e
18 changed files with 1933 additions and 0 deletions

93
checker/auth.go Normal file
View file

@ -0,0 +1,93 @@
package checker
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/jcmturner/gokrb5/v8/client"
"github.com/jcmturner/gokrb5/v8/config"
)
// runAuthProbe performs a Login (AS-REQ + preauth) and, if a targetService
// is supplied, a TGS-REQ. kdcHosts maps reachable KDC hostnames to their
// TCP port so we can populate the krb5 config without doing DNS again.
func runAuthProbe(ctx context.Context, realm, principal, password, targetService string,
kdcHosts map[string]uint16, timeout time.Duration) *AuthProbeResult {
res := &AuthProbeResult{Attempted: true, Principal: principal, TargetService: targetService}
username := principal
if i := strings.LastIndex(principal, "@"); i >= 0 {
username = principal[:i]
}
cfg := config.New()
cfg.LibDefaults.DefaultRealm = realm
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.Clockskew = 5 * time.Minute
cfg.LibDefaults.UDPPreferenceLimit = 1 // force TCP
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cfg.LibDefaults.DefaultTGSEnctypeIDs = preferredEnctypes
cfg.LibDefaults.PermittedEnctypeIDs = preferredEnctypes
realmCfg := config.Realm{Realm: realm}
for host, port := range kdcHosts {
realmCfg.KDC = append(realmCfg.KDC, host+":"+strconv.Itoa(int(port)))
}
cfg.Realms = []config.Realm{realmCfg}
cfg.DomainRealm = config.DomainRealm{strings.ToLower(realm): realm}
start := time.Now()
cl := client.NewWithPassword(username, realm, password, cfg,
client.DisablePAFXFAST(true))
loginErr := cl.Login()
res.Latency = time.Since(start)
if loginErr != nil {
res.Error = loginErr.Error()
if code, ok := extractKRBErrorCode(loginErr); ok {
res.ErrorCode = code
res.ErrorName = errorcodeName(code)
}
return res
}
res.TGTAcquired = true
spn := targetService
if spn == "" {
// Self-test: request a fresh TGT (krbtgt/REALM@REALM).
spn = fmt.Sprintf("krbtgt/%s", realm)
}
if _, _, err := cl.GetServiceTicket(spn); err != nil {
res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err)
if code, ok := extractKRBErrorCode(err); ok {
res.ErrorCode = code
res.ErrorName = errorcodeName(code)
}
return res
}
res.TGSAcquired = true
return res
}
// extractKRBErrorCode tries to pull a Kerberos error code out of the
// wrapped errors returned by gokrb5. It's best-effort: if the code
// can't be determined, ok is false.
func extractKRBErrorCode(err error) (int32, bool) {
if err == nil {
return 0, false
}
// gokrb5 wraps KRBError values: their Error() string begins with "KRB Error: (N)".
msg := err.Error()
if _, after, ok := strings.Cut(msg, "KRB Error: ("); ok {
if code, _, ok := strings.Cut(after, ")"); ok {
if n, err := strconv.Atoi(code); err == nil {
return int32(n), true
}
}
}
return 0, false
}

516
checker/collect.go Normal file
View file

@ -0,0 +1,516 @@
package checker
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
asn1 "github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
"github.com/jcmturner/gokrb5/v8/iana/nametype"
"github.com/jcmturner/gokrb5/v8/iana/patype"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// srvPrefixes lists every SRV label we probe, in order. The first two are
// considered mandatory (no realm is usable without one of them).
var srvPrefixes = []struct {
prefix string
role string
proto string
}{
{"_kerberos._tcp.", "kdc", "tcp"},
{"_kerberos._udp.", "kdc", "udp"},
{"_kerberos-master._tcp.", "master", "tcp"},
{"_kerberos-adm._tcp.", "kadmin", "tcp"},
{"_kpasswd._tcp.", "kpasswd", "tcp"},
{"_kpasswd._udp.", "kpasswd", "udp"},
}
// weakEnctypes lists the IDs considered weak per RFC 8429 + common guidance.
var weakEnctypes = map[int32]bool{
etypeID.DES_CBC_CRC: true,
etypeID.DES_CBC_MD4: true,
etypeID.DES_CBC_MD5: true,
etypeID.DES_CBC_RAW: true,
etypeID.DES3_CBC_MD5: true,
etypeID.DES3_CBC_RAW: true,
etypeID.DES3_CBC_SHA1: true,
etypeID.DES_HMAC_SHA1: true,
etypeID.RC4_HMAC: true,
etypeID.RC4_HMAC_EXP: true,
}
// preferredEnctypes is the enctype preference list used in anonymous probes
// and authenticated requests. RC4 is included last so the KDC's ETYPE-INFO2
// reveals whether it still supports it.
var preferredEnctypes = []int32{
etypeID.AES256_CTS_HMAC_SHA1_96,
etypeID.AES128_CTS_HMAC_SHA1_96,
etypeID.AES256_CTS_HMAC_SHA384_192,
etypeID.AES128_CTS_HMAC_SHA256_128,
etypeID.RC4_HMAC,
}
// etypeName returns a human-friendly name for an enctype ID, falling back
// to its numeric value when unknown.
func etypeName(id int32) string {
for name, nid := range etypeID.ETypesByName {
if nid == id {
// Prefer canonical "aes..." / "rc4-hmac" shape
if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") {
return name
}
}
}
return fmt.Sprintf("etype-%d", id)
}
func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
realmRaw, _ := opts["realm"].(string)
realmRaw = strings.TrimSuffix(realmRaw, ".")
if realmRaw == "" {
return nil, fmt.Errorf("realm is required")
}
realm := strings.ToUpper(realmRaw)
domain := strings.ToLower(realmRaw)
timeoutSec := optFloat(opts, "timeout", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
now := time.Now().UTC()
data := &KerberosData{
Realm: realm,
CollectedAt: now,
LocalTime: now,
Resolution: map[string]HostResolution{},
}
// 1. SRV discovery ---------------------------------------------------------
resolver := &net.Resolver{}
data.SRV = make([]SRVBucket, len(srvPrefixes))
var wg sync.WaitGroup
for i, sp := range srvPrefixes {
wg.Go(func() {
bucket := SRVBucket{Prefix: sp.prefix, LookupName: sp.prefix + domain + "."}
lctx, cancel := context.WithTimeout(ctx, timeout)
_, addrs, err := resolver.LookupSRV(lctx, "", "", sp.prefix+domain)
cancel()
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
bucket.NXDomain = true
} else {
bucket.Error = err.Error()
}
}
for _, srv := range addrs {
target := strings.TrimSuffix(srv.Target, ".")
bucket.Records = append(bucket.Records, SRVRecord{
Target: target,
Port: srv.Port,
Priority: srv.Priority,
Weight: srv.Weight,
})
}
data.SRV[i] = bucket
})
}
wg.Wait()
// 2. Forward resolution ----------------------------------------------------
var targets []string
seen := map[string]bool{}
for _, b := range data.SRV {
for _, r := range b.Records {
if !seen[r.Target] {
seen[r.Target] = true
targets = append(targets, r.Target)
}
}
}
resolutions := make([]HostResolution, len(targets))
for i, target := range targets {
wg.Go(func() {
hr := HostResolution{Target: target}
lctx, cancel := context.WithTimeout(ctx, timeout)
ips, err := resolver.LookupIPAddr(lctx, target)
cancel()
if err != nil {
hr.Error = err.Error()
}
for _, ip := range ips {
if ip.IP.To4() != nil {
hr.IPv4 = append(hr.IPv4, ip.IP.String())
} else {
hr.IPv6 = append(hr.IPv6, ip.IP.String())
}
}
resolutions[i] = hr
})
}
wg.Wait()
for _, hr := range resolutions {
data.Resolution[hr.Target] = hr
}
// 3. L4 probes -------------------------------------------------------------
kdcHosts := map[string]uint16{} // target -> tcp port, used to pick the AS-REQ destination
for _, b := range data.SRV {
role := roleForPrefix(b.Prefix)
if strings.HasSuffix(b.Prefix, "._tcp.") {
for _, r := range b.Records {
probe := dialTCP(ctx, r.Target, r.Port, role, timeout)
data.Probes = append(data.Probes, probe)
if role == "kdc" && probe.OK {
if _, ok := kdcHosts[r.Target]; !ok {
kdcHosts[r.Target] = r.Port
}
}
}
}
}
// 4. AS-REQ probe (tries each reachable KDC TCP first, then UDP fallbacks).
asProbe := ASProbeResult{Attempted: true}
asReq, err := buildProbeASReq(realm)
if err != nil {
asProbe.Error = fmt.Sprintf("build AS-REQ: %v", err)
} else {
req, _ := asReq.Marshal()
// Target list: reachable TCP KDCs, then UDP KDCs if nothing else.
var tcpEps, udpEps []endpoint
for _, b := range data.SRV {
if !strings.HasPrefix(b.Prefix, "_kerberos.") {
continue
}
proto := "tcp"
if strings.HasSuffix(b.Prefix, "._udp.") {
proto = "udp"
}
for _, r := range b.Records {
e := endpoint{r.Target, r.Port, proto}
if proto == "tcp" {
tcpEps = append(tcpEps, e)
} else {
udpEps = append(udpEps, e)
}
}
}
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
for _, e := range eps {
start := time.Now()
var reply []byte
var perr error
if e.proto == "tcp" {
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
} else {
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
// Track UDP reachability via this attempt.
probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start),
}
if perr == nil {
probe.OK = true
probe.KrbSeen = true
} else {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
}
if perr != nil || len(reply) == 0 {
continue
}
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
break
}
if asProbe.Target == "" && asProbe.Error == "" {
asProbe.Error = "no KDC answered our AS-REQ probe"
}
if !asProbe.ServerTime.IsZero() {
asProbe.ClockSkew = time.Since(asProbe.ServerTime)
}
}
data.AS = asProbe
// 5. Roll up enctypes ------------------------------------------------------
for _, e := range asProbe.Enctypes {
if e.Weak {
data.WeakEnctypes = append(data.WeakEnctypes, e)
}
}
data.Enctypes = asProbe.Enctypes
// 6. Optional authenticated round-trip ------------------------------------
principal, _ := opts["principal"].(string)
password, _ := opts["password"].(string)
if principal != "" && password != "" {
data.Auth = runAuthProbe(ctx, realm, principal, password,
stringOpt(opts, "targetService"), kdcHosts, timeout)
}
return data, nil
}
func roleForPrefix(prefix string) string {
switch {
case strings.HasPrefix(prefix, "_kerberos-master."):
return "master"
case strings.HasPrefix(prefix, "_kerberos-adm."):
return "kadmin"
case strings.HasPrefix(prefix, "_kpasswd."):
return "kpasswd"
default:
return "kdc"
}
}
type endpoint struct {
target string
port uint16
proto string
}
func dialTCP(ctx context.Context, target string, port uint16, role string, timeout time.Duration) KDCProbe {
probe := KDCProbe{Target: target, Port: port, Proto: "tcp", Role: role}
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
start := time.Now()
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
probe.RTT = time.Since(start)
if err != nil {
probe.Error = err.Error()
return probe
}
_ = conn.Close()
probe.OK = true
return probe
}
// sendASReqTCP frames the AS-REQ with its 4-byte length prefix (RFC 4120 §7.2.2)
// and reads the response the same way.
func sendASReqTCP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
hdr := make([]byte, 4)
binary.BigEndian.PutUint32(hdr, uint32(len(body)))
if _, err := conn.Write(append(hdr, body...)); err != nil {
return nil, err
}
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(lenBuf)
if n == 0 || n > 1<<20 {
return nil, fmt.Errorf("suspicious reply length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, err
}
return buf, nil
}
func sendASReqUDP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "udp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write(body); err != nil {
return nil, err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
// buildProbeASReq builds an AS-REQ for krbtgt/REALM@REALM with a throwaway
// cname. We don't want to depend on reading a local krb5.conf, so we craft
// the request fields by hand from a default config.
func buildProbeASReq(realm string) (messages.ASReq, error) {
cfg := config.New()
cfg.LibDefaults.DefaultRealm = realm
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain")
return messages.NewASReqForTGT(realm, cfg, cname)
}
// parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or,
// less commonly, an AS-REP (principal exists and doesn't require preauth —
// AS-REP roasting territory).
func parseASResponse(raw []byte, out *ASProbeResult) {
// Try KRB-ERROR first.
var krbErr messages.KRBError
if err := krbErr.Unmarshal(raw); err == nil {
out.ErrorCode = krbErr.ErrorCode
out.ErrorName = errorcodeName(krbErr.ErrorCode)
out.ServerRealm = krbErr.Realm
if !krbErr.STime.IsZero() {
out.ServerTime = krbErr.STime.UTC()
}
out.PreauthReq = krbErr.ErrorCode == errorcode.KDC_ERR_PREAUTH_REQUIRED
out.Raw = fmt.Sprintf("KRB-ERROR %s", out.ErrorName)
if len(krbErr.EData) > 0 {
enctypes, pkinit := extractEData(krbErr.EData)
out.Enctypes = enctypes
out.PKINITOffered = pkinit
}
return
}
// Try AS-REP. If this succeeds, preauth wasn't required — surface it.
var asRep messages.ASRep
if err := asRep.Unmarshal(raw); err == nil {
out.PrincipalFound = true
out.ServerRealm = asRep.CRealm
out.Raw = "AS-REP received without preauth (AS-REP roasting exposure)"
return
}
out.Error = "unable to parse KDC reply (" + hex.EncodeToString(first(raw, 16)) + ")"
}
func first(b []byte, n int) []byte {
if len(b) < n {
return b
}
return b[:n]
}
// extractEData parses the METHOD-DATA / PADataSequence that KDCs attach to
// PREAUTH_REQUIRED errors and returns the advertised enctypes + pkinit hint.
func extractEData(edata []byte) ([]EnctypeEntry, bool) {
var pas types.PADataSequence
if _, err := asn1.Unmarshal(edata, &pas); err != nil {
return nil, false
}
var out []EnctypeEntry
pkinit := false
for _, pa := range pas {
switch pa.PADataType {
case patype.PA_ETYPE_INFO2:
var info types.ETypeInfo2
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: e.Salt,
Source: "etype-info2",
})
}
}
case patype.PA_ETYPE_INFO:
var info types.ETypeInfo
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
if hasEnctype(out, e.EType) {
continue
}
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: string(e.Salt),
Source: "etype-info",
})
}
}
case patype.PA_PK_AS_REQ, patype.PA_PK_AS_REQ_OLD:
pkinit = true
}
}
return out, pkinit
}
func hasEnctype(list []EnctypeEntry, id int32) bool {
for _, e := range list {
if e.ID == id {
return true
}
}
return false
}
func errorcodeName(code int32) string {
s := errorcode.Lookup(code)
if _, after, ok := strings.Cut(s, ") "); ok {
s = after
}
if before, _, ok := strings.Cut(s, " "); ok {
s = before
}
return s
}
// ---- helpers ----------------------------------------------------------------
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case string:
if f, err := strconv.ParseFloat(x, 64); err == nil {
return f
}
}
return def
}
func stringOpt(opts sdk.CheckerOptions, key string) string {
s, _ := opts[key].(string)
return s
}

91
checker/definition.go Normal file
View file

@ -0,0 +1,91 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Overridden at link time by the binary/plugin entrypoints.
var Version = "built-in"
// Definition returns the CheckerDefinition for the Kerberos checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "kerberos",
Name: "Kerberos Realm Tester",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Kerberos"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyKerberos},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "realm",
Type: "string",
Label: "Kerberos realm",
Placeholder: "EXAMPLE.COM",
AutoFill: sdk.AutoFillDomainName,
Required: true,
Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).",
},
{
Id: "principal",
Type: "string",
Label: "Principal (optional)",
Placeholder: "user@EXAMPLE.COM",
Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.",
},
{
Id: "password",
Type: "string",
Label: "Password (optional)",
Secret: true,
Description: "Password for the principal above. Used once per run; never stored by the checker.",
},
{
Id: "targetService",
Type: "string",
Label: "Service to request (TGS)",
Placeholder: "host/host.example.com",
Default: "",
Description: "SPN requested via TGS-REQ once a TGT is acquired. Defaults to krbtgt (realm self-test).",
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "timeout",
Type: "number",
Label: "Per-probe timeout (seconds)",
Default: 5,
},
{
Id: "requireStrongEnctypes",
Type: "bool",
Label: "Require strong enctypes",
Default: true,
Description: "Flag realms that only advertise DES/RC4 as CRIT.",
},
{
Id: "maxClockSkew",
Type: "number",
Label: "Max tolerated clock skew (seconds)",
Default: 300,
Description: "Default Kerberos tolerance is 300s; tighter values surface drift earlier.",
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

92
checker/interactive.go Normal file
View file

@ -0,0 +1,92 @@
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the minimal set of fields a human needs to fire a
// standalone Kerberos probe via GET /check.
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "realm",
Type: "string",
Label: "Kerberos realm",
Placeholder: "EXAMPLE.COM",
Required: true,
Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).",
},
{
Id: "principal",
Type: "string",
Label: "Principal (optional)",
Placeholder: "user@EXAMPLE.COM",
Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.",
},
{
Id: "password",
Type: "string",
Label: "Password (optional)",
Secret: true,
},
{
Id: "targetService",
Type: "string",
Label: "Service to request (TGS)",
Placeholder: "host/host.example.com",
},
{
Id: "timeout",
Type: "number",
Label: "Per-probe timeout (seconds)",
Default: 5,
},
{
Id: "requireStrongEnctypes",
Type: "bool",
Label: "Require strong enctypes",
Default: true,
},
{
Id: "maxClockSkew",
Type: "number",
Label: "Max tolerated clock skew (seconds)",
Default: 300,
},
}
}
// ParseForm turns the submitted form into a CheckerOptions. Collect handles
// the SRV / DNS discovery itself, so there is nothing to auto-fill here
// beyond the raw inputs.
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
realm := strings.TrimSpace(r.FormValue("realm"))
if realm == "" {
return nil, errors.New("realm is required")
}
opts := sdk.CheckerOptions{"realm": realm}
if v := strings.TrimSpace(r.FormValue("principal")); v != "" {
opts["principal"] = v
}
if v := r.FormValue("password"); v != "" {
opts["password"] = v
}
if v := strings.TrimSpace(r.FormValue("targetService")); v != "" {
opts["targetService"] = v
}
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
opts["timeout"] = v
}
if v := strings.TrimSpace(r.FormValue("maxClockSkew")); v != "" {
opts["maxClockSkew"] = v
}
opts["requireStrongEnctypes"] = r.FormValue("requireStrongEnctypes") == "true"
return opts, nil
}

21
checker/provider.go Normal file
View file

@ -0,0 +1,21 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new Kerberos observation provider.
func Provider() sdk.ObservationProvider {
return &kerberosProvider{}
}
type kerberosProvider struct{}
func (p *kerberosProvider) Key() sdk.ObservationKey {
return ObservationKeyKerberos
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

507
checker/report.go Normal file
View file

@ -0,0 +1,507 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
)
// ── 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
OverallOK bool
CollectedAt string
LocalTime 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(raw json.RawMessage) (string, error) {
var r KerberosData
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err)
}
rd := reportData{
Realm: r.Realm,
OverallOK: r.OverallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339),
LocalTime: r.LocalTime.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)
if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute {
rd.ClockSkewBad = true
}
}
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.
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 .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}}
<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>Local time</dt><dd>{{.LocalTime}}</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>`),
)

224
checker/rule.go Normal file
View file

@ -0,0 +1,224 @@
package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the kerberos_health rule.
func Rule() sdk.CheckRule {
return &kerberosRule{}
}
type kerberosRule struct{}
func (r *kerberosRule) Name() string {
return "kerberos_health"
}
func (r *kerberosRule) Description() string {
return "Checks whether a Kerberos realm answers correctly, advertises strong crypto, and exposes no obvious misconfiguration."
}
func (r *kerberosRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil }
func (r *kerberosRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data KerberosData
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Kerberos data: %v", err),
Code: "kerberos_error",
}}
}
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300)) * time.Second
requireStrong := optBool(opts, "requireStrongEnctypes", true)
// Presence of at least one _kerberos._tcp or ._udp record is mandatory.
hasKDCSRV := false
for _, b := range data.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
hasKDCSRV = true
break
}
}
if !hasKDCSRV {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
Code: "kerberos_no_srv",
}}
}
// KDC reachability: need at least one successful probe among KDC roles.
reachable := 0
kdcProbes := 0
kadminDown, kpasswdDown := false, false
for _, p := range data.Probes {
switch p.Role {
case "kdc":
kdcProbes++
if p.OK {
reachable++
}
case "kadmin":
if !p.OK {
kadminDown = true
}
case "kpasswd":
if !p.OK {
kpasswdDown = true
}
}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "No KDC is reachable on TCP 88 or UDP 88",
Code: "kerberos_kdc_unreachable",
}}
}
// AS-REQ result.
if data.AS.Attempted {
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "AS-REQ probe failed: " + data.AS.Error,
Code: "kerberos_error",
}}
}
if data.AS.ServerRealm != "" && !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
Code: "kerberos_wrong_realm",
}}
}
if abs(data.AS.ClockSkew) > maxSkew {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s)", round(data.AS.ClockSkew), maxSkew),
Code: "kerberos_clock_skew",
Meta: map[string]any{
"skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
}
// Crypto posture.
hasStrong := false
for _, e := range data.Enctypes {
if !e.Weak {
hasStrong = true
break
}
}
if requireStrong && len(data.Enctypes) > 0 && !hasStrong {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "KDC only advertises weak enctypes (DES/RC4)",
Code: "kerberos_weak_crypto",
}}
}
// Auth probe (if any).
if data.Auth != nil && data.Auth.Attempted {
if !data.Auth.TGTAcquired {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "Authenticated probe: TGT acquisition failed",
Code: "kerberos_auth_fail",
}}
}
if data.Auth.TargetService != "" && !data.Auth.TGSAcquired {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed", data.Auth.TargetService),
Code: "kerberos_tgs_fail",
}}
}
}
// Warnings: partial reachability, no UDP, mixed crypto, no preauth.
var warnings []string
if reachable < kdcProbes {
warnings = append(warnings, fmt.Sprintf("%d/%d KDC endpoints unreachable", kdcProbes-reachable, kdcProbes))
}
if len(data.WeakEnctypes) > 0 && hasStrong {
warnings = append(warnings, "KDC also advertises weak enctypes alongside strong ones")
}
if data.AS.Attempted && data.AS.PrincipalFound && !data.AS.PreauthReq {
warnings = append(warnings, "AS-REP returned without preauth (AS-REP roasting exposure)")
}
if kadminDown {
warnings = append(warnings, "kadmin server unreachable")
}
if kpasswdDown {
warnings = append(warnings, "kpasswd unreachable")
}
if len(warnings) > 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s reachable: %s", data.Realm, strings.Join(warnings, "; ")),
Code: "kerberos_warn",
Meta: map[string]any{
"reachable_kdcs": reachable,
"warnings": warnings,
},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s healthy (%d KDC reachable, strong crypto)", data.Realm, reachable),
Code: "kerberos_ok",
Meta: map[string]any{
"realm": data.Realm,
"reachable_kdcs": reachable,
"clock_skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
func abs(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
func round(d time.Duration) time.Duration {
return d.Round(time.Millisecond)
}
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1"
}
return def
}

120
checker/types.go Normal file
View file

@ -0,0 +1,120 @@
// Package checker implements the Kerberos realm checker for happyDomain.
//
// Given a realm / DNS domain, the checker performs a sequence of anonymous
// probes (SRV layout, KDC reachability, AS-REQ exchange, enctype & clock-
// skew discovery) and optionally an authenticated round-trip. Results are
// stored as a single KerberosData observation and rendered as an HTML
// report.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyKerberos is the observation key for Kerberos realm test data.
const ObservationKeyKerberos sdk.ObservationKey = "kerberos_realm"
// SRVRecord mirrors a *dns.SRV row we want to report on.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
}
// SRVBucket is the resolution outcome for one SRV prefix.
type SRVBucket struct {
Prefix string `json:"prefix"`
Records []SRVRecord `json:"records,omitempty"`
// Error describes why the lookup failed. NXDOMAIN is reported as an
// empty slice + Error="" so the UI can tell "no records" from
// "lookup failed".
Error string `json:"error,omitempty"`
NXDomain bool `json:"nxdomain,omitempty"`
LookupName string `json:"lookupName"`
}
// HostResolution is the A/AAAA resolution outcome for a single SRV target.
type HostResolution struct {
Target string `json:"target"`
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
Error string `json:"error,omitempty"`
}
// KDCProbe captures the outcome of a single L4 probe.
type KDCProbe struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Proto string `json:"proto"` // "tcp" | "udp"
Role string `json:"role"` // "kdc" | "kadmin" | "kpasswd" | "master"
OK bool `json:"ok"`
RTT time.Duration `json:"rtt_ns"`
Error string `json:"error,omitempty"`
KrbSeen bool `json:"krbSeen,omitempty"` // true when a KRB message was actually parsed
}
// EnctypeEntry describes one advertised enctype.
type EnctypeEntry struct {
ID int32 `json:"id"`
Name string `json:"name"`
Weak bool `json:"weak,omitempty"`
Salt string `json:"salt,omitempty"`
Source string `json:"source"` // "etype-info2" | "etype-info" | "pw-salt"
}
// ASProbeResult summarizes what the AS-REQ probe taught us.
type ASProbeResult struct {
Attempted bool `json:"attempted"`
Target string `json:"target,omitempty"`
Proto string `json:"proto,omitempty"`
ErrorCode int32 `json:"errorCode,omitempty"`
ErrorName string `json:"errorName,omitempty"`
ServerRealm string `json:"serverRealm,omitempty"`
ServerTime time.Time `json:"serverTime,omitempty"`
ClockSkew time.Duration `json:"clockSkew_ns,omitempty"`
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
PreauthReq bool `json:"preauthRequired"`
PKINITOffered bool `json:"pkinitOffered,omitempty"`
PrincipalFound bool `json:"principalFound,omitempty"` // KDC returned an AS-REP without preauth (rare / AS-REP roasting exposure)
Raw string `json:"raw,omitempty"` // informative: e.g. "KRB-ERROR (25) PREAUTH_REQUIRED"
Error string `json:"error,omitempty"`
}
// AuthProbeResult is filled only when credentials were supplied.
type AuthProbeResult struct {
Attempted bool `json:"attempted"`
Principal string `json:"principal,omitempty"`
TGTAcquired bool `json:"tgtAcquired"`
TGSAcquired bool `json:"tgsAcquired"`
TargetService string `json:"targetService,omitempty"`
Latency time.Duration `json:"latency_ns,omitempty"`
ErrorCode int32 `json:"errorCode,omitempty"`
ErrorName string `json:"errorName,omitempty"`
Error string `json:"error,omitempty"`
}
// KerberosData is the full observation payload.
type KerberosData struct {
Realm string `json:"realm"`
CollectedAt time.Time `json:"collectedAt"`
LocalTime time.Time `json:"localTime"`
SRV []SRVBucket `json:"srv"`
Resolution map[string]HostResolution `json:"resolution,omitempty"`
Probes []KDCProbe `json:"probes,omitempty"`
AS ASProbeResult `json:"as"`
Auth *AuthProbeResult `json:"auth,omitempty"`
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"`
// OverallOK is the rule's summary verdict; set by the rule, not the
// collector. Stored here for the HTML report which is rendered from
// the observation alone.
OverallOK bool `json:"overallOK"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}