Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:41:50 +07:00
commit 40a4cf285e
18 changed files with 1933 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-kerberos
checker-kerberos.so

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-kerberos .
FROM scratch
COPY --from=builder /checker-kerberos /checker-kerberos
EXPOSE 8080
ENTRYPOINT ["/checker-kerberos"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
CHECKER_NAME := checker-kerberos
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

25
NOTICE Normal file
View file

@ -0,0 +1,25 @@
checker-kerberos
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software from the gokrb5 project
(https://github.com/jcmturner/gokrb5), licensed under the Apache License,
Version 2.0:
gokrb5
Copyright 2017 Jonathan Turner
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

50
README.md Normal file
View file

@ -0,0 +1,50 @@
# checker-kerberos
happyDomain checker that audits a Kerberos realm from its DNS records.
Starting from the realm name (or from the SRV records grouped under the
`abstract.Kerberos` service), the checker performs a series of
**anonymous probes** — and an optional **authenticated round-trip** when
credentials are supplied — to give a complete picture of the realm's
availability and security posture.
## What gets checked
- SRV layout — `_kerberos._tcp.`, `_kerberos._udp.`,
`_kerberos-master._tcp.`, `_kerberos-adm._tcp.`, `_kpasswd._tcp.`,
`_kpasswd._udp.`.
- Forward resolution of every SRV target (A + AAAA).
- TCP reachability of each KDC/kadmin/kpasswd host.
- UDP reachability of the KDC via a real AS-REQ.
- Anonymous AS-REQ probe: realm confirmation, supported enctypes
(from `ETYPE-INFO2`), PKINIT hint (`PA-PK-AS-REQ`), clock skew.
- Weak enctype detection (DES / RC4).
- Optional authenticated round-trip when `principal` and `password`
are supplied: TGT acquisition then TGS-REQ for `targetService`.
The HTML report surfaces the most common misconfigurations with a
direct remediation hint:
| Failure | Hint |
| --- | --- |
| No SRV records | publish `_kerberos._tcp.REALM. SRV …` |
| SRV target DNS failure | add A/AAAA for the target |
| Port 88 unreachable | open TCP+UDP 88 inbound |
| Clock skew > max | run ntpd/chrony |
| Weak enctypes only | switch to `aes256-cts-hmac-sha1-96` |
| Wrong realm in reply | fix `default_realm` / realm config |
| AS-REP roasting exposure | enable `requires_preauth` |
## Build
```sh
make # standalone binary
make plugin # shared object for happyDomain
make docker # container image
```
## Run
```sh
./checker-kerberos -listen :8080
```

93
checker/auth.go Normal file
View file

@ -0,0 +1,93 @@
package checker
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/jcmturner/gokrb5/v8/client"
"github.com/jcmturner/gokrb5/v8/config"
)
// runAuthProbe performs a Login (AS-REQ + preauth) and, if a targetService
// is supplied, a TGS-REQ. kdcHosts maps reachable KDC hostnames to their
// TCP port so we can populate the krb5 config without doing DNS again.
func runAuthProbe(ctx context.Context, realm, principal, password, targetService string,
kdcHosts map[string]uint16, timeout time.Duration) *AuthProbeResult {
res := &AuthProbeResult{Attempted: true, Principal: principal, TargetService: targetService}
username := principal
if i := strings.LastIndex(principal, "@"); i >= 0 {
username = principal[:i]
}
cfg := config.New()
cfg.LibDefaults.DefaultRealm = realm
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.Clockskew = 5 * time.Minute
cfg.LibDefaults.UDPPreferenceLimit = 1 // force TCP
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cfg.LibDefaults.DefaultTGSEnctypeIDs = preferredEnctypes
cfg.LibDefaults.PermittedEnctypeIDs = preferredEnctypes
realmCfg := config.Realm{Realm: realm}
for host, port := range kdcHosts {
realmCfg.KDC = append(realmCfg.KDC, host+":"+strconv.Itoa(int(port)))
}
cfg.Realms = []config.Realm{realmCfg}
cfg.DomainRealm = config.DomainRealm{strings.ToLower(realm): realm}
start := time.Now()
cl := client.NewWithPassword(username, realm, password, cfg,
client.DisablePAFXFAST(true))
loginErr := cl.Login()
res.Latency = time.Since(start)
if loginErr != nil {
res.Error = loginErr.Error()
if code, ok := extractKRBErrorCode(loginErr); ok {
res.ErrorCode = code
res.ErrorName = errorcodeName(code)
}
return res
}
res.TGTAcquired = true
spn := targetService
if spn == "" {
// Self-test: request a fresh TGT (krbtgt/REALM@REALM).
spn = fmt.Sprintf("krbtgt/%s", realm)
}
if _, _, err := cl.GetServiceTicket(spn); err != nil {
res.Error = fmt.Sprintf("TGS failed for %s: %v", spn, err)
if code, ok := extractKRBErrorCode(err); ok {
res.ErrorCode = code
res.ErrorName = errorcodeName(code)
}
return res
}
res.TGSAcquired = true
return res
}
// extractKRBErrorCode tries to pull a Kerberos error code out of the
// wrapped errors returned by gokrb5. It's best-effort: if the code
// can't be determined, ok is false.
func extractKRBErrorCode(err error) (int32, bool) {
if err == nil {
return 0, false
}
// gokrb5 wraps KRBError values: their Error() string begins with "KRB Error: (N)".
msg := err.Error()
if _, after, ok := strings.Cut(msg, "KRB Error: ("); ok {
if code, _, ok := strings.Cut(after, ")"); ok {
if n, err := strconv.Atoi(code); err == nil {
return int32(n), true
}
}
}
return 0, false
}

516
checker/collect.go Normal file
View file

@ -0,0 +1,516 @@
package checker
import (
"context"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
asn1 "github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/config"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
"github.com/jcmturner/gokrb5/v8/iana/nametype"
"github.com/jcmturner/gokrb5/v8/iana/patype"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// srvPrefixes lists every SRV label we probe, in order. The first two are
// considered mandatory (no realm is usable without one of them).
var srvPrefixes = []struct {
prefix string
role string
proto string
}{
{"_kerberos._tcp.", "kdc", "tcp"},
{"_kerberos._udp.", "kdc", "udp"},
{"_kerberos-master._tcp.", "master", "tcp"},
{"_kerberos-adm._tcp.", "kadmin", "tcp"},
{"_kpasswd._tcp.", "kpasswd", "tcp"},
{"_kpasswd._udp.", "kpasswd", "udp"},
}
// weakEnctypes lists the IDs considered weak per RFC 8429 + common guidance.
var weakEnctypes = map[int32]bool{
etypeID.DES_CBC_CRC: true,
etypeID.DES_CBC_MD4: true,
etypeID.DES_CBC_MD5: true,
etypeID.DES_CBC_RAW: true,
etypeID.DES3_CBC_MD5: true,
etypeID.DES3_CBC_RAW: true,
etypeID.DES3_CBC_SHA1: true,
etypeID.DES_HMAC_SHA1: true,
etypeID.RC4_HMAC: true,
etypeID.RC4_HMAC_EXP: true,
}
// preferredEnctypes is the enctype preference list used in anonymous probes
// and authenticated requests. RC4 is included last so the KDC's ETYPE-INFO2
// reveals whether it still supports it.
var preferredEnctypes = []int32{
etypeID.AES256_CTS_HMAC_SHA1_96,
etypeID.AES128_CTS_HMAC_SHA1_96,
etypeID.AES256_CTS_HMAC_SHA384_192,
etypeID.AES128_CTS_HMAC_SHA256_128,
etypeID.RC4_HMAC,
}
// etypeName returns a human-friendly name for an enctype ID, falling back
// to its numeric value when unknown.
func etypeName(id int32) string {
for name, nid := range etypeID.ETypesByName {
if nid == id {
// Prefer canonical "aes..." / "rc4-hmac" shape
if !strings.Contains(name, "-CmsOID") && !strings.HasSuffix(name, "-EnvOID") {
return name
}
}
}
return fmt.Sprintf("etype-%d", id)
}
func (p *kerberosProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
realmRaw, _ := opts["realm"].(string)
realmRaw = strings.TrimSuffix(realmRaw, ".")
if realmRaw == "" {
return nil, fmt.Errorf("realm is required")
}
realm := strings.ToUpper(realmRaw)
domain := strings.ToLower(realmRaw)
timeoutSec := optFloat(opts, "timeout", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
timeout := time.Duration(timeoutSec * float64(time.Second))
now := time.Now().UTC()
data := &KerberosData{
Realm: realm,
CollectedAt: now,
LocalTime: now,
Resolution: map[string]HostResolution{},
}
// 1. SRV discovery ---------------------------------------------------------
resolver := &net.Resolver{}
data.SRV = make([]SRVBucket, len(srvPrefixes))
var wg sync.WaitGroup
for i, sp := range srvPrefixes {
wg.Go(func() {
bucket := SRVBucket{Prefix: sp.prefix, LookupName: sp.prefix + domain + "."}
lctx, cancel := context.WithTimeout(ctx, timeout)
_, addrs, err := resolver.LookupSRV(lctx, "", "", sp.prefix+domain)
cancel()
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
bucket.NXDomain = true
} else {
bucket.Error = err.Error()
}
}
for _, srv := range addrs {
target := strings.TrimSuffix(srv.Target, ".")
bucket.Records = append(bucket.Records, SRVRecord{
Target: target,
Port: srv.Port,
Priority: srv.Priority,
Weight: srv.Weight,
})
}
data.SRV[i] = bucket
})
}
wg.Wait()
// 2. Forward resolution ----------------------------------------------------
var targets []string
seen := map[string]bool{}
for _, b := range data.SRV {
for _, r := range b.Records {
if !seen[r.Target] {
seen[r.Target] = true
targets = append(targets, r.Target)
}
}
}
resolutions := make([]HostResolution, len(targets))
for i, target := range targets {
wg.Go(func() {
hr := HostResolution{Target: target}
lctx, cancel := context.WithTimeout(ctx, timeout)
ips, err := resolver.LookupIPAddr(lctx, target)
cancel()
if err != nil {
hr.Error = err.Error()
}
for _, ip := range ips {
if ip.IP.To4() != nil {
hr.IPv4 = append(hr.IPv4, ip.IP.String())
} else {
hr.IPv6 = append(hr.IPv6, ip.IP.String())
}
}
resolutions[i] = hr
})
}
wg.Wait()
for _, hr := range resolutions {
data.Resolution[hr.Target] = hr
}
// 3. L4 probes -------------------------------------------------------------
kdcHosts := map[string]uint16{} // target -> tcp port, used to pick the AS-REQ destination
for _, b := range data.SRV {
role := roleForPrefix(b.Prefix)
if strings.HasSuffix(b.Prefix, "._tcp.") {
for _, r := range b.Records {
probe := dialTCP(ctx, r.Target, r.Port, role, timeout)
data.Probes = append(data.Probes, probe)
if role == "kdc" && probe.OK {
if _, ok := kdcHosts[r.Target]; !ok {
kdcHosts[r.Target] = r.Port
}
}
}
}
}
// 4. AS-REQ probe (tries each reachable KDC TCP first, then UDP fallbacks).
asProbe := ASProbeResult{Attempted: true}
asReq, err := buildProbeASReq(realm)
if err != nil {
asProbe.Error = fmt.Sprintf("build AS-REQ: %v", err)
} else {
req, _ := asReq.Marshal()
// Target list: reachable TCP KDCs, then UDP KDCs if nothing else.
var tcpEps, udpEps []endpoint
for _, b := range data.SRV {
if !strings.HasPrefix(b.Prefix, "_kerberos.") {
continue
}
proto := "tcp"
if strings.HasSuffix(b.Prefix, "._udp.") {
proto = "udp"
}
for _, r := range b.Records {
e := endpoint{r.Target, r.Port, proto}
if proto == "tcp" {
tcpEps = append(tcpEps, e)
} else {
udpEps = append(udpEps, e)
}
}
}
eps := append(tcpEps[:len(tcpEps):len(tcpEps)], udpEps...)
for _, e := range eps {
start := time.Now()
var reply []byte
var perr error
if e.proto == "tcp" {
reply, perr = sendASReqTCP(ctx, e.target, e.port, req, timeout)
} else {
reply, perr = sendASReqUDP(ctx, e.target, e.port, req, timeout)
// Track UDP reachability via this attempt.
probe := KDCProbe{
Target: e.target, Port: e.port, Proto: "udp", Role: "kdc",
RTT: time.Since(start),
}
if perr == nil {
probe.OK = true
probe.KrbSeen = true
} else {
probe.Error = perr.Error()
}
data.Probes = append(data.Probes, probe)
}
if perr != nil || len(reply) == 0 {
continue
}
asProbe.Target = e.target
asProbe.Proto = e.proto
parseASResponse(reply, &asProbe)
break
}
if asProbe.Target == "" && asProbe.Error == "" {
asProbe.Error = "no KDC answered our AS-REQ probe"
}
if !asProbe.ServerTime.IsZero() {
asProbe.ClockSkew = time.Since(asProbe.ServerTime)
}
}
data.AS = asProbe
// 5. Roll up enctypes ------------------------------------------------------
for _, e := range asProbe.Enctypes {
if e.Weak {
data.WeakEnctypes = append(data.WeakEnctypes, e)
}
}
data.Enctypes = asProbe.Enctypes
// 6. Optional authenticated round-trip ------------------------------------
principal, _ := opts["principal"].(string)
password, _ := opts["password"].(string)
if principal != "" && password != "" {
data.Auth = runAuthProbe(ctx, realm, principal, password,
stringOpt(opts, "targetService"), kdcHosts, timeout)
}
return data, nil
}
func roleForPrefix(prefix string) string {
switch {
case strings.HasPrefix(prefix, "_kerberos-master."):
return "master"
case strings.HasPrefix(prefix, "_kerberos-adm."):
return "kadmin"
case strings.HasPrefix(prefix, "_kpasswd."):
return "kpasswd"
default:
return "kdc"
}
}
type endpoint struct {
target string
port uint16
proto string
}
func dialTCP(ctx context.Context, target string, port uint16, role string, timeout time.Duration) KDCProbe {
probe := KDCProbe{Target: target, Port: port, Proto: "tcp", Role: role}
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
start := time.Now()
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
probe.RTT = time.Since(start)
if err != nil {
probe.Error = err.Error()
return probe
}
_ = conn.Close()
probe.OK = true
return probe
}
// sendASReqTCP frames the AS-REQ with its 4-byte length prefix (RFC 4120 §7.2.2)
// and reads the response the same way.
func sendASReqTCP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
hdr := make([]byte, 4)
binary.BigEndian.PutUint32(hdr, uint32(len(body)))
if _, err := conn.Write(append(hdr, body...)); err != nil {
return nil, err
}
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
return nil, err
}
n := binary.BigEndian.Uint32(lenBuf)
if n == 0 || n > 1<<20 {
return nil, fmt.Errorf("suspicious reply length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(conn, buf); err != nil {
return nil, err
}
return buf, nil
}
func sendASReqUDP(ctx context.Context, target string, port uint16, body []byte, timeout time.Duration) ([]byte, error) {
addr := net.JoinHostPort(target, strconv.Itoa(int(port)))
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "udp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write(body); err != nil {
return nil, err
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return nil, err
}
return buf[:n], nil
}
// buildProbeASReq builds an AS-REQ for krbtgt/REALM@REALM with a throwaway
// cname. We don't want to depend on reading a local krb5.conf, so we craft
// the request fields by hand from a default config.
func buildProbeASReq(realm string) (messages.ASReq, error) {
cfg := config.New()
cfg.LibDefaults.DefaultRealm = realm
cfg.LibDefaults.NoAddresses = true
cfg.LibDefaults.TicketLifetime = 10 * time.Minute
cfg.LibDefaults.DefaultTktEnctypeIDs = preferredEnctypes
cname := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "probe-happydomain")
return messages.NewASReqForTGT(realm, cfg, cname)
}
// parseASResponse inspects the raw KDC reply and fills the ASProbeResult.
// Expected replies: KRB-ERROR (PREAUTH_REQUIRED / C_PRINCIPAL_UNKNOWN) or,
// less commonly, an AS-REP (principal exists and doesn't require preauth —
// AS-REP roasting territory).
func parseASResponse(raw []byte, out *ASProbeResult) {
// Try KRB-ERROR first.
var krbErr messages.KRBError
if err := krbErr.Unmarshal(raw); err == nil {
out.ErrorCode = krbErr.ErrorCode
out.ErrorName = errorcodeName(krbErr.ErrorCode)
out.ServerRealm = krbErr.Realm
if !krbErr.STime.IsZero() {
out.ServerTime = krbErr.STime.UTC()
}
out.PreauthReq = krbErr.ErrorCode == errorcode.KDC_ERR_PREAUTH_REQUIRED
out.Raw = fmt.Sprintf("KRB-ERROR %s", out.ErrorName)
if len(krbErr.EData) > 0 {
enctypes, pkinit := extractEData(krbErr.EData)
out.Enctypes = enctypes
out.PKINITOffered = pkinit
}
return
}
// Try AS-REP. If this succeeds, preauth wasn't required — surface it.
var asRep messages.ASRep
if err := asRep.Unmarshal(raw); err == nil {
out.PrincipalFound = true
out.ServerRealm = asRep.CRealm
out.Raw = "AS-REP received without preauth (AS-REP roasting exposure)"
return
}
out.Error = "unable to parse KDC reply (" + hex.EncodeToString(first(raw, 16)) + ")"
}
func first(b []byte, n int) []byte {
if len(b) < n {
return b
}
return b[:n]
}
// extractEData parses the METHOD-DATA / PADataSequence that KDCs attach to
// PREAUTH_REQUIRED errors and returns the advertised enctypes + pkinit hint.
func extractEData(edata []byte) ([]EnctypeEntry, bool) {
var pas types.PADataSequence
if _, err := asn1.Unmarshal(edata, &pas); err != nil {
return nil, false
}
var out []EnctypeEntry
pkinit := false
for _, pa := range pas {
switch pa.PADataType {
case patype.PA_ETYPE_INFO2:
var info types.ETypeInfo2
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: e.Salt,
Source: "etype-info2",
})
}
}
case patype.PA_ETYPE_INFO:
var info types.ETypeInfo
if _, err := asn1.Unmarshal(pa.PADataValue, &info); err == nil {
for _, e := range info {
if hasEnctype(out, e.EType) {
continue
}
out = append(out, EnctypeEntry{
ID: e.EType,
Name: etypeName(e.EType),
Weak: weakEnctypes[e.EType],
Salt: string(e.Salt),
Source: "etype-info",
})
}
}
case patype.PA_PK_AS_REQ, patype.PA_PK_AS_REQ_OLD:
pkinit = true
}
}
return out, pkinit
}
func hasEnctype(list []EnctypeEntry, id int32) bool {
for _, e := range list {
if e.ID == id {
return true
}
}
return false
}
func errorcodeName(code int32) string {
s := errorcode.Lookup(code)
if _, after, ok := strings.Cut(s, ") "); ok {
s = after
}
if before, _, ok := strings.Cut(s, " "); ok {
s = before
}
return s
}
// ---- helpers ----------------------------------------------------------------
func optFloat(opts sdk.CheckerOptions, key string, def float64) float64 {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
case string:
if f, err := strconv.ParseFloat(x, 64); err == nil {
return f
}
}
return def
}
func stringOpt(opts sdk.CheckerOptions, key string) string {
s, _ := opts[key].(string)
return s
}

