Initial commit
This commit is contained in:
commit
40a4cf285e
18 changed files with 1933 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-kerberos
|
||||
checker-kerberos.so
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -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"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
25
Makefile
Normal file
25
Makefile
Normal file
|
|
@ -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
|
||||
25
NOTICE
Normal file
25
NOTICE
Normal file
|
|
@ -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
|
||||
50
README.md
Normal file
50
README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
93
checker/auth.go
Normal file
93
checker/auth.go
Normal file
|
|
@ -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
|
||||
}
|
||||
516
checker/collect.go
Normal file
516
checker/collect.go
Normal file
|
|
@ -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
|
||||
}
|
||||
91
checker/definition.go
Normal file
91
checker/definition.go
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
92
checker/interactive.go
Normal file
92
checker/interactive.go
Normal file
|
|
@ -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
|
||||
}
|
||||
21
checker/provider.go
Normal file
21
checker/provider.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
507
checker/report.go
Normal file
507
checker/report.go
Normal file
|
|
@ -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 <code>_kerberos._tcp.%[1]s</code> or <code>_kerberos._udp.%[1]s</code> records exist. Publish at minimum:<br>
|
||||
<pre>_kerberos._tcp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.
|
||||
_kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.</pre>`, 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: <code>%s</code>. 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 <code>TCP 88</code> and <code>UDP 88</code>, 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 <strong>%s</strong>. Kerberos denies authentication once skew exceeds 5 minutes; run <code>ntpd</code> or <code>chronyd</code> 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:
|
||||
<pre>[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</pre>
|
||||
then rekey principals with <code>kadmin -q "cpw -randkey principal"</code> 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 <code>%s</code> instead of <code>%s</code>. Align <code>default_realm</code> in <code>krb5.conf</code> and the <code>[realms]</code> stanza in <code>kdc.conf</code> 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 <strong>AS-REP roasting</strong>. Enable <code>requires_preauth</code> (MIT) or the equivalent flag on every user principal, e.g. <code>kadmin -q "modprinc +requires_preauth user@REALM"</code>.`),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
var kerberosHTMLTemplate = template.Must(
|
||||
template.New("kerberos").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kerberos Realm Report</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
pre {
|
||||
font-family: ui-monospace, monospace; font-size: .82em;
|
||||
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px;
|
||||
padding: .55rem .7rem; overflow-x: auto; margin: .35rem 0 0;
|
||||
}
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||
|
||||
.hd {
|
||||
background: #fff; border-radius: 10px;
|
||||
padding: 1rem 1.25rem; margin-bottom: .75rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.neutral { background: #e5e7eb; color: #374151; }
|
||||
|
||||
.realm { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
.realm code { color: #111827; }
|
||||
|
||||
.section {
|
||||
background: #fff; border-radius: 8px;
|
||||
padding: .85rem 1rem; margin-bottom: .6rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.reme {
|
||||
background: #fff7ed; border: 1px solid #fdba74; border-left: 4px solid #f97316;
|
||||
border-radius: 8px; padding: .75rem 1rem; margin-bottom: .6rem;
|
||||
}
|
||||
.reme h2 { color: #9a3412; }
|
||||
.reme-item { padding: .55rem 0; border-top: 1px dashed #fdba74; }
|
||||
.reme-item:first-of-type { border-top: none; padding-top: .25rem; }
|
||||
.reme-item h3 { color: #9a3412; margin-bottom: .25rem; }
|
||||
|
||||
details {
|
||||
background: #fff; border-radius: 8px; margin-bottom: .45rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;
|
||||
}
|
||||
.section details {
|
||||
box-shadow: none; border-radius: 6px;
|
||||
border: 1px solid #e5e7eb; margin-bottom: .4rem;
|
||||
}
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .55rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before {
|
||||
content: "▶"; font-size: .65rem; color: #9ca3af;
|
||||
transition: transform .15s; flex-shrink: 0;
|
||||
}
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.srv-title { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .88rem; }
|
||||
|
||||
.details-body { padding: .55rem 1rem .8rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
|
||||
|
||||
.chip {
|
||||
display: inline-block; padding: .15em .6em; margin: .12em .2em .12em 0;
|
||||
border-radius: 9999px; font-size: .78rem; font-weight: 600;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
}
|
||||
.chip.weak { background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: .8rem; row-gap: .1rem; font-size: .85rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>Kerberos Realm</h1>
|
||||
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
|
||||
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
|
||||
{{else}}<span class="badge warn">Needs attention</span>{{end}}
|
||||
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} · probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
|
||||
</div>
|
||||
|
||||
{{if .Remediations}}
|
||||
<div class="reme">
|
||||
<h2>Most common issues — fix these first</h2>
|
||||
{{range .Remediations}}
|
||||
<div class="reme-item">
|
||||
<h3>{{.Title}}</h3>
|
||||
<div>{{.Body}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>SRV records</h2>
|
||||
{{range .SRVBuckets}}
|
||||
<details{{if and .Error (not .Records)}} open{{end}}>
|
||||
<summary>
|
||||
<span class="srv-title">{{.Lookup}}</span>
|
||||
{{if .Records}}<span class="badge ok">{{len .Records}}</span>
|
||||
{{else if .NXDomain}}<span class="badge neutral">none</span>
|
||||
{{else if .Error}}<span class="badge fail">error</span>
|
||||
{{else}}<span class="badge neutral">empty</span>{{end}}
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
{{if .Records}}
|
||||
<table>
|
||||
<tr><th>Target</th><th>Port</th><th>Priority</th><th>Weight</th></tr>
|
||||
{{range .Records}}
|
||||
<tr>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Priority}}</td>
|
||||
<td>{{.Weight}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else if .Error}}<p class="errmsg">{{.Error}}</p>
|
||||
{{else}}<p class="note">No records published.</p>{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .Resolution}}
|
||||
<div class="section">
|
||||
<h2>Host resolution</h2>
|
||||
<table>
|
||||
<tr><th>Host</th><th>IPv4</th><th>IPv6</th><th>Status</th></tr>
|
||||
{{range .Resolution}}
|
||||
<tr>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{range .IPv4}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>{{range .IPv6}}<code>{{.}}</code> {{end}}</td>
|
||||
<td>{{if .Error}}<span class="check-fail">{{.Error}}</span>{{else}}<span class="check-ok">✓</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Probes}}
|
||||
<div class="section">
|
||||
<h2>Connectivity probes</h2>
|
||||
<table>
|
||||
<tr><th></th><th>Role</th><th>Host</th><th>Port</th><th>Proto</th><th>RTT</th><th>Detail</th></tr>
|
||||
{{range .Probes}}
|
||||
<tr>
|
||||
<td>{{if .OK}}<span class="check-ok">✓</span>{{else}}<span class="check-fail">✗</span>{{end}}</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td><code>{{.Target}}</code></td>
|
||||
<td>{{.Port}}</td>
|
||||
<td>{{.Proto}}</td>
|
||||
<td>{{.RTT}}</td>
|
||||
<td>{{if .Error}}<span class="errmsg">{{.Error}}</span>{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<h2>AS-REQ probe</h2>
|
||||
{{if .ASProbe.Attempted}}
|
||||
{{if .ASProbe.Error}}
|
||||
<p class="errmsg">{{.ASProbe.Error}}</p>
|
||||
{{else}}
|
||||
<dl class="kv">
|
||||
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
|
||||
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
|
||||
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
|
||||
<dt>Local time</dt><dd>{{.LocalTime}}</dd>
|
||||
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
|
||||
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
|
||||
</dl>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="note">Probe not attempted.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .HasEnctypes}}
|
||||
<div class="section">
|
||||
<h2>Advertised enctypes {{if .HasWeakOnly}}<span class="badge fail">weak only</span>{{else if .HasMixedCrypto}}<span class="badge warn">mixed</span>{{else}}<span class="badge ok">strong</span>{{end}}</h2>
|
||||
{{range .Enctypes}}<span class="chip{{if .Weak}} weak{{end}}">{{.Name}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .AuthProbe}}
|
||||
<div class="section">
|
||||
<h2>Authenticated round-trip</h2>
|
||||
<dl class="kv">
|
||||
<dt>Principal</dt><dd><code>{{.AuthProbe.Principal}}</code></dd>
|
||||
<dt>TGT</dt><dd>{{if .AuthProbe.TGTAcquired}}<span class="check-ok">✓ acquired</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
{{if .AuthProbe.TargetService}}<dt>TGS ({{.AuthProbe.TargetService}})</dt><dd>{{if .AuthProbe.TGSAcquired}}<span class="check-ok">✓ acquired</span>{{else}}<span class="check-fail">✗</span>{{end}}</dd>{{end}}
|
||||
{{if .AuthProbe.ErrorName}}<dt>KDC error</dt><dd><code>{{.AuthProbe.ErrorName}}</code></dd>{{end}}
|
||||
{{if .AuthProbe.Error}}<dt>Detail</dt><dd><span class="errmsg">{{.AuthProbe.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`),
|
||||
)
|
||||
224
checker/rule.go
Normal file
224
checker/rule.go
Normal file
|
|
@ -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
|
||||
}
|
||||
120
checker/types.go
Normal file
120
checker/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
|
|
@ -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
|
||||
)
|
||||
69
go.sum
Normal file
69
go.sum
Normal file
|
|
@ -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=
|
||||
28
main.go
Normal file
28
main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
17
plugin/plugin.go
Normal file
17
plugin/plugin.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue