Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:41:50 +07:00
commit 46862014f6
20 changed files with 2673 additions and 0 deletions

74
checker/auth.go Normal file
View file

@ -0,0 +1,74 @@
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, name, ok := krbErrorInfo(loginErr); ok {
res.ErrorCode = code
res.ErrorName = name
}
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, name, ok := krbErrorInfo(err); ok {
res.ErrorCode = code
res.ErrorName = name
}
return res
}
res.TGSAcquired = true
return res
}

537
checker/collect.go Normal file
View file

@ -0,0 +1,537 @@
package checker
import (
"context"
"crypto/rand"
"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,
}
// etypeNameByID is a deterministic id->name lookup built once from
// etypeID.ETypesByName, ignoring CMS/Env OID aliases.
var etypeNameByID = func() map[int32]string {
m := make(map[int32]string, len(etypeID.ETypesByName))
for name, id := range etypeID.ETypesByName {
if strings.Contains(name, "-CmsOID") || strings.HasSuffix(name, "-EnvOID") {
continue
}
if existing, ok := m[id]; ok && existing < name {
continue // keep lexicographically smallest for stability
}
m[id] = name
}
return m
}()
// etypeName returns a human-friendly name for an enctype ID, falling back
// to its numeric value when unknown.
func etypeName(id int32) string {
if name, ok := etypeNameByID[id]; ok {
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))
data := &KerberosData{
Realm: realm,
CollectedAt: time.Now().UTC(),
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)
}
}
}
// TCP first, stop after the first parsed reply. UDP endpoints are
// always probed (they are the only place we record UDP KDC
// reachability), even when a TCP target already answered.
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
for _, e := range eps {
if e.proto == "tcp" && asProbe.Target != "" {
continue
}
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)
probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start),
}
if perr == nil && len(reply) > 0 {
probe.OK = true
probe.KrbSeen = true
} else if perr != nil {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
}
if perr != nil || len(reply) == 0 {
continue
}
if asProbe.Target == "" {
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
}
}
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 = &AuthProbeResult{
Attempted: true,
Principal: principal,
Error: "password is required when a principal is supplied",
}
} else if principal != "" {
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, randomProbeCName())
return messages.NewASReqForTGT(realm, cfg, cname)
}
// randomProbeCName returns a probe-only principal name. The random suffix
// avoids creating a recognizable, repeating audit-log entry on the KDC and
// keeps two concurrent probes from colliding on the same cname.
func randomProbeCName() string {
var b [6]byte
if _, err := rand.Read(b[:]); err != nil {
return "probe-happydomain"
}
return "probe-happydomain-" + hex.EncodeToString(b[:])
}
// 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
}
// ---- 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
}

332
checker/collect_test.go Normal file
View file