91
checker/definition.go Normal file
View file

@ -0,0 +1,91 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Overridden at link time by the binary/plugin entrypoints.
var Version = "built-in"
// Definition returns the CheckerDefinition for the Kerberos checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "kerberos",
Name: "Kerberos Realm Tester",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Kerberos"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyKerberos},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "realm",
Type: "string",
Label: "Kerberos realm",
Placeholder: "EXAMPLE.COM",
AutoFill: sdk.AutoFillDomainName,
Required: true,
Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).",
},
{
Id: "principal",
Type: "string",
Label: "Principal (optional)",
Placeholder: "user@EXAMPLE.COM",
Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.",
},
{
Id: "password",
Type: "string",
Label: "Password (optional)",
Secret: true,
Description: "Password for the principal above. Used once per run; never stored by the checker.",
},
{
Id: "targetService",
Type: "string",
Label: "Service to request (TGS)",
Placeholder: "host/host.example.com",
Default: "",
Description: "SPN requested via TGS-REQ once a TGT is acquired. Defaults to krbtgt (realm self-test).",
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "timeout",
Type: "number",
Label: "Per-probe timeout (seconds)",
Default: 5,
},
{
Id: "requireStrongEnctypes",
Type: "bool",
Label: "Require strong enctypes",
Default: true,
Description: "Flag realms that only advertise DES/RC4 as CRIT.",
},
{
Id: "maxClockSkew",
Type: "number",
Label: "Max tolerated clock skew (seconds)",
Default: 300,
Description: "Default Kerberos tolerance is 300s; tighter values surface drift earlier.",
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

92
checker/interactive.go Normal file
View file

@ -0,0 +1,92 @@
package checker
import (
"errors"
"net/http"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the minimal set of fields a human needs to fire a
// standalone Kerberos probe via GET /check.
func (p *kerberosProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "realm",
Type: "string",
Label: "Kerberos realm",
Placeholder: "EXAMPLE.COM",
Required: true,
Description: "DNS domain advertising the realm (the realm name itself is derived in uppercase).",
},
{
Id: "principal",
Type: "string",
Label: "Principal (optional)",
Placeholder: "user@EXAMPLE.COM",
Description: "Supply to run an authenticated round-trip. Leave blank for anonymous probes only.",
},
{
Id: "password",
Type: "string",
Label: "Password (optional)",
Secret: true,
},
{
Id: "targetService",
Type: "string",
Label: "Service to request (TGS)",
Placeholder: "host/host.example.com",
},
{
Id: "timeout",
Type: "number",
Label: "Per-probe timeout (seconds)",
Default: 5,
},
{
Id: "requireStrongEnctypes",
Type: "bool",
Label: "Require strong enctypes",
Default: true,
},
{
Id: "maxClockSkew",
Type: "number",
Label: "Max tolerated clock skew (seconds)",
Default: 300,
},
}
}
// ParseForm turns the submitted form into a CheckerOptions. Collect handles
// the SRV / DNS discovery itself, so there is nothing to auto-fill here
// beyond the raw inputs.
func (p *kerberosProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
realm := strings.TrimSpace(r.FormValue("realm"))
if realm == "" {
return nil, errors.New("realm is required")
}
opts := sdk.CheckerOptions{"realm": realm}
if v := strings.TrimSpace(r.FormValue("principal")); v != "" {
opts["principal"] = v
}
if v := r.FormValue("password"); v != "" {
opts["password"] = v
}
if v := strings.TrimSpace(r.FormValue("targetService")); v != "" {
opts["targetService"] = v
}
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
opts["timeout"] = v
}
if v := strings.TrimSpace(r.FormValue("maxClockSkew")); v != "" {
opts["maxClockSkew"] = v
}
opts["requireStrongEnctypes"] = r.FormValue("requireStrongEnctypes") == "true"
return opts, nil
}

