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
+5 -1
View File
@@ -28,7 +28,6 @@ func NewForTest(
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,
-20
View File
@@ -7,7 +7,6 @@ import (
"slices" "slices"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -59,8 +58,6 @@ type Watcher struct {
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.
@@ -77,7 +74,6 @@ func New(
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)",
+38 -17
View File
@@ -615,35 +615,56 @@ 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()
title := "TLS Expiry Warning: " + host
for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
t.Run(interval.String(), func(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{host}
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = interval
title := "TLS Expiry Warning: " + testHost // The TLS checks read the saved hostname and port state and
// 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,
)
// The second check comes within the TLS interval of the first, expiresInThreeDays(deps)
// so it must not warn again. deps.state.SetHostnameState(host, saved(
var warnings int map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) { },
warnings = countNotifications(deps, title) ))
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{host},
}) })
if warnings == 0 { for check := 1; check <= 3; check++ {
t.Fatal("expected expiry warnings from the first check") w.RunTLSChecks(t.Context())
}
got := countNotifications(deps, title) got := countNotifications(deps, title)
if got != warnings { if got != check {
t.Errorf( t.Fatalf(
"expected %d expiry warnings (dedup), got %d", "after check %d: %d expiry warnings, want %d",
warnings, got, check, got, check,
) )
} }
} }
})
}
}
func TestGracefulShutdown(t *testing.T) { func TestGracefulShutdown(t *testing.T) {
t.Parallel() t.Parallel()