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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-kerberos
checker-kerberos.so

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
FROM scratch
COPY --from=builder /checker-kerberos /checker-kerberos
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/checker-kerberos"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-kerberos
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

25
NOTICE Normal file
View file

@ -0,0 +1,25 @@
checker-kerberos
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software from the gokrb5 project
(https://github.com/jcmturner/gokrb5), licensed under the Apache License,
Version 2.0:
gokrb5
Copyright 2017 Jonathan Turner
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

62
README.md Normal file
View file

@ -0,0 +1,62 @@
# checker-kerberos
happyDomain checker that audits a Kerberos realm from its DNS records.
Starting from the realm name (or from the SRV records grouped under the
`abstract.Kerberos` service), the checker performs a series of
**anonymous probes**, and an optional **authenticated round-trip** when
credentials are supplied, to give a complete picture of the realm's
availability and security posture.
## What gets checked
- SRV layout, `_kerberos._tcp.`, `_kerberos._udp.`,
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
`_kpasswd._udp.`.
- Forward resolution of every SRV target (A + AAAA).
- TCP reachability of each KDC/kadmin/kpasswd host.
- UDP reachability of the KDC via a real AS-REQ.
- Anonymous AS-REQ probe: realm confirmation, supported enctypes
(from `ETYPE-INFO2`), PKINIT hint (`PA-PK-AS-REQ`), clock skew.
- Weak enctype detection (DES / RC4).
- Optional authenticated round-trip when `principal` and `password`
are supplied: TGT acquisition then TGS-REQ for `targetService`.
The HTML report surfaces the most common misconfigurations with a
direct remediation hint:
| Failure | Hint |
| --- | --- |
| No SRV records | publish `_kerberos._tcp.REALM. SRV …` |
| SRV target DNS failure | add A/AAAA for the target |
| Port 88 unreachable | open TCP+UDP 88 inbound |
| Clock skew > max | run ntpd/chrony |
| Weak enctypes only | switch to `aes256-cts-hmac-sha1-96` |
| Wrong realm in reply | fix `default_realm` / realm config |
| AS-REP roasting exposure | enable `requires_preauth` |
## Build
```sh
make # standalone binary
make plugin # shared object for happyDomain
make docker # container image
```
## Run
```sh
./checker-kerberos -listen :8080
```
## Deployment
The HTTP listener has no built-in authentication or rate-limiting, and
will issue DNS queries and Kerberos AS-REQ / TGS-REQ exchanges against
whatever realm and KDCs the caller asks for. When a `principal` and
`password` are supplied, those credentials are forwarded to the target
KDC over the network as part of an authenticated round-trip. It is
meant to run on a trusted network, reachable only by the happyDomain
instance that drives it. Restrict access via a reverse proxy with
authentication, a network ACL, or by binding the listener to a private
interface; do not expose it directly to the public internet.

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"`
}

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module git.happydns.org/checker-kerberos
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.3.0
github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4
)
require (
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
)

69
go.sum Normal file
View file

@ -0,0 +1,69 @@
git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A=
git.happydns.org/checker-sdk-go v1.3.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
kerberos "git.happydns.org/checker-kerberos/checker"
"git.happydns.org/checker-sdk-go/checker/server"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
kerberos.Version = Version
srv := server.New(kerberos.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

18
plugin/plugin.go Normal file
View file

@ -0,0 +1,18 @@
// Command plugin is the happyDomain plugin entrypoint for the Kerberos checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
kerberos "git.happydns.org/checker-kerberos/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
kerberos.Version = Version
prvd := kerberos.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}