21
checker/provider.go Normal file
View file

@ -0,0 +1,21 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new Kerberos observation provider.
func Provider() sdk.ObservationProvider {
return &kerberosProvider{}
}
type kerberosProvider struct{}
func (p *kerberosProvider) Key() sdk.ObservationKey {
return ObservationKeyKerberos
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *kerberosProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

507
checker/report.go Normal file
View file

@ -0,0 +1,507 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"sort"
"strings"
"time"
)
// ── HTML report ───────────────────────────────────────────────────────────────
type remediation struct {
Title string
Body template.HTML
}
type probeRow struct {
Target string
Port uint16
Proto string
Role string
OK bool
RTT string
Error string
KrbSeen bool
}
type resolvedHost struct {
Target string
IPv4 []string
IPv6 []string
Error string
}
type enctypeChip struct {
Name string
Weak bool
}
type srvView struct {
Prefix string
Lookup string
Records []SRVRecord
NXDomain bool
Error string
}
type reportData struct {
Realm string
OverallOK bool
CollectedAt string
LocalTime string
ServerTime string
ClockSkew string
ClockSkewBad bool
SRVBuckets []srvView
Resolution []resolvedHost
Probes []probeRow
ASProbe ASProbeResult
ASErrorName string
PreauthReq bool
PKINITOffered bool
PrincipalFound bool
Enctypes []enctypeChip
HasWeakOnly bool
HasMixedCrypto bool
HasEnctypes bool
AuthProbe *AuthProbeResult
Remediations []remediation
}
func fmtDur(d time.Duration) string {
if d == 0 {
return "-"
}
return d.Round(time.Millisecond).String()
}
func (p *kerberosProvider) GetHTMLReport(raw json.RawMessage) (string, error) {
var r KerberosData
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("failed to unmarshal kerberos report: %w", err)
}
rd := reportData{
Realm: r.Realm,
OverallOK: r.OverallOK,
CollectedAt: r.CollectedAt.Format(time.RFC3339),
LocalTime: r.LocalTime.Format(time.RFC3339),
ASProbe: r.AS,
ASErrorName: r.AS.ErrorName,
PreauthReq: r.AS.PreauthReq,
AuthProbe: r.Auth,
}
if !r.AS.ServerTime.IsZero() {
rd.ServerTime = r.AS.ServerTime.Format(time.RFC3339)
}
if r.AS.ClockSkew != 0 {
rd.ClockSkew = fmtDur(r.AS.ClockSkew)
if r.AS.ClockSkew > 5*time.Minute || r.AS.ClockSkew < -5*time.Minute {
rd.ClockSkewBad = true
}
}
rd.PKINITOffered = r.AS.PKINITOffered
rd.PrincipalFound = r.AS.PrincipalFound
for _, b := range r.SRV {
rd.SRVBuckets = append(rd.SRVBuckets, srvView{
Prefix: b.Prefix,
Lookup: b.LookupName,
Records: b.Records,
NXDomain: b.NXDomain,
Error: b.Error,
})
}
hosts := make([]string, 0, len(r.Resolution))
for h := range r.Resolution {
hosts = append(hosts, h)
}
sort.Strings(hosts)
for _, h := range hosts {
v := r.Resolution[h]
rd.Resolution = append(rd.Resolution, resolvedHost{
Target: v.Target,
IPv4: v.IPv4,
IPv6: v.IPv6,
Error: v.Error,
})
}
for _, p := range r.Probes {
rd.Probes = append(rd.Probes, probeRow{
Target: p.Target, Port: p.Port, Proto: p.Proto, Role: p.Role,
OK: p.OK, RTT: fmtDur(p.RTT), Error: p.Error, KrbSeen: p.KrbSeen,
})
}
// Enctype chips + classification flags.
hasStrong := false
for _, e := range r.Enctypes {
rd.Enctypes = append(rd.Enctypes, enctypeChip{Name: e.Name, Weak: e.Weak})
if !e.Weak {
hasStrong = true
}
}
rd.HasEnctypes = len(r.Enctypes) > 0
if rd.HasEnctypes && !hasStrong {
rd.HasWeakOnly = true
}
if rd.HasEnctypes && hasStrong && len(r.WeakEnctypes) > 0 {
rd.HasMixedCrypto = true
}
// Detect common failures and build the remediation banner.
rd.Remediations = buildRemediations(&r, rd)
var buf strings.Builder
if err := kerberosHTMLTemplate.Execute(&buf, rd); err != nil {
return "", fmt.Errorf("failed to render kerberos HTML report: %w", err)
}
return buf.String(), nil
}
// buildRemediations inspects the observation and returns actionable hints
// for the user-visible failures. Only matching hints are appended, so a
// healthy realm shows an empty list (rendered as nothing).
func buildRemediations(r *KerberosData, rd reportData) []remediation {
var out []remediation
hasKDCSRV := false
for _, b := range r.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
hasKDCSRV = true
break
}
}
if !hasKDCSRV {
out = append(out, remediation{
Title: "Publish Kerberos SRV records",
Body: template.HTML(fmt.Sprintf(
`No <code>_kerberos._tcp.%[1]s</code> or <code>_kerberos._udp.%[1]s</code> records exist. Publish at minimum:<br>
<pre>_kerberos._tcp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.
_kerberos._udp.%[1]s. IN SRV 0 0 88 kdc.%[1]s.</pre>`, strings.ToLower(r.Realm))),
})
}
// SRV targets that don't resolve.
var unresolved []string
for _, h := range rd.Resolution {
if h.Error != "" || (len(h.IPv4) == 0 && len(h.IPv6) == 0) {
unresolved = append(unresolved, h.Target)
}
}
if len(unresolved) > 0 {
out = append(out, remediation{
Title: "Resolve KDC host names",
Body: template.HTML(fmt.Sprintf(
`The following SRV target(s) do not resolve to an IP address: <code>%s</code>. Add A/AAAA records for each host, or correct the SRV target.`,
template.HTMLEscapeString(strings.Join(unresolved, ", ")))),
})
}
// No KDC reachable (port filtered / host down).
reachable := 0
totalKDC := 0
for _, p := range r.Probes {
if p.Role == "kdc" {
totalKDC++
if p.OK {
reachable++
}
}
}
if totalKDC > 0 && reachable == 0 {
out = append(out, remediation{
Title: "Open port 88 on KDC hosts",
Body: template.HTML(`Every KDC endpoint refused or timed out. Ensure your firewall allows inbound <code>TCP 88</code> and <code>UDP 88</code>, and that the KDC process is listening on the SRV target's IP.`),
})
}
// Clock skew.
if rd.ClockSkewBad {
out = append(out, remediation{
Title: "Synchronize clocks",
Body: template.HTML(fmt.Sprintf(
`KDC clock differs from this checker by <strong>%s</strong>. Kerberos denies authentication once skew exceeds 5 minutes; run <code>ntpd</code> or <code>chronyd</code> on the KDC and its clients.`,
template.HTMLEscapeString(rd.ClockSkew))),
})
}
// Weak crypto only.
if rd.HasWeakOnly {
out = append(out, remediation{
Title: "Retire DES/RC4 enctypes",
Body: template.HTML(`The KDC only advertises weak encryption types. Configure at least AES:
<pre>[libdefaults]
default_tkt_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
default_tgs_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96
permitted_enctypes = aes256-cts-hmac-sha1-96 aes128-cts-hmac-sha1-96</pre>
then rekey principals with <code>kadmin -q "cpw -randkey principal"</code> or equivalent.`),
})
}
// Wrong realm in KDC reply.
if r.AS.ServerRealm != "" && !strings.EqualFold(r.AS.ServerRealm, r.Realm) {
out = append(out, remediation{
Title: "Fix realm mismatch",
Body: template.HTML(fmt.Sprintf(
`The KDC answered for realm <code>%s</code> instead of <code>%s</code>. Align <code>default_realm</code> in <code>krb5.conf</code> and the <code>[realms]</code> stanza in <code>kdc.conf</code> with the SRV-published realm name.`,
template.HTMLEscapeString(r.AS.ServerRealm),
template.HTMLEscapeString(r.Realm))),
})
}
// AS-REP without preauth — AS-REP roasting.
if r.AS.Attempted && r.AS.PrincipalFound && !r.AS.PreauthReq {
out = append(out, remediation{
Title: "Enable pre-authentication",
Body: template.HTML(`The KDC returned an AS-REP without demanding pre-authentication. This exposes principals to <strong>AS-REP roasting</strong>. Enable <code>requires_preauth</code> (MIT) or the equivalent flag on every user principal, e.g. <code>kadmin -q "modprinc +requires_preauth user@REALM"</code>.`),
})
}
return out
}
var kerberosHTMLTemplate = template.Must(
template.New("kerberos").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kerberos Realm Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
pre {
font-family: ui-monospace, monospace; font-size: .82em;
background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px;
padding: .55rem .7rem; overflow-x: auto; margin: .35rem 0 0;
}
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd {
background: #fff; border-radius: 10px;
padding: 1rem 1.25rem; margin-bottom: .75rem;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.neutral { background: #e5e7eb; color: #374151; }
.realm { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.realm code { color: #111827; }
.section {
background: #fff; border-radius: 8px;
padding: .85rem 1rem; margin-bottom: .6rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07);
}
.reme {
background: #fff7ed; border: 1px solid #fdba74; border-left: 4px solid #f97316;
border-radius: 8px; padding: .75rem 1rem; margin-bottom: .6rem;
}
.reme h2 { color: #9a3412; }
.reme-item { padding: .55rem 0; border-top: 1px dashed #fdba74; }
.reme-item:first-of-type { border-top: none; padding-top: .25rem; }
.reme-item h3 { color: #9a3412; margin-bottom: .25rem; }
details {
background: #fff; border-radius: 8px; margin-bottom: .45rem;
box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden;
}
.section details {
box-shadow: none; border-radius: 6px;
border: 1px solid #e5e7eb; margin-bottom: .4rem;
}
summary {
display: flex; align-items: center; gap: .5rem;
padding: .55rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶"; font-size: .65rem; color: #9ca3af;
transition: transform .15s; flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.srv-title { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .88rem; }
.details-body { padding: .55rem 1rem .8rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.note { color: #6b7280; font-size: .85rem; }
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
.chip {
display: inline-block; padding: .15em .6em; margin: .12em .2em .12em 0;
border-radius: 9999px; font-size: .78rem; font-weight: 600;
background: #e0e7ff; color: #3730a3;
}
.chip.weak { background: #fee2e2; color: #991b1b; }
.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: .8rem; row-gap: .1rem; font-size: .85rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
</style>
</head>
<body>
<div class="hd">
<h1>Kerberos Realm</h1>
{{if .OverallOK}}<span class="badge ok">Realm OK</span>
{{else if .Remediations}}<span class="badge fail">Issues detected</span>
{{else}}<span class="badge warn">Needs attention</span>{{end}}
<div class="realm">Realm: <code>{{.Realm}}</code>{{if .ASProbe.Target}} &middot; probed via <code>{{.ASProbe.Target}}</code> ({{.ASProbe.Proto}}){{end}}</div>
</div>
{{if .Remediations}}
<div class="reme">
<h2>Most common issues &mdash; 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">&#10003;</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">&#10003;</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</td>
<td>{{.Role}}</td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Proto}}</td>
<td>{{.RTT}}</td>
<td>{{if .Error}}<span class="errmsg">{{.Error}}</span>{{else if .KrbSeen}}KRB reply received{{else}}port open{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
<div class="section">
<h2>AS-REQ probe</h2>
{{if .ASProbe.Attempted}}
{{if .ASProbe.Error}}
<p class="errmsg">{{.ASProbe.Error}}</p>
{{else}}
<dl class="kv">
<dt>Response</dt><dd><code>{{.ASErrorName}}</code> {{if .PreauthReq}}<span class="badge ok">preauth required</span>{{end}}{{if .PrincipalFound}}<span class="badge warn">AS-REP without preauth</span>{{end}}</dd>
<dt>Realm echoed</dt><dd><code>{{.ASProbe.ServerRealm}}</code></dd>
<dt>Server time</dt><dd>{{.ServerTime}}</dd>
<dt>Local time</dt><dd>{{.LocalTime}}</dd>
<dt>Clock skew</dt><dd>{{if .ClockSkewBad}}<span class="check-fail">{{.ClockSkew}}</span>{{else}}{{.ClockSkew}}{{end}}</dd>
<dt>PKINIT offered</dt><dd>{{if .PKINITOffered}}<span class="check-ok">yes</span>{{else}}no{{end}}</dd>
</dl>
{{end}}
{{else}}
<p class="note">Probe not attempted.</p>
{{end}}
</div>
{{if .HasEnctypes}}
<div class="section">
<h2>Advertised enctypes {{if .HasWeakOnly}}<span class="badge fail">weak only</span>{{else if .HasMixedCrypto}}<span class="badge warn">mixed</span>{{else}}<span class="badge ok">strong</span>{{end}}</h2>
{{range .Enctypes}}<span class="chip{{if .Weak}} weak{{end}}">{{.Name}}</span>{{end}}
</div>
{{end}}
{{if .AuthProbe}}
<div class="section">
<h2>Authenticated round-trip</h2>
<dl class="kv">
<dt>Principal</dt><dd><code>{{.AuthProbe.Principal}}</code></dd>
<dt>TGT</dt><dd>{{if .AuthProbe.TGTAcquired}}<span class="check-ok">&#10003; acquired</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if .AuthProbe.TargetService}}<dt>TGS ({{.AuthProbe.TargetService}})</dt><dd>{{if .AuthProbe.TGSAcquired}}<span class="check-ok">&#10003; acquired</span>{{else}}<span class="check-fail">&#10007;</span>{{end}}</dd>{{end}}
{{if .AuthProbe.ErrorName}}<dt>KDC error</dt><dd><code>{{.AuthProbe.ErrorName}}</code></dd>{{end}}
{{if .AuthProbe.Error}}<dt>Detail</dt><dd><span class="errmsg">{{.AuthProbe.Error}}</span></dd>{{end}}
</dl>
</div>
{{end}}
</body>
</html>`),
)

224
checker/rule.go Normal file
View file

@ -0,0 +1,224 @@
package checker
import (
"context"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the kerberos_health rule.
func Rule() sdk.CheckRule {
return &kerberosRule{}
}
type kerberosRule struct{}
func (r *kerberosRule) Name() string {
return "kerberos_health"
}
func (r *kerberosRule) Description() string {
return "Checks whether a Kerberos realm answers correctly, advertises strong crypto, and exposes no obvious misconfiguration."
}
func (r *kerberosRule) ValidateOptions(opts sdk.CheckerOptions) error { return nil }
func (r *kerberosRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data KerberosData
if err := obs.Get(ctx, ObservationKeyKerberos, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to get Kerberos data: %v", err),
Code: "kerberos_error",
}}
}
maxSkew := time.Duration(optFloat(opts, "maxClockSkew", 300)) * time.Second
requireStrong := optBool(opts, "requireStrongEnctypes", true)
// Presence of at least one _kerberos._tcp or ._udp record is mandatory.
hasKDCSRV := false
for _, b := range data.SRV {
if strings.HasPrefix(b.Prefix, "_kerberos.") && len(b.Records) > 0 {
hasKDCSRV = true
break
}
}
if !hasKDCSRV {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("No _kerberos SRV records found for %s", data.Realm),
Code: "kerberos_no_srv",
}}
}
// KDC reachability: need at least one successful probe among KDC roles.
reachable := 0
kdcProbes := 0
kadminDown, kpasswdDown := false, false
for _, p := range data.Probes {
switch p.Role {
case "kdc":
kdcProbes++
if p.OK {
reachable++
}
case "kadmin":
if !p.OK {
kadminDown = true
}
case "kpasswd":
if !p.OK {
kpasswdDown = true
}
}
}
if reachable == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "No KDC is reachable on TCP 88 or UDP 88",
Code: "kerberos_kdc_unreachable",
}}
}
// AS-REQ result.
if data.AS.Attempted {
if data.AS.Error != "" && data.AS.ErrorCode == 0 {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "AS-REQ probe failed: " + data.AS.Error,
Code: "kerberos_error",
}}
}
if data.AS.ServerRealm != "" && !strings.EqualFold(data.AS.ServerRealm, data.Realm) {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("KDC replied for realm %q, expected %q", data.AS.ServerRealm, data.Realm),
Code: "kerberos_wrong_realm",
}}
}
if abs(data.AS.ClockSkew) > maxSkew {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: fmt.Sprintf("Clock skew with KDC is %s (max %s)", round(data.AS.ClockSkew), maxSkew),
Code: "kerberos_clock_skew",
Meta: map[string]any{
"skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
}
// Crypto posture.
hasStrong := false
for _, e := range data.Enctypes {
if !e.Weak {
hasStrong = true
break
}
}
if requireStrong && len(data.Enctypes) > 0 && !hasStrong {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "KDC only advertises weak enctypes (DES/RC4)",
Code: "kerberos_weak_crypto",
}}
}
// Auth probe (if any).
if data.Auth != nil && data.Auth.Attempted {
if !data.Auth.TGTAcquired {
return []sdk.CheckState{{
Status: sdk.StatusCrit,
Subject: data.Realm,
Message: "Authenticated probe: TGT acquisition failed",
Code: "kerberos_auth_fail",
}}
}
if data.Auth.TargetService != "" && !data.Auth.TGSAcquired {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("TGT OK but TGS-REQ for %s failed", data.Auth.TargetService),
Code: "kerberos_tgs_fail",
}}
}
}
// Warnings: partial reachability, no UDP, mixed crypto, no preauth.
var warnings []string
if reachable < kdcProbes {
warnings = append(warnings, fmt.Sprintf("%d/%d KDC endpoints unreachable", kdcProbes-reachable, kdcProbes))
}
if len(data.WeakEnctypes) > 0 && hasStrong {
warnings = append(warnings, "KDC also advertises weak enctypes alongside strong ones")
}
if data.AS.Attempted && data.AS.PrincipalFound && !data.AS.PreauthReq {
warnings = append(warnings, "AS-REP returned without preauth (AS-REP roasting exposure)")
}
if kadminDown {
warnings = append(warnings, "kadmin server unreachable")
}
if kpasswdDown {
warnings = append(warnings, "kpasswd unreachable")
}
if len(warnings) > 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s reachable: %s", data.Realm, strings.Join(warnings, "; ")),
Code: "kerberos_warn",
Meta: map[string]any{
"reachable_kdcs": reachable,
"warnings": warnings,
},
}}
}
return []sdk.CheckState{{
Status: sdk.StatusOK,
Subject: data.Realm,
Message: fmt.Sprintf("Realm %s healthy (%d KDC reachable, strong crypto)", data.Realm, reachable),
Code: "kerberos_ok",
Meta: map[string]any{
"realm": data.Realm,
"reachable_kdcs": reachable,
"clock_skew_ns": data.AS.ClockSkew.Nanoseconds(),
},
}}
}
func abs(d time.Duration) time.Duration {
if d < 0 {
return -d
}
return d
}
func round(d time.Duration) time.Duration {
return d.Round(time.Millisecond)
}
func optBool(opts sdk.CheckerOptions, key string, def bool) bool {
v, ok := opts[key]
if !ok {
return def
}
switch x := v.(type) {
case bool:
return x
case string:
return x == "true" || x == "1"
}
return def
}

120
checker/types.go Normal file
View file

@ -0,0 +1,120 @@
// Package checker implements the Kerberos realm checker for happyDomain.
//
// Given a realm / DNS domain, the checker performs a sequence of anonymous
// probes (SRV layout, KDC reachability, AS-REQ exchange, enctype & clock-
// skew discovery) and optionally an authenticated round-trip. Results are
// stored as a single KerberosData observation and rendered as an HTML
// report.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyKerberos is the observation key for Kerberos realm test data.
const ObservationKeyKerberos sdk.ObservationKey = "kerberos_realm"
// SRVRecord mirrors a *dns.SRV row we want to report on.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
}
// SRVBucket is the resolution outcome for one SRV prefix.
type SRVBucket struct {
Prefix string `json:"prefix"`
Records []SRVRecord `json:"records,omitempty"`
// Error describes why the lookup failed. NXDOMAIN is reported as an
// empty slice + Error="" so the UI can tell "no records" from
// "lookup failed".
Error string `json:"error,omitempty"`
NXDomain bool `json:"nxdomain,omitempty"`
LookupName string `json:"lookupName"`
}
// HostResolution is the A/AAAA resolution outcome for a single SRV target.
type HostResolution struct {
Target string `json:"target"`
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
Error string `json:"error,omitempty"`
}
// KDCProbe captures the outcome of a single L4 probe.
type KDCProbe struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Proto string `json:"proto"` // "tcp" | "udp"
Role string `json:"role"` // "kdc" | "kadmin" | "kpasswd" | "master"
OK bool `json:"ok"`
RTT time.Duration `json:"rtt_ns"`
Error string `json:"error,omitempty"`
KrbSeen bool `json:"krbSeen,omitempty"` // true when a KRB message was actually parsed
}
// EnctypeEntry describes one advertised enctype.
type EnctypeEntry struct {
ID int32 `json:"id"`
Name string `json:"name"`
Weak bool `json:"weak,omitempty"`
Salt string `json:"salt,omitempty"`
Source string `json:"source"` // "etype-info2" | "etype-info" | "pw-salt"
}
// ASProbeResult summarizes what the AS-REQ probe taught us.
type ASProbeResult struct {
Attempted bool `json:"attempted"`
Target string `json:"target,omitempty"`
Proto string `json:"proto,omitempty"`
ErrorCode int32 `json:"errorCode,omitempty"`
ErrorName string `json:"errorName,omitempty"`
ServerRealm string `json:"serverRealm,omitempty"`
ServerTime time.Time `json:"serverTime,omitempty"`
ClockSkew time.Duration `json:"clockSkew_ns,omitempty"`
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
PreauthReq bool `json:"preauthRequired"`
PKINITOffered bool `json:"pkinitOffered,omitempty"`
PrincipalFound bool `json:"principalFound,omitempty"` // KDC returned an AS-REP without preauth (rare / AS-REP roasting exposure)
Raw string `json:"raw,omitempty"` // informative: e.g. "KRB-ERROR (25) PREAUTH_REQUIRED"
Error string `json:"error,omitempty"`
}
// AuthProbeResult is filled only when credentials were supplied.
type AuthProbeResult struct {
Attempted bool `json:"attempted"`
Principal string `json:"principal,omitempty"`
TGTAcquired bool `json:"tgtAcquired"`
TGSAcquired bool `json:"tgsAcquired"`
TargetService string `json:"targetService,omitempty"`
Latency time.Duration `json:"latency_ns,omitempty"`
ErrorCode int32 `json:"errorCode,omitempty"`
ErrorName string `json:"errorName,omitempty"`
Error string `json:"error,omitempty"`
}
// KerberosData is the full observation payload.
type KerberosData struct {
Realm string `json:"realm"`
CollectedAt time.Time `json:"collectedAt"`
LocalTime time.Time `json:"localTime"`
SRV []SRVBucket `json:"srv"`
Resolution map[string]HostResolution `json:"resolution,omitempty"`
Probes []KDCProbe `json:"probes,omitempty"`
AS ASProbeResult `json:"as"`
Auth *AuthProbeResult `json:"auth,omitempty"`
Enctypes []EnctypeEntry `json:"enctypes,omitempty"`
WeakEnctypes []EnctypeEntry `json:"weakEnctypes,omitempty"`
// OverallOK is the rule's summary verdict; set by the rule, not the
// collector. Stored here for the HTML report which is rendered from
// the observation alone.
OverallOK bool `json:"overallOK"`
Warnings []string `json:"warnings,omitempty"`
Errors []string `json:"errors,omitempty"`
}

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module git.happydns.org/checker-kerberos
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.2.0
github.com/jcmturner/gofork v1.7.6
github.com/jcmturner/gokrb5/v8 v8.4.4
)
require (
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
)

69
go.sum Normal file
View file

@ -0,0 +1,69 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
kerberos "git.happydns.org/checker-kerberos/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
kerberos.Version = Version
server := sdk.NewServer(kerberos.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

17
plugin/plugin.go Normal file
View file

@ -0,0 +1,17 @@
// Command plugin is the happyDomain plugin entrypoint for the Kerberos checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
kerberos "git.happydns.org/checker-kerberos/checker"
sdk "git.happydns.org/checker-sdk-go/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
kerberos.Version = Version
return kerberos.Definition(), kerberos.Provider(), nil
}