Initial commit
This commit is contained in:
commit
46862014f6
20 changed files with 2673 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-kerberos
|
||||
checker-kerberos.so
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -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"]
|
||||
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.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -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
|
||||
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
|
||||
62
README.md
Normal file
62
README.md
Normal file
|
|
@ -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.
|
||||
74
checker/auth.go
Normal file
74
checker/auth.go
Normal file
|
|
@ -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
|
||||
}
|
||||
537
checker/collect.go
Normal file
537
checker/collect.go
Normal file
|
|
@ -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
|
||||
}
|
||||
332
checker/collect_test.go
Normal file
332
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
asn1 "github.com/jcmturner/gofork/encoding/asn1"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/nametype"
|
||||
"github.com/jcmturner/gokrb5/v8/iana/patype"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
"github.com/jcmturner/gokrb5/v8/types"
|
||||
)
|
||||
|
||||
// buildKRBError constructs a marshaled KRB-ERROR with the given code and
|
||||
// optional EData payload.
|
||||
func buildKRBError(t *testing.T, realm string, code int32, edata []byte) []byte {
|
||||
t.Helper()
|
||||
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm)
|
||||
k := messages.NewKRBError(sname, realm, code, "")
|
||||
k.STime = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
k.Susec = 0
|
||||
k.EData = edata
|
||||
raw, err := k.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal KRBError: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// buildETypeInfo2EData marshals a PADataSequence containing one
|
||||
// PA_ETYPE_INFO2 entry per supplied (etype, salt) pair.
|
||||
func buildETypeInfo2EData(t *testing.T, entries []types.ETypeInfo2Entry) []byte {
|
||||
t.Helper()
|
||||
value, err := asn1.Marshal(types.ETypeInfo2(entries))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ETypeInfo2: %v", err)
|
||||
}
|
||||
pas := types.PADataSequence{
|
||||
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: value},
|
||||
{PADataType: patype.PA_PK_AS_REQ, PADataValue: []byte{0x00}},
|
||||
}
|
||||
raw, err := asn1.Marshal(pas)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PADataSequence: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestParseASResponse_KRBErrorPreauthRequiredWithEData(t *testing.T) {
|
||||
edata := buildETypeInfo2EData(t, []types.ETypeInfo2Entry{
|
||||
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "EXAMPLE.COMuser"},
|
||||
{EType: etypeID.RC4_HMAC, Salt: ""},
|
||||
})
|
||||
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, edata)
|
||||
|
||||
var out ASProbeResult
|
||||
parseASResponse(raw, &out)
|
||||
|
||||
if out.Error != "" {
|
||||
t.Fatalf("unexpected parse error: %s", out.Error)
|
||||
}
|
||||
if out.ErrorCode != errorcode.KDC_ERR_PREAUTH_REQUIRED {
|
||||
t.Errorf("ErrorCode = %d, want KDC_ERR_PREAUTH_REQUIRED", out.ErrorCode)
|
||||
}
|
||||
if !out.PreauthReq {
|
||||
t.Error("PreauthReq should be true for KDC_ERR_PREAUTH_REQUIRED")
|
||||
}
|
||||
if out.ServerRealm != "EXAMPLE.COM" {
|
||||
t.Errorf("ServerRealm = %q, want EXAMPLE.COM", out.ServerRealm)
|
||||
}
|
||||
if out.ServerTime.IsZero() {
|
||||
t.Error("ServerTime should be populated from STime")
|
||||
}
|
||||
if !out.PKINITOffered {
|
||||
t.Error("PKINITOffered should be true (PA_PK_AS_REQ present)")
|
||||
}
|
||||
if len(out.Enctypes) != 2 {
|
||||
t.Fatalf("Enctypes len = %d, want 2", len(out.Enctypes))
|
||||
}
|
||||
|
||||
var sawAES, sawRC4 bool
|
||||
for _, e := range out.Enctypes {
|
||||
switch e.ID {
|
||||
case etypeID.AES256_CTS_HMAC_SHA1_96:
|
||||
sawAES = true
|
||||
if e.Weak {
|
||||
t.Error("AES256 should not be flagged weak")
|
||||
}
|
||||
if e.Source != "etype-info2" {
|
||||
t.Errorf("AES256 Source = %q, want etype-info2", e.Source)
|
||||
}
|
||||
case etypeID.RC4_HMAC:
|
||||
sawRC4 = true
|
||||
if !e.Weak {
|
||||
t.Error("RC4_HMAC should be flagged weak")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawAES || !sawRC4 {
|
||||
t.Errorf("missing expected enctypes (sawAES=%v sawRC4=%v)", sawAES, sawRC4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseASResponse_KRBErrorPrincipalUnknownNoEData(t *testing.T) {
|
||||
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN, nil)
|
||||
|
||||
var out ASProbeResult
|
||||
parseASResponse(raw, &out)
|
||||
|
||||
if out.Error != "" {
|
||||
t.Fatalf("unexpected parse error: %s", out.Error)
|
||||
}
|
||||
if out.ErrorCode != errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN {
|
||||
t.Errorf("ErrorCode = %d, want KDC_ERR_C_PRINCIPAL_UNKNOWN", out.ErrorCode)
|
||||
}
|
||||
if out.PreauthReq {
|
||||
t.Error("PreauthReq should be false")
|
||||
}
|
||||
if len(out.Enctypes) != 0 {
|
||||
t.Errorf("Enctypes should be empty, got %d", len(out.Enctypes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseASResponse_GarbageBytes(t *testing.T) {
|
||||
var out ASProbeResult
|
||||
parseASResponse([]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, &out)
|
||||
if out.Error == "" {
|
||||
t.Fatal("expected an Error string for unparsable bytes")
|
||||
}
|
||||
if !strings.Contains(out.Error, "deadbeefcafe") {
|
||||
t.Errorf("Error should include hex prefix of payload, got %q", out.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEData_ETypeInfoFallback(t *testing.T) {
|
||||
// PA_ETYPE_INFO (legacy) only. Salt is octet-string here.
|
||||
value, err := asn1.Marshal(types.ETypeInfo{
|
||||
{EType: etypeID.AES128_CTS_HMAC_SHA1_96, Salt: []byte("salty")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal ETypeInfo: %v", err)
|
||||
}
|
||||
edata, err := asn1.Marshal(types.PADataSequence{
|
||||
{PADataType: patype.PA_ETYPE_INFO, PADataValue: value},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal PADataSequence: %v", err)
|
||||
}
|
||||
|
||||
enctypes, pkinit := extractEData(edata)
|
||||
if pkinit {
|
||||
t.Error("PKINIT should not be reported when no PA_PK_AS_REQ is present")
|
||||
}
|
||||
if len(enctypes) != 1 {
|
||||
t.Fatalf("got %d enctypes, want 1", len(enctypes))
|
||||
}
|
||||
if enctypes[0].Source != "etype-info" {
|
||||
t.Errorf("Source = %q, want etype-info", enctypes[0].Source)
|
||||
}
|
||||
if enctypes[0].Salt != "salty" {
|
||||
t.Errorf("Salt = %q, want salty", enctypes[0].Salt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEData_ETypeInfo2WinsOverInfo(t *testing.T) {
|
||||
// Both PA_ETYPE_INFO2 and PA_ETYPE_INFO advertise the same enctype.
|
||||
// The legacy info should be skipped (de-duplicated).
|
||||
v2, _ := asn1.Marshal(types.ETypeInfo2{
|
||||
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "fromInfo2"},
|
||||
})
|
||||
v1, _ := asn1.Marshal(types.ETypeInfo{
|
||||
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: []byte("fromInfo")},
|
||||
})
|
||||
edata, _ := asn1.Marshal(types.PADataSequence{
|
||||
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: v2},
|
||||
{PADataType: patype.PA_ETYPE_INFO, PADataValue: v1},
|
||||
})
|
||||
got, _ := extractEData(edata)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d entries, want 1 (de-duplicated)", len(got))
|
||||
}
|
||||
if got[0].Salt != "fromInfo2" {
|
||||
t.Errorf("salt = %q, want fromInfo2 (etype-info2 must take precedence)", got[0].Salt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractEData_BadASN1(t *testing.T) {
|
||||
enctypes, pkinit := extractEData([]byte{0xff, 0x00})
|
||||
if enctypes != nil || pkinit {
|
||||
t.Errorf("expected nil/false on garbage, got %v / %v", enctypes, pkinit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtypeName(t *testing.T) {
|
||||
if got := etypeName(etypeID.AES256_CTS_HMAC_SHA1_96); !strings.Contains(strings.ToLower(got), "aes256") {
|
||||
t.Errorf("AES256 name = %q, want it to mention aes256", got)
|
||||
}
|
||||
if got := etypeName(99999); got != "etype-99999" {
|
||||
t.Errorf("unknown etype = %q, want etype-99999", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorcodeNameAndKRBErrorInfo(t *testing.T) {
|
||||
name := errorcodeName(errorcode.KDC_ERR_PREAUTH_REQUIRED)
|
||||
if !strings.Contains(name, "PREAUTH") {
|
||||
t.Errorf("errorcodeName = %q, want it to contain PREAUTH", name)
|
||||
}
|
||||
|
||||
// Typed KRBError: errors.As path.
|
||||
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/EXAMPLE.COM")
|
||||
krb := messages.NewKRBError(sname, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, "")
|
||||
code, n, ok := krbErrorInfo(krb)
|
||||
if !ok || code != errorcode.KDC_ERR_PREAUTH_REQUIRED || !strings.Contains(n, "PREAUTH") {
|
||||
t.Errorf("krbErrorInfo(typed) = %d %q %v", code, n, ok)
|
||||
}
|
||||
|
||||
// String fallback: gokrb5 sometimes wraps the code only inside the message.
|
||||
wrapped := errors.New("login failed: KRB Error: (24) KDC_ERR_PREAUTH_FAILED - bla")
|
||||
code, n, ok = krbErrorInfo(wrapped)
|
||||
if !ok || code != 24 {
|
||||
t.Errorf("krbErrorInfo(string) code=%d ok=%v", code, ok)
|
||||
}
|
||||
if !strings.Contains(n, "PREAUTH_FAILED") {
|
||||
t.Errorf("krbErrorInfo(string) name = %q", n)
|
||||
}
|
||||
|
||||
if _, _, ok := krbErrorInfo(nil); ok {
|
||||
t.Error("krbErrorInfo(nil) should return ok=false")
|
||||
}
|
||||
if _, _, ok := krbErrorInfo(errors.New("plain old error")); ok {
|
||||
t.Error("krbErrorInfo on a non-KRB error should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleForPrefix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"_kerberos._tcp.": "kdc",
|
||||
"_kerberos._udp.": "kdc",
|
||||
"_kerberos-master._tcp.": "master",
|
||||
"_kerberos-adm._tcp.": "kadmin",
|
||||
"_kpasswd._tcp.": "kpasswd",
|
||||
"_kpasswd._udp.": "kpasswd",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := roleForPrefix(in); got != want {
|
||||
t.Errorf("roleForPrefix(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptFloat(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want float64
|
||||
}{
|
||||
{float64(2.5), 2.5},
|
||||
{float32(1.5), 1.5},
|
||||
{int(7), 7},
|
||||
{int64(8), 8},
|
||||
{"3.14", 3.14},
|
||||
{"nope", 42}, // falls back to default
|
||||
{nil, 42}, // missing key path is exercised below
|
||||
}
|
||||
for _, c := range cases {
|
||||
opts := map[string]any{"k": c.in}
|
||||
got := optFloat(opts, "k", 42)
|
||||
if got != c.want {
|
||||
t.Errorf("optFloat(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := optFloat(map[string]any{}, "missing", 99); got != 99 {
|
||||
t.Errorf("optFloat(missing) = %v, want 99", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptBool(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
def bool
|
||||
want bool
|
||||
}{
|
||||
{true, false, true},
|
||||
{false, true, false},
|
||||
{"true", false, true},
|
||||
{"1", false, true},
|
||||
{"false", true, false}, // unrecognized string -> default
|
||||
{nil, true, true},
|
||||
{42, false, false}, // unsupported type -> default
|
||||
}
|
||||
for _, c := range cases {
|
||||
opts := map[string]any{}
|
||||
if c.in != nil {
|
||||
opts["k"] = c.in
|
||||
}
|
||||
got := optBool(opts, "k", c.def)
|
||||
if got != c.want {
|
||||
t.Errorf("optBool(%v, def=%v) = %v, want %v", c.in, c.def, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmallHelpers(t *testing.T) {
|
||||
if got := abs(-3 * time.Second); got != 3*time.Second {
|
||||
t.Errorf("abs negative = %v", got)
|
||||
}
|
||||
if got := abs(2 * time.Second); got != 2*time.Second {
|
||||
t.Errorf("abs positive = %v", got)
|
||||
}
|
||||
if got := firstNonEmpty("", "", "x", "y"); got != "x" {
|
||||
t.Errorf("firstNonEmpty = %q", got)
|
||||
}
|
||||
if got := firstNonEmpty("", ""); got != "" {
|
||||
t.Errorf("firstNonEmpty(all empty) = %q", got)
|
||||
}
|
||||
if got := first([]byte{1, 2, 3}, 16); len(got) != 3 {
|
||||
t.Errorf("first(short) len = %d, want 3", len(got))
|
||||
}
|
||||
if got := first([]byte{1, 2, 3, 4, 5}, 2); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Errorf("first(long) = %v", got)
|
||||
}
|
||||
list := []EnctypeEntry{{ID: 18}, {ID: 17}}
|
||||
if !hasEnctype(list, 17) {
|
||||
t.Error("hasEnctype should find 17")
|
||||
}
|
||||
if hasEnctype(list, 23) {
|
||||
t.Error("hasEnctype should not find 23")
|
||||
}
|
||||
}
|
||||
89
checker/definition.go
Normal file
89
checker/definition.go
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
48
checker/errors.go
Normal file
48
checker/errors.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
|
||||
"github.com/jcmturner/gokrb5/v8/messages"
|
||||
)
|
||||
|
||||
// krbErrorInfo extracts a Kerberos error code (and its short name) from an
|
||||
// error returned by gokrb5. Direct KRBError values are matched via
|
||||
// errors.As; otherwise the error string is parsed, since gokrb5 also
|
||||
// returns wrapped krberror.Krberror values that carry the code only inside
|
||||
// their formatted message. ok is false when no code could be extracted.
|
||||
func krbErrorInfo(err error) (code int32, name string, ok bool) {
|
||||
if err == nil {
|
||||
return 0, "", false
|
||||
}
|
||||
var krbErr messages.KRBError
|
||||
if errors.As(err, &krbErr) {
|
||||
return krbErr.ErrorCode, errorcodeName(krbErr.ErrorCode), true
|
||||
}
|
||||
msg := err.Error()
|
||||
if _, after, found := strings.Cut(msg, "KRB Error: ("); found {
|
||||
if c, _, found := strings.Cut(after, ")"); found {
|
||||
if n, perr := strconv.Atoi(c); perr == nil {
|
||||
return int32(n), errorcodeName(int32(n)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, "", false
|
||||
}
|
||||
|
||||
// errorcodeName returns the short symbolic name of a Kerberos error code
|
||||
// (e.g. "KDC_ERR_PREAUTH_REQUIRED"), trimming the numeric/textual padding
|
||||
// gokrb5 wraps around it.
|
||||
func errorcodeName(code int32) string {
|
||||
s := errorcode.Lookup(code)
|
||||
if _, after, ok := strings.Cut(s, ") "); ok {
|
||||
s = after
|
||||
}
|
||||
if before, _, ok := strings.Cut(s, " "); ok {
|
||||
s = before
|
||||
}
|
||||
return s
|
||||
}
|
||||
53
checker/interactive.go
Normal file
53
checker/interactive.go
Normal file
|
|
@ -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
|
||||
}
|
||||
16
checker/provider.go
Normal file
16
checker/provider.go
Normal file
|
|
@ -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
|
||||
}
|
||||
532
checker/report.go
Normal file
532
checker/report.go
Normal file
|
|
@ -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 <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 .HasStates}}
|
||||
{{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}}
|
||||
{{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>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>`),
|
||||
)
|
||||
594
checker/rules.go
Normal file
594
checker/rules.go
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rule codes emitted by the kerberos rules. Keep these stable; UI / metrics
|
||||
// may match on them.
|
||||
const (
|
||||
CodeSRVOK = "kerberos.srv.ok"
|
||||
CodeNoSRV = "kerberos.srv.missing"
|
||||
CodeKDCReachableOK = "kerberos.kdc.reachable"
|
||||
CodeKDCUnreachable = "kerberos.kdc.unreachable"
|
||||
CodeKDCPartial = "kerberos.kdc.partial"
|
||||
CodeASProbeOK = "kerberos.as.ok"
|
||||
CodeASProbeFailed = "kerberos.as.failed"
|
||||
CodeASWrongRealm = "kerberos.as.wrong_realm"
|
||||
CodeASRepNoPreauth = "kerberos.as.no_preauth"
|
||||
CodeClockSkewOK = "kerberos.clock_skew.ok"
|
||||
CodeClockSkewBad = "kerberos.clock_skew.bad"
|
||||
CodeEnctypesStrong = "kerberos.enctypes.strong"
|
||||
CodeEnctypesWeakOnly = "kerberos.enctypes.weak_only"
|
||||
CodeEnctypesMixed = "kerberos.enctypes.mixed"
|
||||
CodeEnctypesUnknown = "kerberos.enctypes.unknown"
|
||||
CodeKadminDown = "kerberos.kadmin.unreachable"
|
||||
CodeKadminOK = "kerberos.kadmin.ok"
|
||||
CodeKpasswdDown = "kerberos.kpasswd.unreachable"
|
||||
CodeKpasswdOK = "kerberos.kpasswd.ok"
|
||||
CodeAuthSkipped = "kerberos.auth.skipped"
|
||||
CodeAuthTGTOK = "kerberos.auth.tgt_ok"
|
||||
CodeAuthTGTFail = "kerberos.auth.tgt_fail"
|
||||
CodeAuthTGSOK = "kerberos.auth.tgs_ok"
|
||||
CodeAuthTGSFail = "kerberos.auth.tgs_fail"
|
||||
CodeAuthTGSSkipped = "kerberos.auth.tgs_skipped"
|
||||
)
|
||||
|
||||
// Rules returns the full list of CheckRules exposed by the Kerberos checker.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&srvPresenceRule{},
|
||||
&kdcReachabilityRule{},
|
||||
&asProbeRule{},
|
||||
&realmMatchRule{},
|
||||
&preauthRule{},
|
||||
&clockSkewRule{},
|
||||
&enctypesRule{},
|
||||
&kadminRule{},
|
||||
&kpasswdRule{},
|
||||
&authTGTRule{},
|
||||
&authTGSRule{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadData fetches the Kerberos observation. On error, returns a CheckState
|
||||
// the caller should emit to short-circuit its rule.
|
||||
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*KerberosData, *sdk.CheckState) {
|
||||
var data KerberosData
|
||||
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load Kerberos observation: %v", err),
|
||||
Code: "kerberos.observation_error",
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// ── SRV presence ─────────────────────────────────────────────────────────────
|
||||
|
||||
type srvPresenceRule struct{}
|
||||
|
||||
func (r *srvPresenceRule) Name() string { return "kerberos.srv_present" }
|
||||
func (r *srvPresenceRule) Description() string {
|
||||
return "Verifies that at least one _kerberos._tcp / _kerberos._udp SRV record is published for the realm."
|
||||
}
|
||||
|
||||
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
for _, b := range data.SRV {
|
||||
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: "Kerberos SRV records are published.",
|
||||
Code: CodeSRVOK,
|
||||
}}
|
||||
}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
|
||||
Code: CodeNoSRV,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── KDC reachability ─────────────────────────────────────────────────────────
|
||||
|
||||
type kdcReachabilityRule struct{}
|
||||
|
||||
func (r *kdcReachabilityRule) Name() string { return "kerberos.kdc_reachable" }
|
||||
func (r *kdcReachabilityRule) Description() string {
|
||||
return "Verifies that at least one KDC endpoint (TCP/UDP 88) accepts a connection."
|
||||
}
|
||||
|
||||
func (r *kdcReachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
total, reachable := 0, 0
|
||||
for _, p := range data.Probes {
|
||||
if p.Role != "kdc" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if p.OK {
|
||||
reachable++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "No KDC probe was attempted (no SRV target).",
|
||||
Code: CodeKDCUnreachable,
|
||||
}}
|
||||
}
|
||||
if reachable == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: "No KDC is reachable on TCP 88 or UDP 88.",
|
||||
Code: CodeKDCUnreachable,
|
||||
Meta: map[string]any{"total": total},
|
||||
}}
|
||||
}
|
||||
if reachable < total {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("%d/%d KDC endpoints unreachable.", total-reachable, total),
|
||||
Code: CodeKDCPartial,
|
||||
Meta: map[string]any{"reachable": reachable, "total": total},
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("All %d KDC endpoints reachable.", total),
|
||||
Code: CodeKDCReachableOK,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── AS-REQ probe sanity ──────────────────────────────────────────────────────
|
||||
|
||||
type asProbeRule struct{}
|
||||
|
||||
func (r *asProbeRule) Name() string { return "kerberos.as_probe" }
|
||||
func (r *asProbeRule) Description() string {
|
||||
return "Verifies that the anonymous AS-REQ probe received a sane reply (KRB-ERROR or AS-REP)."
|
||||
}
|
||||
|
||||
func (r *asProbeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.AS.Attempted {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "AS-REQ probe not attempted.",
|
||||
Code: CodeASProbeFailed,
|
||||
}}
|
||||
}
|
||||
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: "AS-REQ probe failed: " + data.AS.Error,
|
||||
Code: CodeASProbeFailed,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("KDC replied to AS-REQ (%s).", firstNonEmpty(data.AS.ErrorName, "AS-REP")),
|
||||
Code: CodeASProbeOK,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── Realm echoed in KDC reply ────────────────────────────────────────────────
|
||||
|
||||
type realmMatchRule struct{}
|
||||
|
||||
func (r *realmMatchRule) Name() string { return "kerberos.realm_match" }
|
||||
func (r *realmMatchRule) Description() string {
|
||||
return "Verifies the KDC answers for the expected realm name."
|
||||
}
|
||||
|
||||
func (r *realmMatchRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.AS.ServerRealm == "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC did not echo a realm (probe may have failed).",
|
||||
Code: CodeASWrongRealm,
|
||||
}}
|
||||
}
|
||||
if !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
|
||||
Code: CodeASWrongRealm,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC echoed the expected realm.",
|
||||
Code: "kerberos.realm_match.ok",
|
||||
}}
|
||||
}
|
||||
|
||||
// ── AS-REP without preauth (AS-REP roasting exposure) ───────────────────────
|
||||
|
||||
type preauthRule struct{}
|
||||
|
||||
func (r *preauthRule) Name() string { return "kerberos.preauth_required" }
|
||||
func (r *preauthRule) Description() string {
|
||||
return "Flags KDCs that return an AS-REP without requiring pre-authentication (AS-REP roasting exposure)."
|
||||
}
|
||||
|
||||
func (r *preauthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.AS.Attempted {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "AS-REQ probe not attempted; preauth posture unknown.",
|
||||
Code: CodeASRepNoPreauth,
|
||||
}}
|
||||
}
|
||||
if data.AS.PrincipalFound && !data.AS.PreauthReq {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: "AS-REP returned without preauth (AS-REP roasting exposure).",
|
||||
Code: CodeASRepNoPreauth,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: "Pre-authentication is enforced (or no AS-REP issued).",
|
||||
Code: "kerberos.preauth_required.ok",
|
||||
}}
|
||||
}
|
||||
|
||||
// ── Clock skew ───────────────────────────────────────────────────────────────
|
||||
|
||||
type clockSkewRule struct{}
|
||||
|
||||
func (r *clockSkewRule) Name() string { return "kerberos.clock_skew" }
|
||||
func (r *clockSkewRule) Description() string {
|
||||
return "Verifies the KDC clock is within tolerance of the checker's clock."
|
||||
}
|
||||
|
||||
func (r *clockSkewRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.AS.ServerTime.IsZero() {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC did not return a server time (probe may have failed).",
|
||||
Code: CodeClockSkewBad,
|
||||
}}
|
||||
}
|
||||
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300) * float64(time.Second))
|
||||
if abs(data.AS.ClockSkew) > maxSkew {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s).", round(data.AS.ClockSkew), maxSkew),
|
||||
Code: CodeClockSkewBad,
|
||||
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("Clock skew within tolerance (%s).", round(data.AS.ClockSkew)),
|
||||
Code: CodeClockSkewOK,
|
||||
Meta: map[string]any{"skew_ns": data.AS.ClockSkew.Nanoseconds()},
|
||||
}}
|
||||
}
|
||||
|
||||
// ── Enctypes offered ─────────────────────────────────────────────────────────
|
||||
|
||||
type enctypesRule struct{}
|
||||
|
||||
func (r *enctypesRule) Name() string { return "kerberos.enctypes" }
|
||||
func (r *enctypesRule) Description() string {
|
||||
return "Reviews the encryption types advertised by the KDC, flagging DES/RC4-only configurations."
|
||||
}
|
||||
|
||||
func (r *enctypesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Enctypes) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC did not advertise enctypes (no ETYPE-INFO2 seen).",
|
||||
Code: CodeEnctypesUnknown,
|
||||
}}
|
||||
}
|
||||
hasStrong, hasWeak := false, false
|
||||
var names []string
|
||||
for _, e := range data.Enctypes {
|
||||
names = append(names, e.Name)
|
||||
if e.Weak {
|
||||
hasWeak = true
|
||||
} else {
|
||||
hasStrong = true
|
||||
}
|
||||
}
|
||||
requireStrong := optBool(opts, "requireStrongEnctypes", true)
|
||||
if !hasStrong {
|
||||
status := sdk.StatusWarn
|
||||
if requireStrong {
|
||||
status = sdk.StatusCrit
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: status,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC only advertises weak enctypes (DES/RC4).",
|
||||
Code: CodeEnctypesWeakOnly,
|
||||
Meta: map[string]any{"enctypes": names},
|
||||
}}
|
||||
}
|
||||
if hasWeak {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC advertises weak enctypes alongside strong ones.",
|
||||
Code: CodeEnctypesMixed,
|
||||
Meta: map[string]any{"enctypes": names},
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: "KDC advertises only strong enctypes.",
|
||||
Code: CodeEnctypesStrong,
|
||||
Meta: map[string]any{"enctypes": names},
|
||||
}}
|
||||
}
|
||||
|
||||
// ── kadmin reachability ──────────────────────────────────────────────────────
|
||||
|
||||
type kadminRule struct{}
|
||||
|
||||
func (r *kadminRule) Name() string { return "kerberos.kadmin_reachable" }
|
||||
func (r *kadminRule) Description() string {
|
||||
return "Flags kadmin endpoints that are published via SRV but not reachable."
|
||||
}
|
||||
|
||||
func (r *kadminRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
return roleReachability(data, "kadmin", "kadmin server", CodeKadminOK, CodeKadminDown)
|
||||
}
|
||||
|
||||
// ── kpasswd reachability ─────────────────────────────────────────────────────
|
||||
|
||||
type kpasswdRule struct{}
|
||||
|
||||
func (r *kpasswdRule) Name() string { return "kerberos.kpasswd_reachable" }
|
||||
func (r *kpasswdRule) Description() string {
|
||||
return "Flags kpasswd endpoints that are published via SRV but not reachable."
|
||||
}
|
||||
|
||||
func (r *kpasswdRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
return roleReachability(data, "kpasswd", "kpasswd", CodeKpasswdOK, CodeKpasswdDown)
|
||||
}
|
||||
|
||||
func roleReachability(data *KerberosData, role, label, okCode, downCode string) []sdk.CheckState {
|
||||
total, reachable := 0, 0
|
||||
for _, p := range data.Probes {
|
||||
if p.Role != role {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if p.OK {
|
||||
reachable++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("No %s SRV endpoint published.", label),
|
||||
Code: okCode,
|
||||
}}
|
||||
}
|
||||
if reachable == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("%s unreachable.", label),
|
||||
Code: downCode,
|
||||
}}
|
||||
}
|
||||
if reachable < total {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("%s: %d/%d endpoints unreachable.", label, total-reachable, total),
|
||||
Code: downCode,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("%s reachable.", label),
|
||||
Code: okCode,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── Authenticated probe: TGT acquisition ─────────────────────────────────────
|
||||
|
||||
type authTGTRule struct{}
|
||||
|
||||
func (r *authTGTRule) Name() string { return "kerberos.auth_tgt" }
|
||||
func (r *authTGTRule) Description() string {
|
||||
return "Verifies the supplied principal/password can obtain a TGT (only runs when credentials are supplied)."
|
||||
}
|
||||
|
||||
func (r *authTGTRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Auth == nil || !data.Auth.Attempted {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "Authenticated probe not attempted (no principal/password supplied).",
|
||||
Code: CodeAuthSkipped,
|
||||
}}
|
||||
}
|
||||
if !data.Auth.TGTAcquired {
|
||||
msg := "Authenticated probe: TGT acquisition failed."
|
||||
if data.Auth.Error != "" {
|
||||
msg = "Authenticated probe: " + data.Auth.Error
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Subject: data.Realm,
|
||||
Message: msg,
|
||||
Code: CodeAuthTGTFail,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: "TGT acquired for supplied principal.",
|
||||
Code: CodeAuthTGTOK,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── Authenticated probe: TGS request ─────────────────────────────────────────
|
||||
|
||||
type authTGSRule struct{}
|
||||
|
||||
func (r *authTGSRule) Name() string { return "kerberos.auth_tgs" }
|
||||
func (r *authTGSRule) Description() string {
|
||||
return "Verifies a TGS-REQ succeeds for the supplied target service (only runs when credentials and targetService are supplied)."
|
||||
}
|
||||
|
||||
func (r *authTGSRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Auth == nil || !data.Auth.Attempted {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "TGS probe not attempted (no credentials supplied).",
|
||||
Code: CodeAuthTGSSkipped,
|
||||
}}
|
||||
}
|
||||
if data.Auth.TargetService == "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "TGS probe skipped (no targetService supplied).",
|
||||
Code: CodeAuthTGSSkipped,
|
||||
}}
|
||||
}
|
||||
if !data.Auth.TGTAcquired {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusUnknown,
|
||||
Subject: data.Realm,
|
||||
Message: "TGS probe skipped: TGT not acquired.",
|
||||
Code: CodeAuthTGSSkipped,
|
||||
}}
|
||||
}
|
||||
if !data.Auth.TGSAcquired {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed.", data.Auth.TargetService),
|
||||
Code: CodeAuthTGSFail,
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Subject: data.Realm,
|
||||
Message: fmt.Sprintf("TGS-REQ for %s succeeded.", data.Auth.TargetService),
|
||||
Code: CodeAuthTGSOK,
|
||||
}}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func abs(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func round(d time.Duration) time.Duration {
|
||||
return d.Round(time.Millisecond)
|
||||
}
|
||||
|
||||
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
|
||||
v, ok := opts[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(x)) {
|
||||
case "true", "1", "yes", "y", "on":
|
||||
return true
|
||||
case "false", "0", "no", "n", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
112
checker/types.go
Normal file
112
checker/types.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
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.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
|
||||
)
|
||||
69
go.sum
Normal file
69
go.sum
Normal file
|
|
@ -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=
|
||||
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"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
18
plugin/plugin.go
Normal file
18
plugin/plugin.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue