594 lines
18 KiB
Go
594 lines
18 KiB
Go
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 ""
|
|
}
|