watcher: warn of an expiring certificate on every TLS check (closes #204) #208
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user