Compare commits

...

No commits in common. "master" and "v0.1.0" have entirely different histories.

20 changed files with 398 additions and 1365 deletions

View file

@ -1,22 +0,0 @@
image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}}
{{#if build.tags}}
tags:
{{#each build.tags}}
- {{this}}
{{/each}}
{{/if}}
manifests:
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64
platform:
architecture: amd64
os: linux
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64
platform:
architecture: arm64
os: linux
variant: v8
- image: happydomain/checker-kerberos:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm
platform:
architecture: arm
os: linux
variant: v7

View file

@ -1,187 +0,0 @@
---
kind: pipeline
type: docker
name: build-amd64
platform:
os: linux
arch: amd64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-kerberos
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-kerberos
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
---
kind: pipeline
type: docker
name: build-arm64
platform:
os: linux
arch: arm64
steps:
- name: checker build
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}"
CGO_ENABLED: 0
when:
event:
exclude:
- tag
- name: checker build tag
image: golang:1-alpine
commands:
- apk add --no-cache git make
- make
environment:
CHECKER_VERSION: "${DRONE_SEMVER}"
CGO_ENABLED: 0
when:
event:
- tag
- name: publish on Docker Hub
image: plugins/docker
settings:
repo: happydomain/checker-kerberos
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
exclude:
- tag
- name: publish on Docker Hub (tag)
image: plugins/docker
settings:
repo: happydomain/checker-kerberos
auto_tag: true
auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
dockerfile: Dockerfile
build_args:
- CHECKER_VERSION=${DRONE_SEMVER}
username:
from_secret: docker_username
password:
from_secret: docker_password
when:
event:
- tag
trigger:
event:
- cron
- push
- tag
---
kind: pipeline
name: docker-manifest
platform:
os: linux
arch: arm64
steps:
- name: publish on Docker Hub
image: plugins/manifest
settings:
auto_tag: true
ignore_missing: true
spec: .drone-manifest.yml
username:
from_secret: docker_username
password:
from_secret: docker_password
trigger:
branch:
exclude:
- renovate/*
event:
- cron
- push
- tag
depends_on:
- build-amd64
- build-arm64

View file

@ -6,12 +6,9 @@ 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 .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
FROM scratch
COPY --from=builder /checker-kerberos /checker-kerberos
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-kerberos", "-healthcheck"]
ENTRYPOINT ["/checker-kerberos"]

View file

@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
.PHONY: all plugin docker clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
@ -21,8 +21,5 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
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

View file

@ -4,13 +4,13 @@ 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
**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.`,
- SRV layout `_kerberos._tcp.`, `_kerberos._udp.`,
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
`_kpasswd._udp.`.
- Forward resolution of every SRV target (A + AAAA).
@ -35,22 +35,6 @@ direct remediation hint:
| Wrong realm in reply | fix `default_realm` / realm config |
| AS-REP roasting exposure | enable `requires_preauth` |
## Rules
| Code | Description | Severity |
|--------------------------------|---------------------------------------------------------------------------------------------------|---------------------|
| `kerberos.srv_present` | Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm. | CRITICAL |
| `kerberos.kdc_reachable` | Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection. | CRITICAL |
| `kerberos.as_probe` | Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP). | CRITICAL |
| `kerberos.realm_match` | Verifies the KDC answers for the expected realm name. | CRITICAL |
| `kerberos.preauth_required` | Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure). | WARNING |
| `kerberos.clock_skew` | Verifies the KDC clock is within tolerance of the checker's clock. | CRITICAL |
| `kerberos.enctypes` | Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations. | CRITICAL |
| `kerberos.kadmin_reachable` | Flags kadmin endpoints that are published via SRV but not reachable. | WARNING |
| `kerberos.kpasswd_reachable` | Flags kpasswd endpoints that are published via SRV but not reachable. | WARNING |
| `kerberos.auth_tgt` | Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied). | CRITICAL |
| `kerberos.auth_tgs` | Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied). | WARNING |
## Build
```sh
@ -64,15 +48,3 @@ make docker # container image
```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.

View file

@ -48,9 +48,9 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService
res.Latency = time.Since(start)
if loginErr != nil {
res.Error = loginErr.Error()
if code, name, ok := krbErrorInfo(loginErr); ok {
if code, ok := extractKRBErrorCode(loginErr); ok {
res.ErrorCode = code
res.ErrorName = name
res.ErrorName = errorcodeName(code)
}
return res
}
@ -63,12 +63,31 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService
}
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 {
if code, ok := extractKRBErrorCode(err); ok {
res.ErrorCode = code
res.ErrorName = name
res.ErrorName = errorcodeName(code)
}
return res
}
res.TGSAcquired = true
return res
}
// extractKRBErrorCode tries to pull a Kerberos error code out of the
// wrapped errors returned by gokrb5. It's best-effort: if the code
// can't be determined, ok is false.
func extractKRBErrorCode(err error) (int32, bool) {
if err == nil {
return 0, false
}
// gokrb5 wraps KRBError values: their Error() string begins with "KRB Error: (N)".
msg := err.Error()
if _, after, ok := strings.Cut(msg, "KRB Error: ("); ok {
if code, _, ok := strings.Cut(after, ")"); ok {
if n, err := strconv.Atoi(code); err == nil {
return int32(n), true
}
}
}
return 0, false
}

View file

@ -2,7 +2,6 @@ package checker
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
@ -66,27 +65,16 @@ var preferredEnctypes = []int32{
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
for name, nid := range etypeID.ETypesByName {
if nid == id {
// Prefer canonical "aes..." / "rc4-hmac" shape
if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") {
return name
}
}
}
return fmt.Sprintf("etype-%d", id)
}
@ -106,9 +94,11 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
}
timeout := time.Duration(timeoutSec * float64(time.Second))
now := time.Now().UTC()
data := &KerberosData{
Realm: realm,
CollectedAt: time.Now().UTC(),
CollectedAt: now,
LocalTime: now,
Resolution: map[string]HostResolution{},
}
@ -224,14 +214,9 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
}
}
}
// 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
@ -239,14 +224,15 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
} else {
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
// Track UDP reachability via this attempt.
probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start),
}
if perr == nil && len(reply) > 0 {
if perr == nil {
probe.OK = true
probe.KrbSeen = true
} else if perr != nil {
} else {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
@ -254,11 +240,10 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
if perr != nil || len(reply) == 0 {
continue
}
if asProbe.Target == "" {
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
}
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
break
}
if asProbe.Target == "" && asProbe.Error == "" {
asProbe.Error = "no KDC answered our AS-REQ probe"
@ -281,13 +266,7 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
// 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 != "" {
if principal != "" && password != "" {
data.Auth = runAuthProbe(ctx, realm, principal, password,
stringOpt(opts, "targetService"), kdcHosts, timeout)
}
@ -392,24 +371,13 @@ func buildProbeASReq(realm string) (messages.ASReq, error) {
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, randomProbeCName())
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain")
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 .
// 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.
@ -432,7 +400,7 @@ func parseASResponse(raw []byte, out *ASProbeResult) {
return
}
// Try AS-REP. If this succeeds, preauth wasn't required, surface it.
// 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
@ -507,6 +475,17 @@ func hasEnctype(list []EnctypeEntry, id int32) bool {
return false
}
func errorcodeName(code int32) string {
s := errorcode.Lookup(code)
if _, after, ok := strings.Cut(s, ") "); ok {
s = after
}
if before, _, ok := strings.Cut(s, " "); ok {
s = before
}
return s
}
// ---- helpers ----------------------------------------------------------------
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {

View file

@ -1,332 +0,0 @@
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")
}
}

View file

@ -11,7 +11,7 @@ import (
var Version = "built-in"
// Definition returns the CheckerDefinition for the Kerberos checker.
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "kerberos",
Name: "Kerberos Realm Tester",
@ -79,7 +79,9 @@ func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
},
},
},
Rules: Rules(),
Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,

View file

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

View file

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

View file

@ -14,3 +14,8 @@ type kerberosProvider struct{}
func (p *kerberosProvider) Key() sdk.ObservationKey {
return ObservationKeyKerberos
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

View file

@ -7,8 +7,6 @@ import (
"sort"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ── HTML report ───────────────────────────────────────────────────────────────
@ -51,9 +49,9 @@ type srvView struct {
type reportData struct {
Realm string
HasStates bool
OverallOK bool
CollectedAt string
LocalTime string
ServerTime string
ClockSkew string
ClockSkewBad bool
@ -80,30 +78,17 @@ func fmtDur(d time.Duration) string {
return d.Round(time.Millisecond).String()
}
func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
var r KerberosData
if err := json.Unmarshal(rctx.Data(), &r); err != nil {
if err := json.Unmarshal(raw, &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,
OverallOK: r.OverallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339),
LocalTime: r.LocalTime.Format(time.RFC3339),
ASProbe: r.AS,
ASErrorName: r.AS.ErrorName,
PreauthReq: r.AS.PreauthReq,
@ -115,14 +100,8 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error)
}
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) {
if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute {
rd.ClockSkewBad = true
break
}
}
@ -177,11 +156,8 @@ func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error)
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)
}
// Detect common failures and build the remediation banner.
rd.Remediations = buildRemediations(&r, rd)
var buf strings.Builder
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
@ -281,7 +257,7 @@ then rekey principals with <code>kadmin -q "cpw -randkey principal"</code> or eq
})
}
// AS-REP without preauth, AS-REP roasting.
// 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",
@ -400,11 +376,9 @@ th { font-weight: 600; color: #6b7280; }
<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}}
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
{{else}}<span class="badge warn">Needs attention</span>{{end}}
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} &middot; probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
</div>
@ -498,6 +472,7 @@ th { font-weight: 600; color: #6b7280; }
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
<dt>Local time</dt><dd>{{.LocalTime}}</dd>
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
</dl>

224
checker/rule.go Normal file
View file

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

View file

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

View file

@ -100,6 +100,7 @@ type AuthProbeResult struct {
type KerberosData struct {
Realm string `json:"realm"`
CollectedAt time.Time `json:"collectedAt"`
LocalTime time.Time `json:"localTime"`
SRV []SRVBucket `json:"srv"`
Resolution map[string]HostResolution `json:"resolution,omitempty"`
@ -109,4 +110,11 @@ type KerberosData struct {
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"`
// OverallOK is the rule's summary verdict; set by the rule, not the
// collector. Stored here for the HTML report which is rendered from
// the observation alone.
OverallOK bool `json:"overallOK"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}

2
go.mod
View file

@ -3,7 +3,7 @@ module git.happydns.org/checker-kerberos
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-sdk-go v1.2.0
github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4
)

4
go.sum
View file

@ -1,5 +1,5 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.2.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=

View file

@ -5,7 +5,7 @@ import (
"log"
kerberos "git.happydns.org/checker-kerberos/checker"
"git.happydns.org/checker-sdk-go/checker/server"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the standalone binary's version. It defaults to "custom-build"
@ -21,8 +21,8 @@ func main() {
kerberos.Version = Version
srv := server.New(kerberos.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
server := sdk.NewServer(kerberos.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

View file

@ -13,6 +13,5 @@ var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
kerberos.Version = Version
prvd := kerberos.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
return kerberos.Definition(), kerberos.Provider(), nil
}