watcher: warn of an expiring certificate on every TLS check (closes #204) #208

Merged
clawbot merged 1 commits from issue-204-expiry-warning-rule into next 2026-10-02 01:58:28 +02:00
4 changed files with 74 additions and 67 deletions
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-01: a certificate within the expiry warning period is warned about on
every TLS check, where some checks used to skip it at random (closes #204).
- 2026-10-01: a domain's NS set is its delegation from the parent zone's - 2026-10-01: a domain's NS set is its delegation from the parent zone's
servers, not whichever of its own servers answered first (closes #200). servers, not whichever of its own servers answered first (closes #200).
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its - 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
+13 -9
View File
@@ -20,15 +20,14 @@ func NewForTest(
n Notifier, n Notifier,
) *Watcher { ) *Watcher {
return &Watcher{ return &Watcher{
log: slog.Default(), log: slog.Default(),
config: cfg, config: cfg,
state: st, state: st,
resolver: res, resolver: res,
portCheck: pc, portCheck: pc,
tlsCheck: tc, tlsCheck: tc,
notify: n, notify: n,
firstRun: true, firstRun: true,
expiryNotified: make(map[string]time.Time),
} }
} }
@@ -72,6 +71,11 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx) w.checkAllPorts(ctx)
} }
// RunTLSChecks exports runTLSChecks for testing.
func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx)
}
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
+18 -38
View File
@@ -7,7 +7,6 @@ import (
"slices" "slices"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -49,18 +48,16 @@ type Params struct {
// Watcher orchestrates all monitoring checks on a schedule. // Watcher orchestrates all monitoring checks on a schedule.
type Watcher struct { type Watcher struct {
log *slog.Logger log *slog.Logger
config *config.Config config *config.Config
state *state.State state *state.State
resolver DNSResolver resolver DNSResolver
portCheck PortChecker portCheck PortChecker
tlsCheck TLSChecker tlsCheck TLSChecker
notify Notifier notify Notifier
cancel context.CancelFunc cancel context.CancelFunc
done chan struct{} // closed when Run returns done chan struct{} // closed when Run returns
firstRun bool firstRun bool
expiryNotifiedMu sync.Mutex
expiryNotified map[string]time.Time
} }
// New creates a new Watcher instance wired into the fx lifecycle. // New creates a new Watcher instance wired into the fx lifecycle.
@@ -69,15 +66,14 @@ func New(
params Params, params Params,
) (*Watcher, error) { ) (*Watcher, error) {
w := &Watcher{ w := &Watcher{
log: params.Logger.Get(), log: params.Logger.Get(),
config: params.Config, config: params.Config,
state: params.State, state: params.State,
resolver: params.Resolver, resolver: params.Resolver,
portCheck: params.PortCheck, portCheck: params.PortCheck,
tlsCheck: params.TLSCheck, tlsCheck: params.TLSCheck,
notify: params.Notify, notify: params.Notify,
firstRun: true, firstRun: true,
expiryNotified: make(map[string]time.Time),
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{
@@ -1028,22 +1024,6 @@ func (w *Watcher) checkTLSExpiry(
return return
} }
// Deduplicate expiry warnings: don't re-notify for the same
// hostname within the TLS check interval.
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
w.expiryNotifiedMu.Lock()
lastNotified, seen := w.expiryNotified[dedupKey]
if seen && time.Since(lastNotified) < w.config.TLSInterval {
w.expiryNotifiedMu.Unlock()
return
}
w.expiryNotified[dedupKey] = time.Now()
w.expiryNotifiedMu.Unlock()
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nCN: %s\n"+ "Host: %s\nIP: %s\nCN: %s\n"+
"Expires: %s (%.0f days)", "Expires: %s (%.0f days)",
+41 -20
View File
@@ -615,33 +615,54 @@ func TestTLSExpiryWarning(t *testing.T) {
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
} }
func TestTLSExpiryWarningDedup(t *testing.T) { // TestTLSExpiryWarningEachCheck runs the TLS checks three times in a
// row on hostname and port state built here, for a certificate that
// expires within the warning period. Each check warns once, whether the
// TLS interval is a nanosecond, shorter than the time between two
// checks, or a day, longer than it.
func TestTLSExpiryWarningEachCheck(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) title := "TLS Expiry Warning: " + host
cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour
title := "TLS Expiry Warning: " + testHost for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
t.Run(interval.String(), func(t *testing.T) {
t.Parallel()
// The second check comes within the TLS interval of the first, cfg := defaultTestConfig(t)
// so it must not warn again. cfg.Hostnames = []string{host}
var warnings int cfg.TLSInterval = interval
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) { // The TLS checks read the saved hostname and port state and
warnings = countNotifications(deps, title) // look nothing up, so the watcher has no resolver.
}) deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
if warnings == 0 { expiresInThreeDays(deps)
t.Fatal("expected expiry warnings from the first check") deps.state.SetHostnameState(host, saved(
} map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{host},
})
got := countNotifications(deps, title) for check := 1; check <= 3; check++ {
if got != warnings { w.RunTLSChecks(t.Context())
t.Errorf(
"expected %d expiry warnings (dedup), got %d", got := countNotifications(deps, title)
warnings, got, if got != check {
) t.Fatalf(
"after check %d: %d expiry warnings, want %d",
check, got, check,
)
}
}
})
} }
} }