diff --git a/.drone-manifest.yml b/.drone-manifest.yml
deleted file mode 100644
index ad844ab..0000000
--- a/.drone-manifest.yml
+++ /dev/null
@@ -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
diff --git a/.drone.yml b/.drone.yml
deleted file mode 100644
index a00d44b..0000000
--- a/.drone.yml
+++ /dev/null
@@ -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
diff --git a/Dockerfile b/Dockerfile
index 82f5642..99555e6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/Makefile b/Makefile
index 1d83007..fb74a58 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 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
diff --git a/README.md b/README.md
index 8060baa..4c64a87 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,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.
diff --git a/checker/auth.go b/checker/auth.go
index a7dbfdf..6180926 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, 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
+}
diff --git a/checker/collect.go b/checker/collect.go
index c5da7b0..e13804b 100644
--- a/checker/collect.go
+++ b/checker/collect.go
@@ -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 {
diff --git a/checker/collect_test.go b/checker/collect_test.go
deleted file mode 100644
index 97136c7..0000000
--- a/checker/collect_test.go
+++ /dev/null
@@ -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")
- }
-}
diff --git a/checker/definition.go b/checker/definition.go
index c1fc9dd..88e8455 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 (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,
diff --git a/checker/errors.go b/checker/errors.go
deleted file mode 100644
index ba6f67f..0000000
--- a/checker/errors.go
+++ /dev/null
@@ -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
-}
diff --git a/checker/interactive.go b/checker/interactive.go
index 5b3169e..504d9e5 100644
--- a/checker/interactive.go
+++ b/checker/interactive.go
@@ -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
}
diff --git a/checker/provider.go b/checker/provider.go
index 2cfc4ff..86d68bd 100644
--- a/checker/provider.go
+++ b/checker/provider.go
@@ -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()
+}
diff --git a/checker/report.go b/checker/report.go
index aaca430..35db28d 100644
--- a/checker/report.go
+++ b/checker/report.go
@@ -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 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",
@@ -400,11 +376,9 @@ 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}}