From 46862014f6cd5c9f763decfc1cb3e6f1242618b0 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 | 15 + LICENSE | 21 ++ Makefile | 28 ++ NOTICE | 25 ++ README.md | 62 +++++ checker/auth.go | 74 +++++ checker/collect.go | 537 ++++++++++++++++++++++++++++++++++++ checker/collect_test.go | 332 ++++++++++++++++++++++ checker/definition.go | 89 ++++++ checker/errors.go | 48 ++++ checker/interactive.go | 53 ++++ checker/provider.go | 16 ++ checker/report.go | 532 +++++++++++++++++++++++++++++++++++ checker/rules.go | 594 ++++++++++++++++++++++++++++++++++++++++ checker/types.go | 112 ++++++++ go.mod | 18 ++ go.sum | 69 +++++ main.go | 28 ++ plugin/plugin.go | 18 ++ 20 files changed, 2673 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/collect_test.go create mode 100644 checker/definition.go create mode 100644 checker/errors.go create mode 100644 checker/interactive.go create mode 100644 checker/provider.go create mode 100644 checker/report.go create mode 100644 checker/rules.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..5bf4ff6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +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 -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos . + +FROM scratch +COPY --from=builder /checker-kerberos /checker-kerberos +USER 65534:65534 +EXPOSE 8080 +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..1d83007 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +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 test clean + +all: $(CHECKER_NAME) + +$(CHECKER_NAME): $(CHECKER_SOURCES) + go build -tags standalone -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) . + +test: + go test -tags standalone ./... + +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..c827a95 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# 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 +``` + +## Deployment + +The HTTP listener has no built-in authentication or rate-limiting, and +will issue DNS queries and Kerberos AS-REQ / TGS-REQ exchanges against +whatever realm and KDCs the caller asks for. When a `principal` and +`password` are supplied, those credentials are forwarded to the target +KDC over the network as part of an authenticated round-trip. It is +meant to run on a trusted network, reachable only by the happyDomain +instance that drives it. Restrict access via a reverse proxy with +authentication, a network ACL, or by binding the listener to a private +interface; do not expose it directly to the public internet. diff --git a/checker/auth.go b/checker/auth.go new file mode 100644 index 0000000..a7dbfdf --- /dev/null +++ b/checker/auth.go @@ -0,0 +1,74 @@ +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, name, ok := krbErrorInfo(loginErr); ok { + res.ErrorCode = code + res.ErrorName = name + } + 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, name, ok := krbErrorInfo(err); ok { + res.ErrorCode = code + res.ErrorName = name + } + return res + } + res.TGSAcquired = true + return res +} diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..c5da7b0 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,537 @@ +package checker + +import ( + "context" + "crypto/rand" + "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, +} + +// etypeNameByID is a deterministic id->name lookup built once from +// etypeID.ETypesByName, ignoring CMS/Env OID aliases. +var etypeNameByID = func() map[int32]string { + m := make(map[int32]string, len(etypeID.ETypesByName)) + for name, id := range etypeID.ETypesByName { + if strings.Contains(name, "-CmsOID") || strings.HasSuffix(name, "-EnvOID") { + continue + } + if existing, ok := m[id]; ok && existing < name { + continue // keep lexicographically smallest for stability + } + m[id] = name + } + return m +}() + +// etypeName returns a human-friendly name for an enctype ID, falling back +// to its numeric value when unknown. +func etypeName(id int32) string { + if name, ok := etypeNameByID[id]; ok { + return name + } + 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)) + + data := &KerberosData{ + Realm: realm, + CollectedAt: time.Now().UTC(), + 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) + } + } + } + // TCP first, stop after the first parsed reply. UDP endpoints are + // always probed (they are the only place we record UDP KDC + // reachability), even when a TCP target already answered. + eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...) + for _, e := range eps { + if e.proto == "tcp" && asProbe.Target != "" { + continue + } + start := time.Now() + var reply []byte + var perr error + 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) + probe := KDCProbe{ + Target: e.target, Port: e.port, Proto: "udp", Role: "kdc", + RTT: time.Since(start), + } + if perr == nil && len(reply) > 0 { + probe.OK = true + probe.KrbSeen = true + } else if perr != nil { + probe.Error = perr.Error() + } + data.Probes = append(data.Probes, probe) + } + if perr != nil || len(reply) == 0 { + continue + } + if asProbe.Target == "" { + asProbe.Target = e.target + asProbe.Proto = e.proto + parseASResponse(reply, &asProbe) + } + } + if asProbe.Target == "" && asProbe.Error == "" { + asProbe.Error = "no KDC answered our AS-REQ probe" + } + + 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 = &AuthProbeResult{ + Attempted: true, + Principal: principal, + Error: "password is required when a principal is supplied", + } + } else if principal != "" { + data.Auth = runAuthProbe(ctx, realm, principal, password, + stringOpt(opts, "targetService"), kdcHosts, timeout) + } + + 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, randomProbeCName()) + return messages.NewASReqForTGT(realm, cfg, cname) +} + +// randomProbeCName returns a probe-only principal name. The random suffix +// avoids creating a recognizable, repeating audit-log entry on the KDC and +// keeps two concurrent probes from colliding on the same cname. +func randomProbeCName() string { + var b [6]byte + if _, err := rand.Read(b[:]); err != nil { + return "probe-happydomain" + } + return "probe-happydomain-" + hex.EncodeToString(b[:]) +} + +// parseASResponse inspects the raw KDC reply and fills the ASProbeResult. +// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or, +// less commonly, an AS-REP (principal exists and doesn't require preauth . +// 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 +} + +// ---- 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/collect_test.go b/checker/collect_test.go new file mode 100644 index 0000000..97136c7 --- /dev/null +++ b/checker/collect_test.go @@ -0,0 +1,332 @@ +package checker + +import ( + "errors" + "strings" + "testing" + "time" + + asn1 "github.com/jcmturner/gofork/encoding/asn1" + "github.com/jcmturner/gokrb5/v8/iana/errorcode" + "github.com/jcmturner/gokrb5/v8/iana/etypeID" + "github.com/jcmturner/gokrb5/v8/iana/nametype" + "github.com/jcmturner/gokrb5/v8/iana/patype" + "github.com/jcmturner/gokrb5/v8/messages" + "github.com/jcmturner/gokrb5/v8/types" +) + +// buildKRBError constructs a marshaled KRB-ERROR with the given code and +// optional EData payload. +func buildKRBError(t *testing.T, realm string, code int32, edata []byte) []byte { + t.Helper() + sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm) + k := messages.NewKRBError(sname, realm, code, "") + k.STime = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC) + k.Susec = 0 + k.EData = edata + raw, err := k.Marshal() + if err != nil { + t.Fatalf("marshal KRBError: %v", err) + } + return raw +} + +// buildETypeInfo2EData marshals a PADataSequence containing one +// PA_ETYPE_INFO2 entry per supplied (etype, salt) pair. +func buildETypeInfo2EData(t *testing.T, entries []types.ETypeInfo2Entry) []byte { + t.Helper() + value, err := asn1.Marshal(types.ETypeInfo2(entries)) + if err != nil { + t.Fatalf("marshal ETypeInfo2: %v", err) + } + pas := types.PADataSequence{ + {PADataType: patype.PA_ETYPE_INFO2, PADataValue: value}, + {PADataType: patype.PA_PK_AS_REQ, PADataValue: []byte{0x00}}, + } + raw, err := asn1.Marshal(pas) + if err != nil { + t.Fatalf("marshal PADataSequence: %v", err) + } + return raw +} + +func TestParseASResponse_KRBErrorPreauthRequiredWithEData(t *testing.T) { + edata := buildETypeInfo2EData(t, []types.ETypeInfo2Entry{ + {EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "EXAMPLE.COMuser"}, + {EType: etypeID.RC4_HMAC, Salt: ""}, + }) + raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, edata) + + var out ASProbeResult + parseASResponse(raw, &out) + + if out.Error != "" { + t.Fatalf("unexpected parse error: %s", out.Error) + } + if out.ErrorCode != errorcode.KDC_ERR_PREAUTH_REQUIRED { + t.Errorf("ErrorCode = %d, want KDC_ERR_PREAUTH_REQUIRED", out.ErrorCode) + } + if !out.PreauthReq { + t.Error("PreauthReq should be true for KDC_ERR_PREAUTH_REQUIRED") + } + if out.ServerRealm != "EXAMPLE.COM" { + t.Errorf("ServerRealm = %q, want EXAMPLE.COM", out.ServerRealm) + } + if out.ServerTime.IsZero() { + t.Error("ServerTime should be populated from STime") + } + if !out.PKINITOffered { + t.Error("PKINITOffered should be true (PA_PK_AS_REQ present)") + } + if len(out.Enctypes) != 2 { + t.Fatalf("Enctypes len = %d, want 2", len(out.Enctypes)) + } + + var sawAES, sawRC4 bool + for _, e := range out.Enctypes { + switch e.ID { + case etypeID.AES256_CTS_HMAC_SHA1_96: + sawAES = true + if e.Weak { + t.Error("AES256 should not be flagged weak") + } + if e.Source != "etype-info2" { + t.Errorf("AES256 Source = %q, want etype-info2", e.Source) + } + case etypeID.RC4_HMAC: + sawRC4 = true + if !e.Weak { + t.Error("RC4_HMAC should be flagged weak") + } + } + } + if !sawAES || !sawRC4 { + t.Errorf("missing expected enctypes (sawAES=%v sawRC4=%v)", sawAES, sawRC4) + } +} + +func TestParseASResponse_KRBErrorPrincipalUnknownNoEData(t *testing.T) { + raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN, nil) + + var out ASProbeResult + parseASResponse(raw, &out) + + if out.Error != "" { + t.Fatalf("unexpected parse error: %s", out.Error) + } + if out.ErrorCode != errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN { + t.Errorf("ErrorCode = %d, want KDC_ERR_C_PRINCIPAL_UNKNOWN", out.ErrorCode) + } + if out.PreauthReq { + t.Error("PreauthReq should be false") + } + if len(out.Enctypes) != 0 { + t.Errorf("Enctypes should be empty, got %d", len(out.Enctypes)) + } +} + +func TestParseASResponse_GarbageBytes(t *testing.T) { + var out ASProbeResult + parseASResponse([]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, &out) + if out.Error == "" { + t.Fatal("expected an Error string for unparsable bytes") + } + if !strings.Contains(out.Error, "deadbeefcafe") { + t.Errorf("Error should include hex prefix of payload, got %q", out.Error) + } +} + +func TestExtractEData_ETypeInfoFallback(t *testing.T) { + // PA_ETYPE_INFO (legacy) only. Salt is octet-string here. + value, err := asn1.Marshal(types.ETypeInfo{ + {EType: etypeID.AES128_CTS_HMAC_SHA1_96, Salt: []byte("salty")}, + }) + if err != nil { + t.Fatalf("marshal ETypeInfo: %v", err) + } + edata, err := asn1.Marshal(types.PADataSequence{ + {PADataType: patype.PA_ETYPE_INFO, PADataValue: value}, + }) + if err != nil { + t.Fatalf("marshal PADataSequence: %v", err) + } + + enctypes, pkinit := extractEData(edata) + if pkinit { + t.Error("PKINIT should not be reported when no PA_PK_AS_REQ is present") + } + if len(enctypes) != 1 { + t.Fatalf("got %d enctypes, want 1", len(enctypes)) + } + if enctypes[0].Source != "etype-info" { + t.Errorf("Source = %q, want etype-info", enctypes[0].Source) + } + if enctypes[0].Salt != "salty" { + t.Errorf("Salt = %q, want salty", enctypes[0].Salt) + } +} + +func TestExtractEData_ETypeInfo2WinsOverInfo(t *testing.T) { + // Both PA_ETYPE_INFO2 and PA_ETYPE_INFO advertise the same enctype. + // The legacy info should be skipped (de-duplicated). + v2, _ := asn1.Marshal(types.ETypeInfo2{ + {EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "fromInfo2"}, + }) + v1, _ := asn1.Marshal(types.ETypeInfo{ + {EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: []byte("fromInfo")}, + }) + edata, _ := asn1.Marshal(types.PADataSequence{ + {PADataType: patype.PA_ETYPE_INFO2, PADataValue: v2}, + {PADataType: patype.PA_ETYPE_INFO, PADataValue: v1}, + }) + got, _ := extractEData(edata) + if len(got) != 1 { + t.Fatalf("got %d entries, want 1 (de-duplicated)", len(got)) + } + if got[0].Salt != "fromInfo2" { + t.Errorf("salt = %q, want fromInfo2 (etype-info2 must take precedence)", got[0].Salt) + } +} + +func TestExtractEData_BadASN1(t *testing.T) { + enctypes, pkinit := extractEData([]byte{0xff, 0x00}) + if enctypes != nil || pkinit { + t.Errorf("expected nil/false on garbage, got %v / %v", enctypes, pkinit) + } +} + +func TestEtypeName(t *testing.T) { + if got := etypeName(etypeID.AES256_CTS_HMAC_SHA1_96); !strings.Contains(strings.ToLower(got), "aes256") { + t.Errorf("AES256 name = %q, want it to mention aes256", got) + } + if got := etypeName(99999); got != "etype-99999" { + t.Errorf("unknown etype = %q, want etype-99999", got) + } +} + +func TestErrorcodeNameAndKRBErrorInfo(t *testing.T) { + name := errorcodeName(errorcode.KDC_ERR_PREAUTH_REQUIRED) + if !strings.Contains(name, "PREAUTH") { + t.Errorf("errorcodeName = %q, want it to contain PREAUTH", name) + } + + // Typed KRBError: errors.As path. + sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/EXAMPLE.COM") + krb := messages.NewKRBError(sname, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, "") + code, n, ok := krbErrorInfo(krb) + if !ok || code != errorcode.KDC_ERR_PREAUTH_REQUIRED || !strings.Contains(n, "PREAUTH") { + t.Errorf("krbErrorInfo(typed) = %d %q %v", code, n, ok) + } + + // String fallback: gokrb5 sometimes wraps the code only inside the message. + wrapped := errors.New("login failed: KRB Error: (24) KDC_ERR_PREAUTH_FAILED - bla") + code, n, ok = krbErrorInfo(wrapped) + if !ok || code != 24 { + t.Errorf("krbErrorInfo(string) code=%d ok=%v", code, ok) + } + if !strings.Contains(n, "PREAUTH_FAILED") { + t.Errorf("krbErrorInfo(string) name = %q", n) + } + + if _, _, ok := krbErrorInfo(nil); ok { + t.Error("krbErrorInfo(nil) should return ok=false") + } + if _, _, ok := krbErrorInfo(errors.New("plain old error")); ok { + t.Error("krbErrorInfo on a non-KRB error should return ok=false") + } +} + +func TestRoleForPrefix(t *testing.T) { + cases := map[string]string{ + "_kerberos._tcp.": "kdc", + "_kerberos._udp.": "kdc", + "_kerberos-master._tcp.": "master", + "_kerberos-adm._tcp.": "kadmin", + "_kpasswd._tcp.": "kpasswd", + "_kpasswd._udp.": "kpasswd", + } + for in, want := range cases { + if got := roleForPrefix(in); got != want { + t.Errorf("roleForPrefix(%q) = %q, want %q", in, got, want) + } + } +} + +func TestOptFloat(t *testing.T) { + cases := []struct { + in any + want float64 + }{ + {float64(2.5), 2.5}, + {float32(1.5), 1.5}, + {int(7), 7}, + {int64(8), 8}, + {"3.14", 3.14}, + {"nope", 42}, // falls back to default + {nil, 42}, // missing key path is exercised below + } + for _, c := range cases { + opts := map[string]any{"k": c.in} + got := optFloat(opts, "k", 42) + if got != c.want { + t.Errorf("optFloat(%v) = %v, want %v", c.in, got, c.want) + } + } + if got := optFloat(map[string]any{}, "missing", 99); got != 99 { + t.Errorf("optFloat(missing) = %v, want 99", got) + } +} + +func TestOptBool(t *testing.T) { + cases := []struct { + in any + def bool + want bool + }{ + {true, false, true}, + {false, true, false}, + {"true", false, true}, + {"1", false, true}, + {"false", true, false}, // unrecognized string -> default + {nil, true, true}, + {42, false, false}, // unsupported type -> default + } + for _, c := range cases { + opts := map[string]any{} + if c.in != nil { + opts["k"] = c.in + } + got := optBool(opts, "k", c.def) + if got != c.want { + t.Errorf("optBool(%v, def=%v) = %v, want %v", c.in, c.def, got, c.want) + } + } +} + +func TestSmallHelpers(t *testing.T) { + if got := abs(-3 * time.Second); got != 3*time.Second { + t.Errorf("abs negative = %v", got) + } + if got := abs(2 * time.Second); got != 2*time.Second { + t.Errorf("abs positive = %v", got) + } + if got := firstNonEmpty("", "", "x", "y"); got != "x" { + t.Errorf("firstNonEmpty = %q", got) + } + if got := firstNonEmpty("", ""); got != "" { + t.Errorf("firstNonEmpty(all empty) = %q", got) + } + if got := first([]byte{1, 2, 3}, 16); len(got) != 3 { + t.Errorf("first(short) len = %d, want 3", len(got)) + } + if got := first([]byte{1, 2, 3, 4, 5}, 2); len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Errorf("first(long) = %v", got) + } + list := []EnctypeEntry{{ID: 18}, {ID: 17}} + if !hasEnctype(list, 17) { + t.Error("hasEnctype should find 17") + } + if hasEnctype(list, 23) { + t.Error("hasEnctype should not find 23") + } +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..c1fc9dd --- /dev/null +++ b/checker/definition.go @@ -0,0 +1,89 @@ +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 (p *kerberosProvider) 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: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 24 * time.Hour, + }, + } +} diff --git a/checker/errors.go b/checker/errors.go new file mode 100644 index 0000000..ba6f67f --- /dev/null +++ b/checker/errors.go @@ -0,0 +1,48 @@ +package checker + +import ( + "errors" + "strconv" + "strings" + + "github.com/jcmturner/gokrb5/v8/iana/errorcode" + "github.com/jcmturner/gokrb5/v8/messages" +) + +// krbErrorInfo extracts a Kerberos error code (and its short name) from an +// error returned by gokrb5. Direct KRBError values are matched via +// errors.As; otherwise the error string is parsed, since gokrb5 also +// returns wrapped krberror.Krberror values that carry the code only inside +// their formatted message. ok is false when no code could be extracted. +func krbErrorInfo(err error) (code int32, name string, ok bool) { + if err == nil { + return 0, "", false + } + var krbErr messages.KRBError + if errors.As(err, &krbErr) { + return krbErr.ErrorCode, errorcodeName(krbErr.ErrorCode), true + } + msg := err.Error() + if _, after, found := strings.Cut(msg, "KRB Error: ("); found { + if c, _, found := strings.Cut(after, ")"); found { + if n, perr := strconv.Atoi(c); perr == nil { + return int32(n), errorcodeName(int32(n)), true + } + } + } + return 0, "", false +} + +// errorcodeName returns the short symbolic name of a Kerberos error code +// (e.g. "KDC_ERR_PREAUTH_REQUIRED"), trimming the numeric/textual padding +// gokrb5 wraps around it. +func errorcodeName(code int32) string { + s := errorcode.Lookup(code) + if _, after, ok := strings.Cut(s, ") "); ok { + s = after + } + if before, _, ok := strings.Cut(s, " "); ok { + s = before + } + return s +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..5b3169e --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,53 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes the run + admin options documented in Definition() +// so the standalone form stays in sync with the host-side documentation. +func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField { + docs := p.Definition().Options + fields := make([]sdk.CheckerOptionField, 0, len(docs.RunOpts)+len(docs.AdminOpts)) + fields = append(fields, docs.RunOpts...) + fields = append(fields, docs.AdminOpts...) + return fields +} + +// ParseForm turns the submitted form into a CheckerOptions, using the +// documented field types to coerce values. +func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + opts := sdk.CheckerOptions{} + for _, f := range p.RenderForm() { + raw := r.FormValue(f.Id) + if f.Type != "bool" { + raw = strings.TrimSpace(raw) + } + if raw == "" { + if f.Required { + return nil, errors.New(f.Id + " is required") + } + continue + } + switch f.Type { + case "bool": + opts[f.Id] = raw == "true" || raw == "1" || raw == "on" + case "number": + if v, err := strconv.ParseFloat(raw, 64); err == nil { + opts[f.Id] = v + } else { + opts[f.Id] = raw + } + default: + opts[f.Id] = raw + } + } + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..2cfc4ff --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,16 @@ +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 +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..aaca430 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,532 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "sort" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ── 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 + HasStates bool + OverallOK bool + CollectedAt 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(rctx sdk.ReportContext) (string, error) { + var r KerberosData + if err := json.Unmarshal(rctx.Data(), &r); err != nil { + return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err) + } + + // Derive overall OK exclusively from the states the host produced for + // this run. When no states are supplied, render a data-only view with + // no status banner and no remediation hints. + states := rctx.States() + hasStates := len(states) > 0 + overallOK := hasStates + for _, s := range states { + if s.Status == sdk.StatusCrit || s.Status == sdk.StatusError || s.Status == sdk.StatusWarn { + overallOK = false + break + } + } + + rd := reportData{ + Realm: r.Realm, + HasStates: hasStates, + OverallOK: overallOK, + CollectedAt: r.CollectedAt.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) + } + // Trust the clock-skew rule's verdict (which honours maxClockSkew) + // rather than re-applying a hardcoded threshold here. + for _, s := range states { + if s.Code == CodeClockSkewBad && + (s.Status == sdk.StatusCrit || s.Status == sdk.StatusWarn || s.Status == sdk.StatusError) { + rd.ClockSkewBad = true + break + } + } + + 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. Hints are + // only surfaced when the host supplied rule states for this run. + if hasStates { + rd.Remediations = buildRemediations(&r, rd) + } + + var buf strings.Builder + if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil { + 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 .HasStates}} + {{if .OverallOK}}Realm OK + {{else if .Remediations}}Issues detected + {{else}}Needs attention{{end}} + {{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}}
+
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/rules.go b/checker/rules.go new file mode 100644 index 0000000..5a8d24e --- /dev/null +++ b/checker/rules.go @@ -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 "" +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..d0f9151 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,112 @@ +// 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"` + + 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"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..392087b --- /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.3.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..e2b9a0d --- /dev/null +++ b/go.sum @@ -0,0 +1,69 @@ +git.happydns.org/checker-sdk-go v1.3.0 h1:FG2kIhlJCzI0m35EhxSgn4UWc9M4ha6aZTeoChu4l7A= +git.happydns.org/checker-sdk-go v1.3.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..6037bfb --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + kerberos "git.happydns.org/checker-kerberos/checker" + "git.happydns.org/checker-sdk-go/checker/server" +) + +// 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 + + srv := server.New(kerberos.Provider()) + if err := srv.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..fd6776c --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,18 @@ +// 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 + prvd := kerberos.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}