@ -0,0 +1,332 @@
package checker
import (
"errors"
"strings"
"testing"
"time"
asn1 "github.com/jcmturner/gofork/encoding/asn1"
"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"
)
// buildKRBError constructs a marshaled KRB-ERROR with the given code and
// optional EData payload.
func buildKRBError(t *testing.T, realm string, code int32, edata []byte) []byte {
t.Helper()
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm)
k := messages.NewKRBError(sname, realm, code, "")
k.STime = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
k.Susec = 0
k.EData = edata
raw, err := k.Marshal()
if err != nil {
t.Fatalf("marshal KRBError: %v", err)
}
return raw
}
// buildETypeInfo2EData marshals a PADataSequence containing one
// PA_ETYPE_INFO2 entry per supplied (etype, salt) pair.
func buildETypeInfo2EData(t *testing.T, entries []types.ETypeInfo2Entry) []byte {
t.Helper()
value, err := asn1.Marshal(types.ETypeInfo2(entries))
if err != nil {
t.Fatalf("marshal ETypeInfo2: %v", err)
}
pas := types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: value},
{PADataType: patype.PA_PK_AS_REQ, PADataValue: []byte{0x00}},
}
raw, err := asn1.Marshal(pas)
if err != nil {
t.Fatalf("marshal PADataSequence: %v", err)
}
return raw
}
func TestParseASResponse_KRBErrorPreauthRequiredWithEData(t *testing.T) {
edata := buildETypeInfo2EData(t, []types.ETypeInfo2Entry{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "EXAMPLE.COMuser"},
{EType: etypeID.RC4_HMAC, Salt: ""},
})
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, edata)
var out ASProbeResult
parseASResponse(raw, &out)
if out.Error != "" {
t.Fatalf("unexpected parse error: %s", out.Error)
}
if out.ErrorCode != errorcode.KDC_ERR_PREAUTH_REQUIRED {
t.Errorf("ErrorCode = %d, want KDC_ERR_PREAUTH_REQUIRED", out.ErrorCode)
}
if !out.PreauthReq {
t.Error("PreauthReq should be true for KDC_ERR_PREAUTH_REQUIRED")
}
if out.ServerRealm != "EXAMPLE.COM" {
t.Errorf("ServerRealm = %q, want EXAMPLE.COM", out.ServerRealm)
}
if out.ServerTime.IsZero() {
t.Error("ServerTime should be populated from STime")
}
if !out.PKINITOffered {
t.Error("PKINITOffered should be true (PA_PK_AS_REQ present)")
}
if len(out.Enctypes) != 2 {
t.Fatalf("Enctypes len = %d, want 2", len(out.Enctypes))
}
var sawAES, sawRC4 bool
for _, e := range out.Enctypes {
switch e.ID {
case etypeID.AES256_CTS_HMAC_SHA1_96:
sawAES = true
if e.Weak {
t.Error("AES256 should not be flagged weak")
}
if e.Source != "etype-info2" {
t.Errorf("AES256 Source = %q, want etype-info2", e.Source)
}
case etypeID.RC4_HMAC:
sawRC4 = true
if !e.Weak {
t.Error("RC4_HMAC should be flagged weak")
}
}
}
if !sawAES || !sawRC4 {
t.Errorf("missing expected enctypes (sawAES=%v sawRC4=%v)", sawAES, sawRC4)
}
}
func TestParseASResponse_KRBErrorPrincipalUnknownNoEData(t *testing.T) {
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN, nil)
var out ASProbeResult
parseASResponse(raw, &out)
if out.Error != "" {
t.Fatalf("unexpected parse error: %s", out.Error)
}
if out.ErrorCode != errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN {
t.Errorf("ErrorCode = %d, want KDC_ERR_C_PRINCIPAL_UNKNOWN", out.ErrorCode)
}
if out.PreauthReq {
t.Error("PreauthReq should be false")
}
if len(out.Enctypes) != 0 {
t.Errorf("Enctypes should be empty, got %d", len(out.Enctypes))
}
}
func TestParseASResponse_GarbageBytes(t *testing.T) {
var out ASProbeResult
parseASResponse([]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, &out)
if out.Error == "" {
t.Fatal("expected an Error string for unparsable bytes")
}
if !strings.Contains(out.Error, "deadbeefcafe") {
t.Errorf("Error should include hex prefix of payload, got %q", out.Error)
}
}
func TestExtractEData_ETypeInfoFallback(t *testing.T) {
// PA_ETYPE_INFO (legacy) only. Salt is octet-string here.
value, err := asn1.Marshal(types.ETypeInfo{
{EType: etypeID.AES128_CTS_HMAC_SHA1_96, Salt: []byte("salty")},
})
if err != nil {
t.Fatalf("marshal ETypeInfo: %v", err)
}
edata, err := asn1.Marshal(types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO, PADataValue: value},
})
if err != nil {
t.Fatalf("marshal PADataSequence: %v", err)
}
enctypes, pkinit := extractEData(edata)
if pkinit {
t.Error("PKINIT should not be reported when no PA_PK_AS_REQ is present")
}
if len(enctypes) != 1 {
t.Fatalf("got %d enctypes, want 1", len(enctypes))
}
if enctypes[0].Source != "etype-info" {
t.Errorf("Source = %q, want etype-info", enctypes[0].Source)
}
if enctypes[0].Salt != "salty" {
t.Errorf("Salt = %q, want salty", enctypes[0].Salt)
}
}
func TestExtractEData_ETypeInfo2WinsOverInfo(t *testing.T) {
// Both PA_ETYPE_INFO2 and PA_ETYPE_INFO advertise the same enctype.
// The legacy info should be skipped (de-duplicated).
v2, _ := asn1.Marshal(types.ETypeInfo2{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "fromInfo2"},
})
v1, _ := asn1.Marshal(types.ETypeInfo{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: []byte("fromInfo")},
})
edata, _ := asn1.Marshal(types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: v2},
{PADataType: patype.PA_ETYPE_INFO, PADataValue: v1},
})
got, _ := extractEData(edata)
if len(got) != 1 {
t.Fatalf("got %d entries, want 1 (de-duplicated)", len(got))
}
if got[0].Salt != "fromInfo2" {
t.Errorf("salt = %q, want fromInfo2 (etype-info2 must take precedence)", got[0].Salt)
}
}
func TestExtractEData_BadASN1(t *testing.T) {
enctypes, pkinit := extractEData([]byte{0xff, 0x00})
if enctypes != nil || pkinit {
t.Errorf("expected nil/false on garbage, got %v / %v", enctypes, pkinit)
}
}
func TestEtypeName(t *testing.T) {
if got := etypeName(etypeID.AES256_CTS_HMAC_SHA1_96); !strings.Contains(strings.ToLower(got), "aes256") {
t.Errorf("AES256 name = %q, want it to mention aes256", got)
}
if got := etypeName(99999); got != "etype-99999" {
t.Errorf("unknown etype = %q, want etype-99999", got)
}
}
func TestErrorcodeNameAndKRBErrorInfo(t *testing.T) {
name := errorcodeName(errorcode.KDC_ERR_PREAUTH_REQUIRED)
if !strings.Contains(name, "PREAUTH") {
t.Errorf("errorcodeName = %q, want it to contain PREAUTH", name)
}
// Typed KRBError: errors.As path.
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/EXAMPLE.COM")
krb := messages.NewKRBError(sname, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, "")
code, n, ok := krbErrorInfo(krb)
if !ok || code != errorcode.KDC_ERR_PREAUTH_REQUIRED || !strings.Contains(n, "PREAUTH") {
t.Errorf("krbErrorInfo(typed) = %d %q %v", code, n, ok)
}
// String fallback: gokrb5 sometimes wraps the code only inside the message.
wrapped := errors.New("login failed: KRB Error: (24) KDC_ERR_PREAUTH_FAILED - bla")
code, n, ok = krbErrorInfo(wrapped)
if !ok || code != 24 {
t.Errorf("krbErrorInfo(string) code=%d ok=%v", code, ok)
}
if !strings.Contains(n, "PREAUTH_FAILED") {
t.Errorf("krbErrorInfo(string) name = %q", n)
}
if _, _, ok := krbErrorInfo(nil); ok {
t.Error("krbErrorInfo(nil) should return ok=false")
}
if _, _, ok := krbErrorInfo(errors.New("plain old error")); ok {
t.Error("krbErrorInfo on a non-KRB error should return ok=false")
}
}
func TestRoleForPrefix(t *testing.T) {
cases := map[string]string{
"_kerberos._tcp.": "kdc",
"_kerberos._udp.": "kdc",
"_kerberos-master._tcp.": "master",
"_kerberos-adm._tcp.": "kadmin",
"_kpasswd._tcp.": "kpasswd",
"_kpasswd._udp.": "kpasswd",
}
for in, want := range cases {
if got := roleForPrefix(in); got != want {
t.Errorf("roleForPrefix(%q) = %q, want %q", in, got, want)
}
}
}
func TestOptFloat(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(2.5), 2.5},
{float32(1.5), 1.5},
{int(7), 7},
{int64(8), 8},
{"3.14", 3.14},
{"nope", 42}, // falls back to default
{nil, 42}, // missing key path is exercised below
}
for _, c := range cases {
opts := map[string]any{"k": c.in}
got := optFloat(opts, "k", 42)
if got != c.want {
t.Errorf("optFloat(%v) = %v, want %v", c.in, got, c.want)
}
}
if got := optFloat(map[string]any{}, "missing", 99); got != 99 {
t.Errorf("optFloat(missing) = %v, want 99", got)
}
}
func TestOptBool(t *testing.T) {
cases := []struct {
in any
def bool
want bool
}{
{true, false, true},
{false, true, false},
{"true", false, true},
{"1", false, true},
{"false", true, false}, // unrecognized string -> default
{nil, true, true},
{42, false, false}, // unsupported type -> default
}
for _, c := range cases {
opts := map[string]any{}
if c.in != nil {
opts["k"] = c.in
}
got := optBool(opts, "k", c.def)
if got != c.want {
t.Errorf("optBool(%v, def=%v) = %v, want %v", c.in, c.def, got, c.want)
}
}
}
func TestSmallHelpers(t *testing.T) {
if got := abs(-3 * time.Second); got != 3*time.Second {
t.Errorf("abs negative = %v", got)
}
if got := abs(2 * time.Second); got != 2*time.Second {
t.Errorf("abs positive = %v", got)
}
if got := firstNonEmpty("", "", "x", "y"); got != "x" {
t.Errorf("firstNonEmpty = %q", got)
}
if got := firstNonEmpty("", ""); got != "" {
t.Errorf("firstNonEmpty(all empty) = %q", got)
}
if got := first([]byte{1, 2, 3}, 16); len(got) != 3 {
t.Errorf("first(short) len = %d, want 3", len(got))
}
if got := first([]byte{1, 2, 3, 4, 5}, 2); len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("first(long) = %v", got)
}
list := []EnctypeEntry{{ID: 18}, {ID: 17}}
if !hasEnctype(list, 17) {
t.Error("hasEnctype should find 17")
}
if hasEnctype(list, 23) {
t.Error("hasEnctype should not find 23")
}
}

89
checker/definition.go Normal file
View file

@ -0,0 +1,89 @@
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 (p *kerberosProvider) 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: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

48
checker/errors.go Normal file
View file

@ -0,0 +1,48 @@
package checker
import (
"errors"
"strconv"
"strings"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
"github.com/jcmturner/gokrb5/v8/messages"
)
// krbErrorInfo extracts a Kerberos error code (and its short name) from an
// error returned by gokrb5. Direct KRBError values are matched via
// errors.As; otherwise the error string is parsed, since gokrb5 also
// returns wrapped krberror.Krberror values that carry the code only inside
// their formatted message. ok is false when no code could be extracted.
func krbErrorInfo(err error) (code int32, name string, ok bool) {
if err == nil {
return 0, "", false
}
var krbErr messages.KRBError
if errors.As(err, &krbErr) {
return krbErr.ErrorCode, errorcodeName(krbErr.ErrorCode), true
}
msg := err.Error()
if _, after, found := strings.Cut(msg, "KRB Error: ("); found {
if c, _, found := strings.Cut(after, ")"); found {
if n, perr := strconv.Atoi(c); perr == nil {
return int32(n), errorcodeName(int32(n)), true
}
}
}
return 0, "", false
}
// errorcodeName returns the short symbolic name of a Kerberos error code
// (e.g. "KDC_ERR_PREAUTH_REQUIRED"), trimming the numeric/textual padding
// gokrb5 wraps around it.
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
}

