Compare commits

...

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

20 changed files with 1365 additions and 398 deletions

22
.drone-manifest.yml Normal file
View file

@ -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

187
.drone.yml Normal file
View file

@ -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

View file

@ -6,9 +6,12 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM scratch
COPY --from=builder /checker-kerberos /checker-kerberos COPY --from=builder /checker-kerberos /checker-kerberos
USER 65534:65534
EXPOSE 8080 EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-kerberos", "-healthcheck"]
ENTRYPOINT ["/checker-kerberos"] ENTRYPOINT ["/checker-kerberos"]

View file

@ -6,12 +6,12 @@ CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean .PHONY: all plugin docker test clean
all: $(CHECKER_NAME) all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES) $(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ . go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so plugin: $(CHECKER_NAME).so
@ -21,5 +21,8 @@ $(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
docker: docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean: clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so 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 Starting from the realm name (or from the SRV records grouped under the
`abstract.Kerberos` service), the checker performs a series of `abstract.Kerberos` service), the checker performs a series of
**anonymous probes** and an optional **authenticated round-trip** when **anonymous probes**, and an optional **authenticated round-trip** when
credentials are supplied to give a complete picture of the realm's credentials are supplied, to give a complete picture of the realm's
availability and security posture. availability and security posture.
## What gets checked ## What gets checked
- SRV layout `_kerberos._tcp.`, `_kerberos._udp.`, - SRV layout, `_kerberos._tcp.`, `_kerberos._udp.`,
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`, `_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
`_kpasswd._udp.`. `_kpasswd._udp.`.
- Forward resolution of every SRV target (A + AAAA). - Forward resolution of every SRV target (A + AAAA).
@ -35,6 +35,22 @@ direct remediation hint:
| Wrong realm in reply | fix `default_realm` / realm config | | Wrong realm in reply | fix `default_realm` / realm config |
| AS-REP roasting exposure | enable `requires_preauth` | | 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 ## Build
```sh ```sh
@ -48,3 +64,15 @@ make docker # container image
```sh ```sh
./checker-kerberos -listen :8080 ./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) res.Latency = time.Since(start)
if loginErr != nil { if loginErr != nil {
res.Error = loginErr.Error() res.Error = loginErr.Error()
if code, ok := extractKRBErrorCode(loginErr); ok { if code, name, ok := krbErrorInfo(loginErr); ok {
res.ErrorCode = code res.ErrorCode = code
res.ErrorName = errorcodeName(code) res.ErrorName = name
} }
return res return res
} }
@ -63,31 +63,12 @@ func runAuthProbe(ctx context.Context, realm, principal, password, targetService
} }
if _, _, err := cl.GetServiceTicket(spn); err != nil { if _, _, err := cl.GetServiceTicket(spn); err != nil {
res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err) 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.ErrorCode = code
res.ErrorName = errorcodeName(code) res.ErrorName = name
} }
return res return res
} }
res.TGSAcquired = true res.TGSAcquired = true
return res 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,6 +2,7 @@ package checker
import ( import (
"context" "context"
"crypto/rand"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"errors" "errors"
@ -65,16 +66,27 @@ var preferredEnctypes = []int32{
etypeID.RC4_HMAC, 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 // etypeName returns a human-friendly name for an enctype ID, falling back
// to its numeric value when unknown. // to its numeric value when unknown.
func etypeName(id int32) string { func etypeName(id int32) string {
for name, nid := range etypeID.ETypesByName { if name, ok := etypeNameByID[id]; ok {
if nid == id { return name
// Prefer canonical "aes..." / "rc4-hmac" shape
if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") {
return name
}
}
} }
return fmt.Sprintf("etype-%d", id) 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)) timeout := time.Duration(timeoutSec * float64(time.Second))
now := time.Now().UTC()
data := &KerberosData{ data := &KerberosData{
Realm: realm, Realm: realm,
CollectedAt: now, CollectedAt: time.Now().UTC(),
LocalTime: now,
Resolution: map[string]HostResolution{}, 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...) eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
for _, e := range eps { for _, e := range eps {
if e.proto == "tcp" && asProbe.Target != "" {
continue
}
start := time.Now() start := time.Now()
var reply []byte var reply []byte
var perr error 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) reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
} else { } else {
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout) reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
// Track UDP reachability via this attempt.
probe := KDCProbe{ probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc", Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start), RTT: time.Since(start),
} }
if perr == nil { if perr == nil && len(reply) > 0 {
probe.OK = true probe.OK = true
probe.KrbSeen = true probe.KrbSeen = true
} else { } else if perr != nil {
probe.Error = perr.Error() probe.Error = perr.Error()
} }
data.Probes = append(data.Probes, probe) 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 { if perr != nil || len(reply) == 0 {
continue continue
} }
asProbe.Target = e.target if asProbe.Target == "" {
asProbe.Proto = e.proto asProbe.Target = e.target
parseASResponse(reply, &asProbe) asProbe.Proto = e.proto
break parseASResponse(reply, &asProbe)
}
} }
if asProbe.Target == "" && asProbe.Error == "" { if asProbe.Target == "" && asProbe.Error == "" {
asProbe.Error = "no KDC answered our AS-REQ probe" 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 ------------------------------------ // 6. Optional authenticated round-trip ------------------------------------
principal, _ := opts["principal"].(string) principal, _ := opts["principal"].(string)
password, _ := opts["password"].(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, data.Auth = runAuthProbe(ctx, realm, principal, password,
stringOpt(opts, "targetService"), kdcHosts, timeout) stringOpt(opts, "targetService"), kdcHosts, timeout)
} }
@ -371,13 +392,24 @@ func buildProbeASReq(realm string) (messages.ASReq, error) {
cfg.LibDefaults.NoAddresses = true cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes 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) 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. // parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or, // 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). // AS-REP roasting territory).
func parseASResponse(raw []byte, out *ASProbeResult) { func parseASResponse(raw []byte, out *ASProbeResult) {
// Try KRB-ERROR first. // Try KRB-ERROR first.
@ -400,7 +432,7 @@ func parseASResponse(raw []byte, out *ASProbeResult) {
return 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 var asRep messages.ASRep
if err := asRep.Unmarshal(raw); err == nil { if err := asRep.Unmarshal(raw); err == nil {
out.PrincipalFound = true out.PrincipalFound = true
@ -475,17 +507,6 @@ func hasEnctype(list []EnctypeEntry, id int32) bool {
return false 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 ---------------------------------------------------------------- // ---- helpers ----------------------------------------------------------------
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 { func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {

332
checker/collect_test.go Normal file
View file

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

View file

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

48
checker/errors.go Normal file
View file

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

View file

@ -1,92 +1,53 @@
//go:build standalone
package checker package checker
import ( import (
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
sdk "git.happydns.org/checker-sdk-go/checker" sdk "git.happydns.org/checker-sdk-go/checker"
) )
// RenderForm exposes the minimal set of fields a human needs to fire a // RenderForm exposes the run + admin options documented in Definition()
// standalone Kerberos probe via GET /check. // so the standalone form stays in sync with the host-side documentation.
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField { func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{ docs := p.Definition().Options
{ fields := make([]sdk.CheckerOptionField, 0, len(docs.RunOpts)+len(docs.AdminOpts))
Id: "realm", fields = append(fields, docs.RunOpts...)
Type: "string", fields = append(fields, docs.AdminOpts...)
Label: "Kerberos realm", return fields
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. Collect handles // ParseForm turns the submitted form into a CheckerOptions, using the
// the SRV / DNS discovery itself, so there is nothing to auto-fill here // documented field types to coerce values.
// beyond the raw inputs.
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
realm := strings.TrimSpace(r.FormValue("realm")) opts := sdk.CheckerOptions{}
if realm == "" { for _, f := range p.RenderForm() {
return nil, errors.New("realm is required") 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 return opts, nil
} }

View file

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

View file

@ -7,6 +7,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
sdk "git.happydns.org/checker-sdk-go/checker"
) )
// ── HTML report ─────────────────────────────────────────────────────────────── // ── HTML report ───────────────────────────────────────────────────────────────
@ -49,9 +51,9 @@ type srvView struct {
type reportData struct { type reportData struct {
Realm string Realm string
HasStates bool
OverallOK bool OverallOK bool
CollectedAt string CollectedAt string
LocalTime string
ServerTime string ServerTime string
ClockSkew string ClockSkew string
ClockSkewBad bool ClockSkewBad bool
@ -78,17 +80,30 @@ func fmtDur(d time.Duration) string {
return d.Round(time.Millisecond).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 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) 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{ rd := reportData{
Realm: r.Realm, Realm: r.Realm,
OverallOK: r.OverallOK, HasStates: hasStates,
OverallOK: overallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339), CollectedAt: r.CollectedAt.Format(time.RFC3339),
LocalTime: r.LocalTime.Format(time.RFC3339),
ASProbe: r.AS, ASProbe: r.AS,
ASErrorName: r.AS.ErrorName, ASErrorName: r.AS.ErrorName,
PreauthReq: r.AS.PreauthReq, PreauthReq: r.AS.PreauthReq,
@ -100,8 +115,14 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
} }
if r.AS.ClockSkew != 0 { if r.AS.ClockSkew != 0 {
rd.ClockSkew = fmtDur(r.AS.ClockSkew) 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 rd.ClockSkewBad = true
break
} }
} }
@ -156,8 +177,11 @@ func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
rd.HasMixedCrypto = true rd.HasMixedCrypto = true
} }
// Detect common failures and build the remediation banner. // Detect common failures and build the remediation banner. Hints are
rd.Remediations = buildRemediations(&r, rd) // only surfaced when the host supplied rule states for this run.
if hasStates {
rd.Remediations = buildRemediations(&r, rd)
}
var buf strings.Builder var buf strings.Builder
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil { if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
@ -257,7 +281,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 { if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
out = append(out, remediation{ out = append(out, remediation{
Title: "Enable pre-authentication", Title: "Enable pre-authentication",
@ -376,9 +400,11 @@ th { font-weight: 600; color: #6b7280; }
<div class="hd"> <div class="hd">
<h1>Kerberos Realm</h1> <h1>Kerberos Realm</h1>
{{if .OverallOK}}<span class="badge ok">Realm OK</span> {{if .HasStates}}
{{else if .Remediations}}<span class="badge fail">Issues detected</span> {{if .OverallOK}}<span class="badge ok">Realm OK</span>
{{else}}<span class="badge warn">Needs attention</span>{{end}} {{else if .Remediations}}<span class="badge fail">Issues detected</span>
{{else}}<span class="badge warn">Needs attention</span>{{end}}
{{end}}
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} &middot; probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div> <div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} &middot; probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
</div> </div>
@ -472,7 +498,6 @@ 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>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>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
<dt>Server time</dt><dd>{{.ServerTime}}</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>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> <dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
</dl> </dl>

View file

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

594
checker/rules.go Normal file
View file

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

View file

@ -100,7 +100,6 @@ type AuthProbeResult struct {
type KerberosData struct { type KerberosData struct {
Realm string `json:"realm"` Realm string `json:"realm"`
CollectedAt time.Time `json:"collectedAt"` CollectedAt time.Time `json:"collectedAt"`
LocalTime time.Time `json:"localTime"`
SRV []SRVBucket `json:"srv"` SRV []SRVBucket `json:"srv"`
Resolution map[string]HostResolution `json:"resolution,omitempty"` Resolution map[string]HostResolution `json:"resolution,omitempty"`
@ -110,11 +109,4 @@ type KerberosData struct {
Enctypes []EnctypeEntry `json:"enctypes,omitempty"` Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,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 go 1.25.0
require ( require (
git.happydns.org/checker-sdk-go v1.2.0 git.happydns.org/checker-sdk-go v1.5.0
github.com/jcmturner/gofork v1.7.6 github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jcmturner/gokrb5/v8 v8.4.4
) )

4
go.sum
View file

@ -1,5 +1,5 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= git.happydns.org/checker-sdk-go v1.5.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

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

View file

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