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 }