watcher: warn of an expiring certificate on every TLS check (closes #204)
check / check (push) Failing after 2m1s
check / check (push) Failing after 2m1s
An expiry warning was skipped when the last one for that hostname and address was sent less than DNSWATCHER_TLS_INTERVAL ago. Each TLS check runs after a DNS pass of varying length, so two checks can be less than the interval apart, and a certificate about to expire was warned about on every check or every other check, at random. TLS checks already start once per interval, so the in-memory record of when each warning was sent is removed and every check warns, as the README says. The test that expected the second check to stay silent is replaced by one that runs TLS checks on state built in the test, with no DNS. Model: opus-5-5
This commit was merged in pull request #208.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user