From 40a4cf285eb6ca7448532dbdca51e52f86055a23 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 21 Apr 2026 22:41:50 +0700 Subject: [PATCH] Initial commit --- .gitignore | 2 + Dockerfile | 14 ++ LICENSE | 21 ++ Makefile | 25 ++ NOTICE | 25 ++ README.md | 50 ++++ checker/auth.go | 93 ++++++++ checker/collect.go | 516 +++++++++++++++++++++++++++++++++++++++++ checker/definition.go | 91 ++++++++ checker/interactive.go | 92 ++++++++ checker/provider.go | 21 ++ checker/report.go | 507 ++++++++++++++++++++++++++++++++++++++++ checker/rule.go | 224 ++++++++++++++++++ checker/types.go | 120 ++++++++++ go.mod | 18 ++ go.sum | 69 ++++++ main.go | 28 +++ plugin/plugin.go | 17 ++ 18 files changed, 1933 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 README.md create mode 100644 checker/auth.go create mode 100644 checker/collect.go create mode 100644 checker/definition.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rule.go create mode 100644 checker/types.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 plugin/plugin.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76321b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-kerberos +checker-kerberos.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99555e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos . + +FROM scratch +COPY --from=builder /checker-kerberos /checker-kerberos +EXPOSE 8080 +ENTRYPOINT ["/checker-kerberos"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The happyDomain Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb74a58 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +CHECKER_NAME := checker-kerberos +CHECKER_IMAGE := happydomain/$(CHECKER_NAME) +CHECKER_VERSION ?= custom-build + +CHECKER_SOURCES := main.go $(wildcard checker/*.go) + +GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) + +.PHONY: all plugin docker clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..f43d39d --- /dev/null +++ b/NOTICE @@ -0,0 +1,25 @@ +checker-kerberos +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + +This product includes software from the gokrb5 project +(https://github.com/jcmturner/gokrb5), licensed under the Apache License, +Version 2.0: + + gokrb5 + Copyright 2017 Jonathan Turner + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c64a87 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# checker-kerberos + +happyDomain checker that audits a Kerberos realm from its DNS records. + +Starting from the realm name (or from the SRV records grouped under the +`abstract.Kerberos` service), the checker performs a series of +**anonymous probes** — and an optional **authenticated round-trip** when +credentials are supplied — to give a complete picture of the realm's +availability and security posture. + +## What gets checked + +- SRV layout — `_kerberos._tcp.`, `_kerberos._udp.`, + `_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`, + `_kpasswd._udp.`. +- Forward resolution of every SRV target (A + AAAA). +- TCP reachability of each KDC/kadmin/kpasswd host. +- UDP reachability of the KDC via a real AS-REQ. +- Anonymous AS-REQ probe: realm confirmation, supported enctypes + (from `ETYPE-INFO2`), PKINIT hint (`PA-PK-AS-REQ`), clock skew. +- Weak enctype detection (DES / RC4). +- Optional authenticated round-trip when `principal` and `password` + are supplied: TGT acquisition then TGS-REQ for `targetService`. + +The HTML report surfaces the most common misconfigurations with a +direct remediation hint: + +| Failure | Hint | +| --- | --- | +| No SRV records | publish `_kerberos._tcp.REALM. SRV …` | +| SRV target DNS failure | add A/AAAA for the target | +| Port 88 unreachable | open TCP+UDP 88 inbound | +| Clock skew > max | run ntpd/chrony | +| Weak enctypes only | switch to `aes256-cts-hmac-sha1-96` | +| Wrong realm in reply | fix `default_realm` / realm config | +| AS-REP roasting exposure | enable `requires_preauth` | + +## Build + +```sh +make # standalone binary +make plugin # shared object for happyDomain +make docker # container image +``` + +## Run + +```sh +./checker-kerberos -listen :8080 +``` diff --git a/checker/auth.go b/checker/auth.go new file mode 100644 index 0000000..6180926 --- /dev/null +++ b/checker/auth.go @@ -0,0 +1,93 @@ +package checker + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jcmturner/gokrb5/v8/client" + "github.com/jcmturner/gokrb5/v8/config" +) + +// runAuthProbe performs a Login (AS-REQ + preauth) and, if a targetService +// is supplied, a TGS-REQ. kdcHosts maps reachable KDC hostnames to their +// TCP port so we can populate the krb5 config without doing DNS again. +func runAuthProbe(ctx context.Context, realm, principal, password, targetService string, + kdcHosts map[string]uint16, timeout time.Duration) *AuthProbeResult { + res := &AuthProbeResult{Attempted: true, Principal: principal, TargetService: targetService} + + username := principal + if i := strings.LastIndex(principal, "@"); i >= 0 { + username = principal[:i] + } + + cfg := config.New() + cfg.LibDefaults.DefaultRealm = realm + cfg.LibDefaults.NoAddresses = true + cfg.LibDefaults.TicketLifetime = 10 * time.Minute + cfg.LibDefaults.Clockskew = 5 * time.Minute + cfg.LibDefaults.UDPPreferenceLimit = 1 // force TCP + cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes + cfg.LibDefaults.DefaultTGSEnctypeIDs = preferredEnctypes + cfg.LibDefaults.PermittedEnctypeIDs = preferredEnctypes + + realmCfg := config.Realm{Realm: realm} + for host, port := range kdcHosts { + realmCfg.KDC = append(realmCfg.KDC, host+":"+strconv.Itoa(int(port))) + } + cfg.Realms = []config.Realm{realmCfg} + cfg.DomainRealm = config.DomainRealm{strings.ToLower(realm): realm} + + start := time.Now() + cl := client.NewWithPassword(username, realm, password, cfg, + client.DisablePAFXFAST(true)) + + loginErr := cl.Login() + res.Latency = time.Since(start) + if loginErr != nil { + res.Error = loginErr.Error() + if code, ok := extractKRBErrorCode(loginErr); ok { + res.ErrorCode = code + res.ErrorName = errorcodeName(code) + } + return res + } + res.TGTAcquired = true + + spn := targetService + if spn == "" { + // Self-test: request a fresh TGT (krbtgt/REALM@REALM). + spn = fmt.Sprintf("krbtgt/%s", realm) + } + if _, _, err := cl.GetServiceTicket(spn); err != nil { + res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err) + if code, ok := extractKRBErrorCode(err); ok { + res.ErrorCode = code + 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 new file mode 100644 index 0000000..e13804b --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,516 @@ +package checker + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + asn1 "github.com/jcmturner/gofork/encoding/asn1" + "github.com/jcmturner/gokrb5/v8/config" + "github.com/jcmturner/gokrb5/v8/iana/errorcode" + "github.com/jcmturner/gokrb5/v8/iana/etypeID" + "github.com/jcmturner/gokrb5/v8/iana/nametype" + "github.com/jcmturner/gokrb5/v8/iana/patype" + "github.com/jcmturner/gokrb5/v8/messages" + "github.com/jcmturner/gokrb5/v8/types" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// srvPrefixes lists every SRV label we probe, in order. The first two are +// considered mandatory (no realm is usable without one of them). +var srvPrefixes = []struct { + prefix string + role string + proto string +}{ + {"_kerberos._tcp.", "kdc", "tcp"}, + {"_kerberos._udp.", "kdc", "udp"}, + {"_kerberos-master._tcp.", "master", "tcp"}, + {"_kerberos-adm._tcp.", "kadmin", "tcp"}, + {"_kpasswd._tcp.", "kpasswd", "tcp"}, + {"_kpasswd._udp.", "kpasswd", "udp"}, +} + +// weakEnctypes lists the IDs considered weak per RFC 8429 + common guidance. +var weakEnctypes = map[int32]bool{ + etypeID.DES_CBC_CRC: true, + etypeID.DES_CBC_MD4: true, + etypeID.DES_CBC_MD5: true, + etypeID.DES_CBC_RAW: true, + etypeID.DES3_CBC_MD5: true, + etypeID.DES3_CBC_RAW: true, + etypeID.DES3_CBC_SHA1: true, + etypeID.DES_HMAC_SHA1: true, + etypeID.RC4_HMAC: true, + etypeID.RC4_HMAC_EXP: true, +} + +// preferredEnctypes is the enctype preference list used in anonymous probes +// and authenticated requests. RC4 is included last so the KDC's ETYPE-INFO2 +// reveals whether it still supports it. +var preferredEnctypes = []int32{ + etypeID.AES256_CTS_HMAC_SHA1_96, + etypeID.AES128_CTS_HMAC_SHA1_96, + etypeID.AES256_CTS_HMAC_SHA384_192, + etypeID.AES128_CTS_HMAC_SHA256_128, + etypeID.RC4_HMAC, +} + +// etypeName returns a human-friendly name for an enctype ID, falling back +// to its numeric value when unknown. +func etypeName(id int32) string { + for name, nid := range etypeID.ETypesByName { + if nid == id { + // Prefer canonical "aes..." / "rc4-hmac" shape + if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") { + return name + } + } + } + return fmt.Sprintf("etype-%d", id) +} + +func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + realmRaw, _ := opts["realm"].(string) + realmRaw = strings.TrimSuffix(realmRaw, ".") + if realmRaw == "" { + return nil, fmt.Errorf("realm is required") + } + realm := strings.ToUpper(realmRaw) + domain := strings.ToLower(realmRaw) + + timeoutSec := optFloat(opts, "timeout", 5) + if timeoutSec <= 0 { + timeoutSec = 5 + } + timeout := time.Duration(timeoutSec * float64(time.Second)) + + now := time.Now().UTC() + data := &KerberosData{ + Realm: realm, + CollectedAt: now, + LocalTime: now, + Resolution: map[string]HostResolution{}, + } + + // 1. SRV discovery --------------------------------------------------------- + resolver := &net.Resolver{} + data.SRV = make([]SRVBucket, len(srvPrefixes)) + var wg sync.WaitGroup + for i, sp := range srvPrefixes { + wg.Go(func() { + bucket := SRVBucket{Prefix: sp.prefix, LookupName: sp.prefix + domain + "."} + lctx, cancel := context.WithTimeout(ctx, timeout) + _, addrs, err := resolver.LookupSRV(lctx, "", "", sp.prefix+domain) + cancel() + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + bucket.NXDomain = true + } else { + bucket.Error = err.Error() + } + } + for _, srv := range addrs { + target := strings.TrimSuffix(srv.Target, ".") + bucket.Records = append(bucket.Records, SRVRecord{ + Target: target, + Port: srv.Port, + Priority: srv.Priority, + Weight: srv.Weight, + }) + } + data.SRV[i] = bucket + }) + } + wg.Wait() + + // 2. Forward resolution ---------------------------------------------------- + var targets []string + seen := map[string]bool{} + for _, b := range data.SRV { + for _, r := range b.Records { + if !seen[r.Target] { + seen[r.Target] = true + targets = append(targets, r.Target) + } + } + } + resolutions := make([]HostResolution, len(targets)) + for i, target := range targets { + wg.Go(func() { + hr := HostResolution{Target: target} + lctx, cancel := context.WithTimeout(ctx, timeout) + ips, err := resolver.LookupIPAddr(lctx, target) + cancel() + if err != nil { + hr.Error = err.Error() + } + for _, ip := range ips { + if ip.IP.To4() != nil { + hr.IPv4 = append(hr.IPv4, ip.IP.String()) + } else { + hr.IPv6 = append(hr.IPv6, ip.IP.String()) + } + } + resolutions[i] = hr + }) + } + wg.Wait() + for _, hr := range resolutions { + data.Resolution[hr.Target] = hr + } + + // 3. L4 probes ------------------------------------------------------------- + kdcHosts := map[string]uint16{} // target -> tcp port, used to pick the AS-REQ destination + for _, b := range data.SRV { + role := roleForPrefix(b.Prefix) + if strings.HasSuffix(b.Prefix, "._tcp.") { + for _, r := range b.Records { + probe := dialTCP(ctx, r.Target, r.Port, role, timeout) + data.Probes = append(data.Probes, probe) + if role == "kdc" && probe.OK { + if _, ok := kdcHosts[r.Target]; !ok { + kdcHosts[r.Target] = r.Port + } + } + } + } + } + + // 4. AS-REQ probe (tries each reachable KDC TCP first, then UDP fallbacks). + asProbe := ASProbeResult{Attempted: true} + asReq, err := buildProbeASReq(realm) + if err != nil { + asProbe.Error = fmt.Sprintf("build AS-REQ: %v", err) + } else { + req, _ := asReq.Marshal() + + // Target list: reachable TCP KDCs, then UDP KDCs if nothing else. + var tcpEps, udpEps []endpoint + for _, b := range data.SRV { + if !strings.HasPrefix(b.Prefix, "_kerberos.") { + continue + } + proto := "tcp" + if strings.HasSuffix(b.Prefix, "._udp.") { + proto = "udp" + } + for _, r := range b.Records { + e := endpoint{r.Target, r.Port, proto} + if proto == "tcp" { + tcpEps = append(tcpEps, e) + } else { + udpEps = append(udpEps, e) + } + } + } + eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...) + + for _, e := range eps { + start := time.Now() + var reply []byte + var perr error + if e.proto == "tcp" { + reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout) + } else { + reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout) + // 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 { + probe.OK = true + probe.KrbSeen = true + } else { + probe.Error = perr.Error() + } + data.Probes = append(data.Probes, probe) + } + if perr != nil || len(reply) == 0 { + continue + } + 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" + } + + if !asProbe.ServerTime.IsZero() { + asProbe.ClockSkew = time.Since(asProbe.ServerTime) + } + } + data.AS = asProbe + + // 5. Roll up enctypes ------------------------------------------------------ + for _, e := range asProbe.Enctypes { + if e.Weak { + data.WeakEnctypes = append(data.WeakEnctypes, e) + } + } + data.Enctypes = asProbe.Enctypes + + // 6. Optional authenticated round-trip ------------------------------------ + principal, _ := opts["principal"].(string) + password, _ := opts["password"].(string) + if principal != "" && password != "" { + data.Auth = runAuthProbe(ctx, realm, principal, password, + stringOpt(opts, "targetService"), kdcHosts, timeout) + } + + return data, nil +} + +func roleForPrefix(prefix string) string { + switch { + case strings.HasPrefix(prefix, "_kerberos-master."): + return "master" + case strings.HasPrefix(prefix, "_kerberos-adm."): + return "kadmin" + case strings.HasPrefix(prefix, "_kpasswd."): + return "kpasswd" + default: + return "kdc" + } +} + +type endpoint struct { + target string + port uint16 + proto string +} + +func dialTCP(ctx context.Context, target string, port uint16, role string, timeout time.Duration) KDCProbe { + probe := KDCProbe{Target: target, Port: port, Proto: "tcp", Role: role} + addr := net.JoinHostPort(target, strconv.Itoa(int(port))) + start := time.Now() + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", addr) + probe.RTT = time.Since(start) + if err != nil { + probe.Error = err.Error() + return probe + } + _ = conn.Close() + probe.OK = true + return probe +} + +// sendASReqTCP frames the AS-REQ with its 4-byte length prefix (RFC 4120 §7.2.2) +// and reads the response the same way. +func sendASReqTCP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) { + addr := net.JoinHostPort(target, strconv.Itoa(int(port))) + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + hdr := make([]byte, 4) + binary.BigEndian.PutUint32(hdr, uint32(len(body))) + if _, err := conn.Write(append(hdr, body...)); err != nil { + return nil, err + } + + lenBuf := make([]byte, 4) + if _, err := io.ReadFull(conn, lenBuf); err != nil { + return nil, err + } + n := binary.BigEndian.Uint32(lenBuf) + if n == 0 || n > 1<<20 { + return nil, fmt.Errorf("suspicious reply length %d", n) + } + buf := make([]byte, n) + if _, err := io.ReadFull(conn, buf); err != nil { + return nil, err + } + return buf, nil +} + +func sendASReqUDP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) { + addr := net.JoinHostPort(target, strconv.Itoa(int(port))) + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "udp", addr) + if err != nil { + return nil, err + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.Write(body); err != nil { + return nil, err + } + buf := make([]byte, 4096) + n, err := conn.Read(buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +// buildProbeASReq builds an AS-REQ for krbtgt/REALM@REALM with a throwaway +// cname. We don't want to depend on reading a local krb5.conf, so we craft +// the request fields by hand from a default config. +func buildProbeASReq(realm string) (messages.ASReq, error) { + cfg := config.New() + cfg.LibDefaults.DefaultRealm = realm + cfg.LibDefaults.NoAddresses = true + cfg.LibDefaults.TicketLifetime = 10 * time.Minute + cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes + cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain") + return messages.NewASReqForTGT(realm, cfg, cname) +} + +// parseASResponse inspects the raw KDC reply and fills the ASProbeResult. +// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or, +// less commonly, an AS-REP (principal exists and doesn't require preauth — +// AS-REP roasting territory). +func parseASResponse(raw []byte, out *ASProbeResult) { + // Try KRB-ERROR first. + var krbErr messages.KRBError + if err := krbErr.Unmarshal(raw); err == nil { + out.ErrorCode = krbErr.ErrorCode + out.ErrorName = errorcodeName(krbErr.ErrorCode) + out.ServerRealm = krbErr.Realm + if !krbErr.STime.IsZero() { + out.ServerTime = krbErr.STime.UTC() + } + out.PreauthReq = krbErr.ErrorCode == errorcode.KDC_ERR_PREAUTH_REQUIRED + out.Raw = fmt.Sprintf("KRB-ERROR %s", out.ErrorName) + + if len(krbErr.EData) > 0 { + enctypes, pkinit := extractEData(krbErr.EData) + out.Enctypes = enctypes + out.PKINITOffered = pkinit + } + return + } + + // Try AS-REP. If this succeeds, preauth wasn't required — surface it. + var asRep messages.ASRep + if err := asRep.Unmarshal(raw); err == nil { + out.PrincipalFound = true + out.ServerRealm = asRep.CRealm + out.Raw = "AS-REP received without preauth (AS-REP roasting exposure)" + return + } + + out.Error = "unable to parse KDC reply (" + hex.EncodeToString(first(raw, 16)) + ")" +} + +func first(b []byte, n int) []byte { + if len(b) < n { + return b + } + return b[:n] +} + +// extractEData parses the METHOD-DATA / PADataSequence that KDCs attach to +// PREAUTH_REQUIRED errors and returns the advertised enctypes + pkinit hint. +func extractEData(edata []byte) ([]EnctypeEntry, bool) { + var pas types.PADataSequence + if _, err := asn1.Unmarshal(edata, &pas); err != nil { + return nil, false + } + var out []EnctypeEntry + pkinit := false + for _, pa := range pas { + switch pa.PADataType { + case patype.PA_ETYPE_INFO2: + var info types.ETypeInfo2 + if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil { + for _, e := range info { + out = append(out, EnctypeEntry{ + ID: e.EType, + Name: etypeName(e.EType), + Weak: weakEnctypes[e.EType], + Salt: e.Salt, + Source: "etype-info2", + }) + } + } + case patype.PA_ETYPE_INFO: + var info types.ETypeInfo + if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil { + for _, e := range info { + if hasEnctype(out, e.EType) { + continue + } + out = append(out, EnctypeEntry{ + ID: e.EType, + Name: etypeName(e.EType), + Weak: weakEnctypes[e.EType], + Salt: string(e.Salt), + Source: "etype-info", + }) + } + } + case patype.PA_PK_AS_REQ, patype.PA_PK_AS_REQ_OLD: + pkinit = true + } + } + return out, pkinit +} + +func hasEnctype(list []EnctypeEntry, id int32) bool { + for _, e := range list { + if e.ID == id { + return true + } + } + return false +} + +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 { + v, ok := opts[key] + if !ok { + return def + } + switch x := v.(type) { + case float64: + return x + case float32: + return float64(x) + case int: + return float64(x) + case int64: + return float64(x) + case string: + if f, err := strconv.ParseFloat(x, 64); err == nil { + return f + } + } + return def +} + +func stringOpt(opts sdk.CheckerOptions, key string) string { + s, _ := opts[key].(string) + return s +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..88e8455 --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,91 @@ +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the checker version reported in CheckerDefinition.Version. +// Overridden at link time by the binary/plugin entrypoints. +var Version = "built-in" + +// Definition returns the CheckerDefinition for the Kerberos checker. +func Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "kerberos", + Name: "Kerberos Realm Tester", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.Kerberos"}, + }, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeyKerberos}, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "realm", + Type: "string", + Label: "Kerberos realm", + Placeholder: "EXAMPLE.COM", + AutoFill: sdk.AutoFillDomainName, + Required: true, + Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).", + }, + { + Id: "principal", + Type: "string", + Label: "Principal (optional)", + Placeholder: "user@EXAMPLE.COM", + Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.", + }, + { + Id: "password", + Type: "string", + Label: "Password (optional)", + Secret: true, + Description: "Password for the principal above. Used once per run; never stored by the checker.", + }, + { + Id: "targetService", + Type: "string", + Label: "Service to request (TGS)", + Placeholder: "host/host.example.com", + Default: "", + Description: "SPN requested via TGS-REQ once a TGT is acquired. Defaults to krbtgt (realm self-test).", + }, + }, + AdminOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "timeout", + Type: "number", + Label: "Per-probe timeout (seconds)", + Default: 5, + }, + { + Id: "requireStrongEnctypes", + Type: "bool", + Label: "Require strong enctypes", + Default: true, + Description: "Flag realms that only advertise DES/RC4 as CRIT.", + }, + { + Id: "maxClockSkew", + Type: "number", + Label: "Max tolerated clock skew (seconds)", + Default: 300, + Description: "Default Kerberos tolerance is 300s; tighter values surface drift earlier.", + }, + }, + }, + Rules: []sdk.CheckRule{ + Rule(), + }, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 24 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..504d9e5 --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,92 @@ +package checker + +import ( + "errors" + "net/http" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes the minimal set of fields a human needs to fire a +// standalone Kerberos probe via GET /check. +func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "realm", + Type: "string", + Label: "Kerberos realm", + Placeholder: "EXAMPLE.COM", + Required: true, + Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).", + }, + { + Id: "principal", + Type: "string", + Label: "Principal (optional)", + Placeholder: "user@EXAMPLE.COM", + Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.", + }, + { + Id: "password", + Type: "string", + Label: "Password (optional)", + Secret: true, + }, + { + Id: "targetService", + Type: "string", + Label: "Service to request (TGS)", + Placeholder: "host/host.example.com", + }, + { + Id: "timeout", + Type: "number", + Label: "Per-probe timeout (seconds)", + Default: 5, + }, + { + Id: "requireStrongEnctypes", + Type: "bool", + Label: "Require strong enctypes", + Default: true, + }, + { + Id: "maxClockSkew", + Type: "number", + Label: "Max tolerated clock skew (seconds)", + Default: 300, + }, + } +} + +// 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) { + 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 new file mode 100644 index 0000000..86d68bd --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,21 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns a new Kerberos observation provider. +func Provider() sdk.ObservationProvider { + return &kerberosProvider{} +} + +type kerberosProvider struct{} + +func (p *kerberosProvider) Key() sdk.ObservationKey { + return ObservationKeyKerberos +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *kerberosProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..35db28d --- /dev/null +++ b/checker/report.go @@ -0,0 +1,507 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + "time" +) + +// ── HTML report ─────────────────────────────────────────────────────────────── + +type remediation struct { + Title string + Body template.HTML +} + +type probeRow struct { + Target string + Port uint16 + Proto string + Role string + OK bool + RTT string + Error string + KrbSeen bool +} + +type resolvedHost struct { + Target string + IPv4 []string + IPv6 []string + Error string +} + +type enctypeChip struct { + Name string + Weak bool +} + +type srvView struct { + Prefix string + Lookup string + Records []SRVRecord + NXDomain bool + Error string +} + +type reportData struct { + Realm string + OverallOK bool + CollectedAt string + LocalTime string + ServerTime string + ClockSkew string + ClockSkewBad bool + SRVBuckets []srvView + Resolution []resolvedHost + Probes []probeRow + ASProbe ASProbeResult + ASErrorName string + PreauthReq bool + PKINITOffered bool + PrincipalFound bool + Enctypes []enctypeChip + HasWeakOnly bool + HasMixedCrypto bool + HasEnctypes bool + AuthProbe *AuthProbeResult + Remediations []remediation +} + +func fmtDur(d time.Duration) string { + if d == 0 { + return "-" + } + return d.Round(time.Millisecond).String() +} + +func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) { + var r KerberosData + if err := json.Unmarshal(raw, &r); err != nil { + return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err) + } + + rd := reportData{ + Realm: r.Realm, + 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, + AuthProbe: r.Auth, + } + + if !r.AS.ServerTime.IsZero() { + rd.ServerTime = r.AS.ServerTime.Format(time.RFC3339) + } + if r.AS.ClockSkew != 0 { + rd.ClockSkew = fmtDur(r.AS.ClockSkew) + if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute { + rd.ClockSkewBad = true + } + } + + rd.PKINITOffered = r.AS.PKINITOffered + rd.PrincipalFound = r.AS.PrincipalFound + + for _, b := range r.SRV { + rd.SRVBuckets = append(rd.SRVBuckets, srvView{ + Prefix: b.Prefix, + Lookup: b.LookupName, + Records: b.Records, + NXDomain: b.NXDomain, + Error: b.Error, + }) + } + + hosts := make([]string, 0, len(r.Resolution)) + for h := range r.Resolution { + hosts = append(hosts, h) + } + sort.Strings(hosts) + for _, h := range hosts { + v := r.Resolution[h] + rd.Resolution = append(rd.Resolution, resolvedHost{ + Target: v.Target, + IPv4: v.IPv4, + IPv6: v.IPv6, + Error: v.Error, + }) + } + + for _, p := range r.Probes { + rd.Probes = append(rd.Probes, probeRow{ + Target: p.Target, Port: p.Port, Proto: p.Proto, Role: p.Role, + OK: p.OK, RTT: fmtDur(p.RTT), Error: p.Error, KrbSeen: p.KrbSeen, + }) + } + + // Enctype chips + classification flags. + hasStrong := false + for _, e := range r.Enctypes { + rd.Enctypes = append(rd.Enctypes, enctypeChip{Name: e.Name, Weak: e.Weak}) + if !e.Weak { + hasStrong = true + } + } + rd.HasEnctypes = len(r.Enctypes) > 0 + if rd.HasEnctypes && !hasStrong { + rd.HasWeakOnly = true + } + if rd.HasEnctypes && hasStrong && len(r.WeakEnctypes) > 0 { + rd.HasMixedCrypto = true + } + + // Detect common failures and build the remediation banner. + rd.Remediations = buildRemediations(&r, rd) + + var buf strings.Builder + if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil { + return "", fmt.Errorf("failed to render kerberos HTML report: %w", err) + } + return buf.String(), nil +} + +// buildRemediations inspects the observation and returns actionable hints +// for the user-visible failures. Only matching hints are appended, so a +// healthy realm shows an empty list (rendered as nothing). +func buildRemediations(r *KerberosData, rd reportData) []remediation { + var out []remediation + + hasKDCSRV := false + for _, b := range r.SRV { + if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 { + hasKDCSRV = true + break + } + } + if !hasKDCSRV { + out = append(out, remediation{ + Title: "Publish Kerberos SRV records", + Body: template.HTML(fmt.Sprintf( + `No _kerberos._tcp.%[1]s or _kerberos._udp.%[1]s records exist. Publish at minimum:
+
_kerberos._tcp.%[1]s.  IN SRV 0 0 88 kdc.%[1]s.
+_kerberos._udp.%[1]s.  IN SRV 0 0 88 kdc.%[1]s.
`, strings.ToLower(r.Realm))), + }) + } + + // SRV targets that don't resolve. + var unresolved []string + for _, h := range rd.Resolution { + if h.Error != "" || (len(h.IPv4) == 0 && len(h.IPv6) == 0) { + unresolved = append(unresolved, h.Target) + } + } + if len(unresolved) > 0 { + out = append(out, remediation{ + Title: "Resolve KDC host names", + Body: template.HTML(fmt.Sprintf( + `The following SRV target(s) do not resolve to an IP address: %s. Add A/AAAA records for each host, or correct the SRV target.`, + template.HTMLEscapeString(strings.Join(unresolved, ", ")))), + }) + } + + // No KDC reachable (port filtered / host down). + reachable := 0 + totalKDC := 0 + for _, p := range r.Probes { + if p.Role == "kdc" { + totalKDC++ + if p.OK { + reachable++ + } + } + } + if totalKDC > 0 && reachable == 0 { + out = append(out, remediation{ + Title: "Open port 88 on KDC hosts", + Body: template.HTML(`Every KDC endpoint refused or timed out. Ensure your firewall allows inbound TCP 88 and UDP 88, and that the KDC process is listening on the SRV target's IP.`), + }) + } + + // Clock skew. + if rd.ClockSkewBad { + out = append(out, remediation{ + Title: "Synchronize clocks", + Body: template.HTML(fmt.Sprintf( + `KDC clock differs from this checker by %s. Kerberos denies authentication once skew exceeds 5 minutes; run ntpd or chronyd on the KDC and its clients.`, + template.HTMLEscapeString(rd.ClockSkew))), + }) + } + + // Weak crypto only. + if rd.HasWeakOnly { + out = append(out, remediation{ + Title: "Retire DES/RC4 enctypes", + Body: template.HTML(`The KDC only advertises weak encryption types. Configure at least AES: +
[libdefaults]
+    default_tkt_enctypes   = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
+    default_tgs_enctypes   = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
+    permitted_enctypes     = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
+then rekey principals with kadmin -q "cpw -randkey principal" or equivalent.`), + }) + } + + // Wrong realm in KDC reply. + if r.AS.ServerRealm != "" && !strings.EqualFold(r.AS.ServerRealm, r.Realm) { + out = append(out, remediation{ + Title: "Fix realm mismatch", + Body: template.HTML(fmt.Sprintf( + `The KDC answered for realm %s instead of %s. Align default_realm in krb5.conf and the [realms] stanza in kdc.conf with the SRV-published realm name.`, + template.HTMLEscapeString(r.AS.ServerRealm), + template.HTMLEscapeString(r.Realm))), + }) + } + + // AS-REP without preauth — AS-REP roasting. + if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq { + out = append(out, remediation{ + Title: "Enable pre-authentication", + Body: template.HTML(`The KDC returned an AS-REP without demanding pre-authentication. This exposes principals to AS-REP roasting. Enable requires_preauth (MIT) or the equivalent flag on every user principal, e.g. kadmin -q "modprinc +requires_preauth user@REALM".`), + }) + } + + return out +} + +var kerberosHTMLTemplate = template.Must( + template.New("kerberos").Parse(` + + + + +Kerberos Realm Report + + + + +
+

Kerberos Realm

+ {{if .OverallOK}}Realm OK + {{else if .Remediations}}Issues detected + {{else}}Needs attention{{end}} +
Realm: {{.Realm}}{{if .ASProbe.Target}} · probed via {{.ASProbe.Target}} ({{.ASProbe.Proto}}){{end}}
+
+ +{{if .Remediations}} +
+

Most common issues — fix these first

+ {{range .Remediations}} +
+

{{.Title}}

+
{{.Body}}
+
+ {{end}} +
+{{end}} + +
+

SRV records

+ {{range .SRVBuckets}} + + + {{.Lookup}} + {{if .Records}}{{len .Records}} + {{else if .NXDomain}}none + {{else if .Error}}error + {{else}}empty{{end}} + +
+ {{if .Records}} + + + {{range .Records}} + + + + + + + {{end}} +
TargetPortPriorityWeight
{{.Target}}{{.Port}}{{.Priority}}{{.Weight}}
+ {{else if .Error}}

{{.Error}}

+ {{else}}

No records published.

{{end}} +
+ + {{end}} +
+ +{{if .Resolution}} +
+

Host resolution

+ + + {{range .Resolution}} + + + + + + + {{end}} +
HostIPv4IPv6Status
{{.Target}}{{range .IPv4}}{{.}} {{end}}{{range .IPv6}}{{.}} {{end}}{{if .Error}}{{.Error}}{{else}}{{end}}
+
+{{end}} + +{{if .Probes}} +
+

Connectivity probes

+ + + {{range .Probes}} + + + + + + + + + + {{end}} +
RoleHostPortProtoRTTDetail
{{if .OK}}{{else}}{{end}}{{.Role}}{{.Target}}{{.Port}}{{.Proto}}{{.RTT}}{{if .Error}}{{.Error}}{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}}
+
+{{end}} + +
+

AS-REQ probe

+ {{if .ASProbe.Attempted}} + {{if .ASProbe.Error}} +

{{.ASProbe.Error}}

+ {{else}} +
+
Response
{{.ASErrorName}} {{if .PreauthReq}}preauth required{{end}}{{if .PrincipalFound}}AS-REP without preauth{{end}}
+
Realm echoed
{{.ASProbe.ServerRealm}}
+
Server time
{{.ServerTime}}
+
Local time
{{.LocalTime}}
+
Clock skew
{{if .ClockSkewBad}}{{.ClockSkew}}{{else}}{{.ClockSkew}}{{end}}
+
PKINIT offered
{{if .PKINITOffered}}yes{{else}}no{{end}}
+
+ {{end}} + {{else}} +

Probe not attempted.

+ {{end}} +
+ +{{if .HasEnctypes}} +
+

Advertised enctypes {{if .HasWeakOnly}}weak only{{else if .HasMixedCrypto}}mixed{{else}}strong{{end}}

+ {{range .Enctypes}}{{.Name}}{{end}} +
+{{end}} + +{{if .AuthProbe}} +
+

Authenticated round-trip

+
+
Principal
{{.AuthProbe.Principal}}
+
TGT
{{if .AuthProbe.TGTAcquired}}✓ acquired{{else}}✗ failed{{end}}
+ {{if .AuthProbe.TargetService}}
TGS ({{.AuthProbe.TargetService}})
{{if .AuthProbe.TGSAcquired}}✓ acquired{{else}}{{end}}
{{end}} + {{if .AuthProbe.ErrorName}}
KDC error
{{.AuthProbe.ErrorName}}
{{end}} + {{if .AuthProbe.Error}}
Detail
{{.AuthProbe.Error}}
{{end}} +
+
+{{end}} + + +`), +) diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..f60f299 --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,224 @@ +package checker + +import ( + "context" + "fmt" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rule returns the kerberos_health rule. +func Rule() sdk.CheckRule { + return &kerberosRule{} +} + +type kerberosRule struct{} + +func (r *kerberosRule) Name() string { + return "kerberos_health" +} + +func (r *kerberosRule) Description() string { + return "Checks whether a Kerberos realm answers correctly, advertises strong crypto, and exposes no obvious misconfiguration." +} + +func (r *kerberosRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil } + +func (r *kerberosRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data KerberosData + if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("Failed to get Kerberos data: %v", err), + Code: "kerberos_error", + }} + } + + maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300)) * time.Second + requireStrong := optBool(opts, "requireStrongEnctypes", true) + + // Presence of at least one _kerberos._tcp or ._udp record is mandatory. + hasKDCSRV := false + for _, b := range data.SRV { + if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 { + hasKDCSRV = true + break + } + } + if !hasKDCSRV { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm), + Code: "kerberos_no_srv", + }} + } + + // KDC reachability: need at least one successful probe among KDC roles. + reachable := 0 + kdcProbes := 0 + kadminDown, kpasswdDown := false, false + for _, p := range data.Probes { + switch p.Role { + case "kdc": + kdcProbes++ + if p.OK { + reachable++ + } + case "kadmin": + if !p.OK { + kadminDown = true + } + case "kpasswd": + if !p.OK { + kpasswdDown = true + } + } + } + if reachable == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: "No KDC is reachable on TCP 88 or UDP 88", + Code: "kerberos_kdc_unreachable", + }} + } + + // AS-REQ result. + if data.AS.Attempted { + if data.AS.Error != "" && data.AS.ErrorCode == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: "AS-REQ probe failed: " + data.AS.Error, + Code: "kerberos_error", + }} + } + if data.AS.ServerRealm != "" && !strings.EqualFold(data.AS.ServerRealm, data.Realm) { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm), + Code: "kerberos_wrong_realm", + }} + } + if abs(data.AS.ClockSkew) > maxSkew { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: fmt.Sprintf("Clock skew with KDC is %s (max %s)", round(data.AS.ClockSkew), maxSkew), + Code: "kerberos_clock_skew", + Meta: map[string]any{ + "skew_ns": data.AS.ClockSkew.Nanoseconds(), + }, + }} + } + } + + // Crypto posture. + hasStrong := false + for _, e := range data.Enctypes { + if !e.Weak { + hasStrong = true + break + } + } + if requireStrong && len(data.Enctypes) > 0 && !hasStrong { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: "KDC only advertises weak enctypes (DES/RC4)", + Code: "kerberos_weak_crypto", + }} + } + + // Auth probe (if any). + if data.Auth != nil && data.Auth.Attempted { + if !data.Auth.TGTAcquired { + return []sdk.CheckState{{ + Status: sdk.StatusCrit, + Subject: data.Realm, + Message: "Authenticated probe: TGT acquisition failed", + Code: "kerberos_auth_fail", + }} + } + if data.Auth.TargetService != "" && !data.Auth.TGSAcquired { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Subject: data.Realm, + Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed", data.Auth.TargetService), + Code: "kerberos_tgs_fail", + }} + } + } + + // Warnings: partial reachability, no UDP, mixed crypto, no preauth. + var warnings []string + if reachable < kdcProbes { + warnings = append(warnings, fmt.Sprintf("%d/%d KDC endpoints unreachable", kdcProbes-reachable, kdcProbes)) + } + if len(data.WeakEnctypes) > 0 && hasStrong { + warnings = append(warnings, "KDC also advertises weak enctypes alongside strong ones") + } + if data.AS.Attempted && data.AS.PrincipalFound && !data.AS.PreauthReq { + warnings = append(warnings, "AS-REP returned without preauth (AS-REP roasting exposure)") + } + if kadminDown { + warnings = append(warnings, "kadmin server unreachable") + } + if kpasswdDown { + warnings = append(warnings, "kpasswd unreachable") + } + + if len(warnings) > 0 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Subject: data.Realm, + Message: fmt.Sprintf("Realm %s reachable: %s", data.Realm, strings.Join(warnings, "; ")), + Code: "kerberos_warn", + Meta: map[string]any{ + "reachable_kdcs": reachable, + "warnings": warnings, + }, + }} + } + + return []sdk.CheckState{{ + Status: sdk.StatusOK, + Subject: data.Realm, + Message: fmt.Sprintf("Realm %s healthy (%d KDC reachable, strong crypto)", data.Realm, reachable), + Code: "kerberos_ok", + Meta: map[string]any{ + "realm": data.Realm, + "reachable_kdcs": reachable, + "clock_skew_ns": data.AS.ClockSkew.Nanoseconds(), + }, + }} +} + +func abs(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} + +func round(d time.Duration) time.Duration { + return d.Round(time.Millisecond) +} + +func optBool(opts sdk.CheckerOptions, key string, def bool) bool { + v, ok := opts[key] + if !ok { + return def + } + switch x := v.(type) { + case bool: + return x + case string: + return x == "true" || x == "1" + } + return def +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..2ac8cca --- /dev/null +++ b/checker/types.go @@ -0,0 +1,120 @@ +// Package checker implements the Kerberos realm checker for happyDomain. +// +// Given a realm / DNS domain, the checker performs a sequence of anonymous +// probes (SRV layout, KDC reachability, AS-REQ exchange, enctype & clock- +// skew discovery) and optionally an authenticated round-trip. Results are +// stored as a single KerberosData observation and rendered as an HTML +// report. +package checker + +import ( + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ObservationKeyKerberos is the observation key for Kerberos realm test data. +const ObservationKeyKerberos sdk.ObservationKey = "kerberos_realm" + +// SRVRecord mirrors a *dns.SRV row we want to report on. +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` +} + +// SRVBucket is the resolution outcome for one SRV prefix. +type SRVBucket struct { + Prefix string `json:"prefix"` + Records []SRVRecord `json:"records,omitempty"` + // Error describes why the lookup failed. NXDOMAIN is reported as an + // empty slice + Error="" so the UI can tell "no records" from + // "lookup failed". + Error string `json:"error,omitempty"` + NXDomain bool `json:"nxdomain,omitempty"` + LookupName string `json:"lookupName"` +} + +// HostResolution is the A/AAAA resolution outcome for a single SRV target. +type HostResolution struct { + Target string `json:"target"` + IPv4 []string `json:"ipv4,omitempty"` + IPv6 []string `json:"ipv6,omitempty"` + Error string `json:"error,omitempty"` +} + +// KDCProbe captures the outcome of a single L4 probe. +type KDCProbe struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Proto string `json:"proto"` // "tcp" | "udp" + Role string `json:"role"` // "kdc" | "kadmin" | "kpasswd" | "master" + OK bool `json:"ok"` + RTT time.Duration `json:"rtt_ns"` + Error string `json:"error,omitempty"` + KrbSeen bool `json:"krbSeen,omitempty"` // true when a KRB message was actually parsed +} + +// EnctypeEntry describes one advertised enctype. +type EnctypeEntry struct { + ID int32 `json:"id"` + Name string `json:"name"` + Weak bool `json:"weak,omitempty"` + Salt string `json:"salt,omitempty"` + Source string `json:"source"` // "etype-info2" | "etype-info" | "pw-salt" +} + +// ASProbeResult summarizes what the AS-REQ probe taught us. +type ASProbeResult struct { + Attempted bool `json:"attempted"` + Target string `json:"target,omitempty"` + Proto string `json:"proto,omitempty"` + ErrorCode int32 `json:"errorCode,omitempty"` + ErrorName string `json:"errorName,omitempty"` + ServerRealm string `json:"serverRealm,omitempty"` + ServerTime time.Time `json:"serverTime,omitempty"` + ClockSkew time.Duration `json:"clockSkew_ns,omitempty"` + Enctypes []EnctypeEntry `json:"enctypes,omitempty"` + PreauthReq bool `json:"preauthRequired"` + PKINITOffered bool `json:"pkinitOffered,omitempty"` + PrincipalFound bool `json:"principalFound,omitempty"` // KDC returned an AS-REP without preauth (rare / AS-REP roasting exposure) + Raw string `json:"raw,omitempty"` // informative: e.g. "KRB-ERROR (25) PREAUTH_REQUIRED" + Error string `json:"error,omitempty"` +} + +// AuthProbeResult is filled only when credentials were supplied. +type AuthProbeResult struct { + Attempted bool `json:"attempted"` + Principal string `json:"principal,omitempty"` + TGTAcquired bool `json:"tgtAcquired"` + TGSAcquired bool `json:"tgsAcquired"` + TargetService string `json:"targetService,omitempty"` + Latency time.Duration `json:"latency_ns,omitempty"` + ErrorCode int32 `json:"errorCode,omitempty"` + ErrorName string `json:"errorName,omitempty"` + Error string `json:"error,omitempty"` +} + +// KerberosData is the full observation payload. +type KerberosData struct { + Realm string `json:"realm"` + CollectedAt time.Time `json:"collectedAt"` + LocalTime time.Time `json:"localTime"` + + SRV []SRVBucket `json:"srv"` + Resolution map[string]HostResolution `json:"resolution,omitempty"` + Probes []KDCProbe `json:"probes,omitempty"` + AS ASProbeResult `json:"as"` + Auth *AuthProbeResult `json:"auth,omitempty"` + + Enctypes []EnctypeEntry `json:"enctypes,omitempty"` + WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"` + + // 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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c2a4640 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module git.happydns.org/checker-kerberos + +go 1.25.0 + +require ( + git.happydns.org/checker-sdk-go v1.2.0 + github.com/jcmturner/gofork v1.7.6 + github.com/jcmturner/gokrb5/v8 v8.4.4 +) + +require ( + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/net v0.7.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c652fb7 --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..846c077 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + kerberos "git.happydns.org/checker-kerberos/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Version is the standalone binary's version. It defaults to "custom-build" +// and is meant to be overridden by the CI at link time: +// +// go build -ldflags "-X main.Version=1.2.3" . +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + kerberos.Version = Version + + server := sdk.NewServer(kerberos.Provider()) + if err := server.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..3301496 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,17 @@ +// Command plugin is the happyDomain plugin entrypoint for the Kerberos checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + kerberos "git.happydns.org/checker-kerberos/checker" + sdk "git.happydns.org/checker-sdk-go/checker" +) + +var Version = "custom-build" + +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + kerberos.Version = Version + return kerberos.Definition(), kerberos.Provider(), nil +}