53
checker/interactive.go Normal file
View file

@ -0,0 +1,53 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the run + admin options documented in Definition()
// so the standalone form stays in sync with the host-side documentation.
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
docs := p.Definition().Options
fields := make([]sdk.CheckerOptionField, 0, len(docs.RunOpts)+len(docs.AdminOpts))
fields = append(fields, docs.RunOpts...)
fields = append(fields, docs.AdminOpts...)
return fields
}
// ParseForm turns the submitted form into a CheckerOptions, using the
// documented field types to coerce values.
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
opts := sdk.CheckerOptions{}
for _, f := range p.RenderForm() {
raw := r.FormValue(f.Id)
if f.Type != "bool" {
raw = strings.TrimSpace(raw)
}
if raw == "" {
if f.Required {
return nil, errors.New(f.Id + " is required")
}
continue
}
switch f.Type {
case "bool":
opts[f.Id] = raw == "true" || raw == "1" || raw == "on"
case "number":
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts[f.Id] = v
} else {
opts[f.Id] = raw
}
default:
opts[f.Id] = raw
}
}
return opts, nil
}

16
checker/provider.go Normal file
View file

@ -0,0 +1,16 @@
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
}

532
checker/report.go Normal file
View file

@ -0,0 +1,532 @@
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>`),
)

594
checker/rules.go Normal file
View file

@ -0,0 +1,594 @@
package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule codes emitted by the kerberos rules. Keep these stable; UI / metrics
// may match on them.
const (
CodeSRVOK = "kerberos.srv.ok"
CodeNoSRV = "kerberos.srv.missing"
CodeKDCReachableOK = "kerberos.kdc.reachable"
CodeKDCUnreachable = "kerberos.kdc.unreachable"
CodeKDCPartial = "kerberos.kdc.partial"
CodeASProbeOK = "kerberos.as.ok"
CodeASProbeFailed = "kerberos.as.failed"
CodeASWrongRealm = "kerberos.as.wrong_realm"
CodeASRepNoPreauth = "kerberos.as.no_preauth"
CodeClockSkewOK = "kerberos.clock_skew.ok"
CodeClockSkewBad = "kerberos.clock_skew.bad"
CodeEnctypesStrong = "kerberos.enctypes.strong"
CodeEnctypesWeakOnly = "kerberos.enctypes.weak_only"
CodeEnctypesMixed = "kerberos.enctypes.mixed"
CodeEnctypesUnknown = "kerberos.enctypes.unknown"
CodeKadminDown = "kerberos.kadmin.unreachable"
CodeKadminOK = "kerberos.kadmin.ok"
CodeKpasswdDown = "kerberos.kpasswd.unreachable"
CodeKpasswdOK = "kerberos.kpasswd.ok"
CodeAuthSkipped = "kerberos.auth.skipped"
CodeAuthTGTOK = "kerberos.auth.tgt_ok"
CodeAuthTGTFail = "kerberos.auth.tgt_fail"
CodeAuthTGSOK = "kerberos.auth.tgs_ok"
CodeAuthTGSFail = "kerberos.auth.tgs_fail"
CodeAuthTGSSkipped = "kerberos.auth.tgs_skipped"
)
// Rules returns the full list of CheckRules exposed by the Kerberos checker.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&srvPresenceRule{},
&kdcReachabilityRule{},
&asProbeRule{},
&realmMatchRule{},
&preauthRule{},
&clockSkewRule{},
&enctypesRule{},
&kadminRule{},
&kpasswdRule{},
&authTGTRule{},
&authTGSRule{},
}
}
// loadData fetches the Kerberos observation. On error, returns a CheckState
// the caller should emit to short-circuit its rule.
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*KerberosData, *sdk.CheckState) {
var data KerberosData
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load Kerberos observation: %v", err),
Code: "kerberos.observation_error",
}
}
return &data, nil
}
// ── SRV presence ─────────────────────────────────────────────────────────────
type srvPresenceRule struct{}
func (r *srvPresenceRule) Name() string { return "kerberos.srv_present" }
func (r *srvPresenceRule) Description() string {
return "Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm."
}
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
for _, b := range data.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "Kerberos SRV records are published.",
Code: CodeSRVOK,
}}
}
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
Code: CodeNoSRV,
}}
}
// ── KDC reachability ─────────────────────────────────────────────────────────
type kdcReachabilityRule struct{}
func (r *kdcReachabilityRule) Name() string { return "kerberos.kdc_reachable" }
func (r *kdcReachabilityRule) Description() string {
return "Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection."
}
func (r *kdcReachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
total, reachable := 0, 0
for _, p := range data.Probes {
if p.Role != "kdc" {
continue
}
total++
if p.OK {
reachable++
}
}
if total == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "No KDC probe was attempted (no SRV target).",
Code: CodeKDCUnreachable,
}}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "No KDC is reachable on TCP 88 or UDP 88.",
Code: CodeKDCUnreachable,
Meta: map[string]any{"total": total},
}}
}
if reachable < total {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%d/%d KDC endpoints unreachable.", total-reachable, total),
Code: CodeKDCPartial,
Meta: map[string]any{"reachable": reachable, "total": total},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("All %d KDC endpoints reachable.", total),
Code: CodeKDCReachableOK,
}}
}
// ── AS-REQ probe sanity ──────────────────────────────────────────────────────
type asProbeRule struct{}
func (r *asProbeRule) Name() string { return "kerberos.as_probe" }
func (r *asProbeRule) Description() string {
return "Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP)."
}
func (r *asProbeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.AS.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "AS-REQ probe not attempted.",
Code: CodeASProbeFailed,
}}
}
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: CodeASProbeFailed,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("KDC replied to AS-REQ (%s).", firstNonEmpty(data.AS.ErrorName, "AS-REP")),
Code: CodeASProbeOK,
}}
}
// ── Realm echoed in KDC reply ────────────────────────────────────────────────
type realmMatchRule struct{}
func (r *realmMatchRule) Name() string { return "kerberos.realm_match" }
func (r *realmMatchRule) Description() string {
return "Verifies the KDC answers for the expected realm name."
}
func (r *realmMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.AS.ServerRealm == "" {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not echo a realm (probe may have failed).",
Code: CodeASWrongRealm,
}}
}
if !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: CodeASWrongRealm,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "KDC echoed the expected realm.",
Code: "kerberos.realm_match.ok",
}}
}
// ── AS-REP without preauth (AS-REP roasting exposure) ───────────────────────
type preauthRule struct{}
func (r *preauthRule) Name() string { return "kerberos.preauth_required" }
func (r *preauthRule) Description() string {
return "Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure)."
}
func (r *preauthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.AS.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "AS-REQ probe not attempted; preauth posture unknown.",
Code: CodeASRepNoPreauth,
}}
}
if data.AS.PrincipalFound && !data.AS.PreauthReq {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: "AS-REP returned without preauth (AS-REP roasting exposure).",
Code: CodeASRepNoPreauth,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "Pre-authentication is enforced (or no AS-REP issued).",
Code: "kerberos.preauth_required.ok",
}}
}
// ── Clock skew ───────────────────────────────────────────────────────────────
type clockSkewRule struct{}
func (r *clockSkewRule) Name() string { return "kerberos.clock_skew" }
func (r *clockSkewRule) Description() string {
return "Verifies the KDC clock is within tolerance of the checker's clock."
}
func (r *clockSkewRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.AS.ServerTime.IsZero() {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not return a server time (probe may have failed).",
Code: CodeClockSkewBad,
}}
}
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300) * float64(time.Second))
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: CodeClockSkewBad,
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("Clock skew within tolerance (%s).", round(data.AS.ClockSkew)),
Code: CodeClockSkewOK,
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
}}
}
// ── Enctypes offered ─────────────────────────────────────────────────────────
type enctypesRule struct{}
func (r *enctypesRule) Name() string { return "kerberos.enctypes" }
func (r *enctypesRule) Description() string {
return "Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations."
}
func (r *enctypesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Enctypes) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "KDC did not advertise enctypes (no ETYPE-INFO2 seen).",
Code: CodeEnctypesUnknown,
}}
}
hasStrong, hasWeak := false, false
var names []string
for _, e := range data.Enctypes {
names = append(names, e.Name)
if e.Weak {
hasWeak = true
} else {
hasStrong = true
}
}
requireStrong := optBool(opts, "requireStrongEnctypes", true)
if !hasStrong {
status := sdk.StatusWarn
if requireStrong {
status = sdk.StatusCrit
}
return []sdk.CheckState{{
Status: status,
Subject: data.Realm,
Message: "KDC only advertises weak enctypes (DES/RC4).",
Code: CodeEnctypesWeakOnly,
Meta: map[string]any{"enctypes": names},
}}
}
if hasWeak {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: "KDC advertises weak enctypes alongside strong ones.",
Code: CodeEnctypesMixed,
Meta: map[string]any{"enctypes": names},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "KDC advertises only strong enctypes.",
Code: CodeEnctypesStrong,
Meta: map[string]any{"enctypes": names},
}}
}
// ── kadmin reachability ──────────────────────────────────────────────────────
type kadminRule struct{}
func (r *kadminRule) Name() string { return "kerberos.kadmin_reachable" }
func (r *kadminRule) Description() string {
return "Flags kadmin endpoints that are published via SRV but not reachable."
}
func (r *kadminRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return roleReachability(data, "kadmin", "kadmin server", CodeKadminOK, CodeKadminDown)
}
// ── kpasswd reachability ─────────────────────────────────────────────────────
type kpasswdRule struct{}
func (r *kpasswdRule) Name() string { return "kerberos.kpasswd_reachable" }
func (r *kpasswdRule) Description() string {
return "Flags kpasswd endpoints that are published via SRV but not reachable."
}
func (r *kpasswdRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
return roleReachability(data, "kpasswd", "kpasswd", CodeKpasswdOK, CodeKpasswdDown)
}
func roleReachability(data *KerberosData, role, label, okCode, downCode string) []sdk.CheckState {
total, reachable := 0, 0
for _, p := range data.Probes {
if p.Role != role {
continue
}
total++
if p.OK {
reachable++
}
}
if total == 0 {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: fmt.Sprintf("No %s SRV endpoint published.", label),
Code: okCode,
}}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%s unreachable.", label),
Code: downCode,
}}
}
if reachable < total {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("%s: %d/%d endpoints unreachable.", label, total-reachable, total),
Code: downCode,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("%s reachable.", label),
Code: okCode,
}}
}
// ── Authenticated probe: TGT acquisition ─────────────────────────────────────
type authTGTRule struct{}
func (r *authTGTRule) Name() string { return "kerberos.auth_tgt" }
func (r *authTGTRule) Description() string {
return "Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied)."
}
func (r *authTGTRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Auth == nil || !data.Auth.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "Authenticated probe not attempted (no principal/password supplied).",
Code: CodeAuthSkipped,
}}
}
if !data.Auth.TGTAcquired {
msg := "Authenticated probe: TGT acquisition failed."
if data.Auth.Error != "" {
msg = "Authenticated probe: " + data.Auth.Error
}
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: msg,
Code: CodeAuthTGTFail,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: "TGT acquired for supplied principal.",
Code: CodeAuthTGTOK,
}}
}
// ── Authenticated probe: TGS request ─────────────────────────────────────────
type authTGSRule struct{}
func (r *authTGSRule) Name() string { return "kerberos.auth_tgs" }
func (r *authTGSRule) Description() string {
return "Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied)."
}
func (r *authTGSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.Auth == nil || !data.Auth.Attempted {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe not attempted (no credentials supplied).",
Code: CodeAuthTGSSkipped,
}}
}
if data.Auth.TargetService == "" {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe skipped (no targetService supplied).",
Code: CodeAuthTGSSkipped,
}}
}
if !data.Auth.TGTAcquired {
return []sdk.CheckState{{
Status: sdk.StatusUnknown,
Subject: data.Realm,
Message: "TGS probe skipped: TGT not acquired.",
Code: CodeAuthTGSSkipped,
}}
}
if !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: CodeAuthTGSFail,
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("TGS-REQ for %s succeeded.", data.Auth.TargetService),
Code: CodeAuthTGSOK,
}}
}
// ── helpers ──────────────────────────────────────────────────────────────────
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:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "1", "yes", "y", "on":
return true
case "false", "0", "no", "n", "off":
return false
}
}
return def
}
func firstNonEmpty(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}

112
checker/types.go Normal file
View file

@ -0,0 +1,112 @@
// 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"`
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"`
}