diff --git a/.drone-manifest.yml b/.drone-manifest.yml
new file mode 100644
index 0000000..ad844ab
--- /dev/null
+++ b/.drone-manifest.yml
@@ -0,0 +1,22 @@
+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
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..a00d44b
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,187 @@
+---
+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
diff --git a/Dockerfile b/Dockerfile
index 99555e6..82f5642 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,9 +6,12 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
-RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
+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
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD ["/checker-kerberos", "-healthcheck"]
ENTRYPOINT ["/checker-kerberos"]
diff --git a/Makefile b/Makefile
index fb74a58..1d83007 100644
--- a/Makefile
+++ b/Makefile
@@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
-.PHONY: all plugin docker clean
+.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
- go build -ldflags "$(GO_LDFLAGS)" -o $@ .
+ go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
@@ -21,5 +21,8 @@ $(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
diff --git a/README.md b/README.md
index 4c64a87..8060baa 100644
--- a/README.md
+++ b/README.md
@@ -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,6 +35,22 @@ 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
@@ -48,3 +64,15 @@ 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.
diff --git a/checker/auth.go b/checker/auth.go
index 6180926..a7dbfdf 100644
--- a/checker/auth.go
+++ b/checker/auth.go
@@ -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, ok := extractKRBErrorCode(loginErr); ok {
+ if code, name, ok := krbErrorInfo(loginErr); ok {
res.ErrorCode = code
- res.ErrorName = errorcodeName(code)
+ res.ErrorName = name
}
return res
}
@@ -63,31 +63,12 @@ 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, ok := extractKRBErrorCode(err); ok {
+ if code, name, ok := krbErrorInfo(err); ok {
res.ErrorCode = code
- res.ErrorName = errorcodeName(code)
+ res.ErrorName = name
}
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
-}
diff --git a/checker/collect.go b/checker/collect.go
index e13804b..c5da7b0 100644
--- a/checker/collect.go
+++ b/checker/collect.go
@@ -2,6 +2,7 @@ package checker
import (
"context"
+ "crypto/rand"
"encoding/binary"
"encoding/hex"
"errors"
@@ -65,16 +66,27 @@ 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 {
- 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
- }
- }
+ if name, ok := etypeNameByID[id]; ok {
+ return name
}
return fmt.Sprintf("etype-%d", id)
}
@@ -94,11 +106,9 @@ 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: now,
- LocalTime: now,
+ CollectedAt: time.Now().UTC(),
Resolution: map[string]HostResolution{},
}
@@ -214,9 +224,14 @@ 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
@@ -224,15 +239,14 @@ 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 {
+ if perr == nil && len(reply) > 0 {
probe.OK = true
probe.KrbSeen = true
- } else {
+ } else if perr != nil {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
@@ -240,10 +254,11 @@ func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions)
if perr != nil || len(reply) == 0 {
continue
}
- asProbe.Target = e.target
- asProbe.Proto = e.proto
- parseASResponse(reply, &asProbe)
- break
+ 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"
@@ -266,7 +281,13 @@ 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 != "" {
+ 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)
}
@@ -371,13 +392,24 @@ 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, "probe-happydomain")
+ 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 —
+// 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.
@@ -400,7 +432,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
@@ -475,17 +507,6 @@ 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 {
diff --git a/checker/collect_test.go b/checker/collect_test.go
new file mode 100644
index 0000000..97136c7
--- /dev/null
+++ b/checker/collect_test.go
@@ -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")
+ }
+}
diff --git a/checker/definition.go b/checker/definition.go
index 88e8455..c1fc9dd 100644
--- a/checker/definition.go
+++ b/checker/definition.go
@@ -11,7 +11,7 @@ import (
var Version = "built-in"
// Definition returns the CheckerDefinition for the Kerberos checker.
-func Definition() *sdk.CheckerDefinition {
+func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "kerberos",
Name: "Kerberos Realm Tester",
@@ -79,9 +79,7 @@ func Definition() *sdk.CheckerDefinition {
},
},
},
- Rules: []sdk.CheckRule{
- Rule(),
- },
+ Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
diff --git a/checker/errors.go b/checker/errors.go
new file mode 100644
index 0000000..ba6f67f
--- /dev/null
+++ b/checker/errors.go
@@ -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
+}
diff --git a/checker/interactive.go b/checker/interactive.go
index 504d9e5..5b3169e 100644
--- a/checker/interactive.go
+++ b/checker/interactive.go
@@ -1,92 +1,53 @@
+//go:build standalone
+
package checker
import (
"errors"
"net/http"
+ "strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
-// RenderForm exposes the minimal set of fields a human needs to fire a
-// standalone Kerberos probe via GET /check.
+// 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 {
- 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,
- },
- }
+ 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. Collect handles
-// the SRV / DNS discovery itself, so there is nothing to auto-fill here
-// beyond the raw inputs.
+// 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) {
- realm := strings.TrimSpace(r.FormValue("realm"))
- if realm == "" {
- return nil, errors.New("realm is required")
+ 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
+ }
}
-
- 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
}
diff --git a/checker/provider.go b/checker/provider.go
index 86d68bd..2cfc4ff 100644
--- a/checker/provider.go
+++ b/checker/provider.go
@@ -14,8 +14,3 @@ type kerberosProvider struct{}
func (p *kerberosProvider) Key() sdk.ObservationKey {
return ObservationKeyKerberos
}
-
-// Definition implements sdk.CheckerDefinitionProvider.
-func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
- return Definition()
-}
diff --git a/checker/report.go b/checker/report.go
index 35db28d..aaca430 100644
--- a/checker/report.go
+++ b/checker/report.go
@@ -7,6 +7,8 @@ import (
"sort"
"strings"
"time"
+
+ sdk "git.happydns.org/checker-sdk-go/checker"
)
// ── HTML report ───────────────────────────────────────────────────────────────
@@ -49,9 +51,9 @@ type srvView struct {
type reportData struct {
Realm string
+ HasStates bool
OverallOK bool
CollectedAt string
- LocalTime string
ServerTime string
ClockSkew string
ClockSkewBad bool
@@ -78,17 +80,30 @@ func fmtDur(d time.Duration) string {
return d.Round(time.Millisecond).String()
}
-func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
+func (p *kerberosProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var r KerberosData
- if err := json.Unmarshal(raw, &r); err != nil {
+ 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,
- OverallOK: r.OverallOK,
+ HasStates: hasStates,
+ OverallOK: overallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339),
- LocalTime: r.LocalTime.Format(time.RFC3339),
ASProbe: r.AS,
ASErrorName: r.AS.ErrorName,
PreauthReq: r.AS.PreauthReq,
@@ -100,8 +115,14 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
}
if r.AS.ClockSkew != 0 {
rd.ClockSkew = fmtDur(r.AS.ClockSkew)
- if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute {
+ }
+ // 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
}
}
@@ -156,8 +177,11 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
rd.HasMixedCrypto = true
}
- // Detect common failures and build the remediation banner.
- rd.Remediations = buildRemediations(&r, rd)
+ // 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 {
@@ -257,7 +281,7 @@ then rekey principals with kadmin -q "cpw -randkey principal" 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",
@@ -376,9 +400,11 @@ th { font-weight: 600; color: #6b7280; }
{{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}{{.ASErrorName}} {{if .PreauthReq}}preauth required{{end}}{{if .PrincipalFound}}AS-REP without preauth{{end}}{{.ASProbe.ServerRealm}}