2 Commits
Author SHA1 Message Date
sneak dbd3343251 watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Failing after 2m1s
When a watched name's nameservers answer with a CNAME and no address,
the DNS check asks ResolveIPAddresses for the name, which looks it up
again and follows the chain, and saves the addresses at its end in the
hostname state as cnameAddresses. The port and TLS checks use them.
Before, a CNAME into another zone got no port or TLS checks. A change
in those addresses is notified as a CNAME address change. When
following fails, or none of the name's nameservers answered, the
addresses the last check saved are kept. The domain check now runs the
hostname check for the apex instead of a copy of it.

Model: opus-5-5
2026-10-02 00:42:18 +00:00
clawbot c9510a986c watcher: warn of an expiring certificate on every TLS check (closes #204)
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
2026-10-02 01:58:28 +02:00
6 changed files with 259 additions and 79 deletions
+19 -6
View File
@@ -121,15 +121,22 @@ notification endpoint set, changes show only on the dashboard; see
failed on it, and answers differently is reported on the check where it
answers. If a pair agrees again and later disagrees, the alert is sent
again.
- **CNAME address change**: For a name whose nameservers answer with a CNAME
and no address, the addresses at the end of its CNAME chain differ from
those of the previous check, including when there are none now. Nothing is
sent when the previous check saved none, or when the previous addresses
were kept because the chain could not be followed or none of the name's
nameservers answered.
### TCP Port Monitoring
- For every configured domain and hostname, constructs a deduplicated list of
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
nameservers returned. When they returned a CNAME and no address, the CNAME
chain is followed and the addresses at its end are used; a change in those
sends no notification of its own. When the chain cannot be followed, the
addresses the last check found at its end are used.
chain is followed and the addresses at its end are used, and a change in those
is notified as a CNAME address change. When the chain cannot be followed, or
none of the name's nameservers answered, the addresses the last check found at
its end are used.
- Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour**, re-checks all ports.
- Any change in port availability triggers a notification:
@@ -176,6 +183,8 @@ includes:
- **DNS NS changes**: Which domain, which nameservers were added/removed.
- **NS address changes**: Which domain, which nameserver, its old and new
addresses.
- **CNAME address changes**: Which hostname, the old and new addresses at the
end of its CNAME chain.
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
REFUSED, network error), which hostname/domain affected.
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
@@ -485,8 +494,10 @@ resolves to. A state file without it loads, and the next check fills it in
without a notification.
`cnameAddresses` lists the sorted addresses at the end of a hostname's CNAME
chain, found when its nameservers answered with a CNAME and no address. It is
left out otherwise.
chain, found when its nameservers answered with a CNAME and no address, and kept
from the previous check when the chain cannot be followed or none of the name's
nameservers answered. It is left out otherwise. A state file without it loads,
and the next check fills it in without a notification.
---
@@ -627,7 +638,9 @@ docker run -d \
completes.
- Port and TLS checks always use freshly resolved IP addresses from the DNS
phase that immediately precedes them — never stale IPs from a previous
cycle.
cycle, with one exception: when a name's CNAME chain cannot be followed,
or none of the name's nameservers answered, the addresses the previous
cycle found at the end of the chain are used.
4. **On change detection**: Send notifications to all configured endpoints,
update in-memory state, persist to disk.
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
+2
View File
@@ -21,6 +21,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-01: a watched name whose nameservers answer with a CNAME and no
address gets port and TLS checks at the end of its CNAME chain (closes #203).
- 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
servers, not whichever of its own servers answered first (closes #200).
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
+112
View File
@@ -85,3 +85,115 @@ func TestCNAMEThatCannotBeFollowedKeepsPrevious(t *testing.T) {
)
}
}
// TestCNAMEWhoseNameserversAllFailedKeepsPrevious checks a name none of
// whose nameservers answered. The addresses the previous check saved
// from following its CNAME are kept, and nothing is looked up: the
// watcher has no resolver.
func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
current := saved(map[string]*state.NameserverRecordState{
nsA: failed(), nsB: failed(),
})
prev := cnameState(oldIP)
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf(
"saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses,
)
}
}
// cnameState builds the state a check leaves behind for a name whose
// nameserver answered with a CNAME and no address, when following the
// CNAME found these addresses.
func cnameState(addresses ...string) *state.HostnameState {
hs := hostnameState(map[string]map[string][]string{
nsA: {"CNAME": {"target.example.org."}},
})
hs.CNAMEAddresses = addresses
return hs
}
func TestCNAMEAddressChangeAlerts(t *testing.T) {
t.Parallel()
// Each case is the state saved by the previous check and by the
// current one. The name's records are the same in both.
tests := []struct {
name string
prev, current *state.HostnameState
want int
}{
{
"same addresses",
cnameState(ip1, ip2), cnameState(ip1, ip2), 0,
},
{
"same addresses in another order",
cnameState(ip2, ip1), cnameState(ip1, ip2), 0,
},
{
"address replaced",
cnameState(ip1), cnameState(ip2), 1,
},
{
"address added",
cnameState(ip1), cnameState(ip1, ip2), 1,
},
{
"no address at the end of the chain now",
cnameState(ip1), cnameState(), 1,
},
{
"state file from before addresses were saved",
cnameState(), cnameState(ip1), 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(t.Context(), host, tt.prev, tt.current)
got := len(notifier.getNotifications())
if got != tt.want {
t.Errorf("sent %d notifications, want %d", got, tt.want)
}
})
}
}
func TestCNAMEAddressChangeAlertNamesHostnameAndAddresses(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(
t.Context(), host, cnameState(ip1), cnameState(ip2, ip3),
)
want := notification{
Title: "CNAME Address Change: " + host,
Message: "Hostname: " + host +
"\nOld: " + ip1 + "\nNew: " + ip2 + ", " + ip3,
Priority: "warning",
}
got := notifier.getNotifications()
if len(got) != 1 || got[0] != want {
t.Errorf("sent %v, want %v", got, want)
}
}
+13 -9
View File
@@ -20,15 +20,14 @@ func NewForTest(
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
}
}
@@ -81,6 +80,11 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx)
}
// RunTLSChecks exports runTLSChecks for testing.
func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx)
}
// BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState(
results map[string]*resolver.NameserverResponse,
+72 -44
View File
@@ -7,7 +7,6 @@ import (
"slices"
"sort"
"strings"
"sync"
"time"
"go.uber.org/fx"
@@ -49,18 +48,16 @@ type Params struct {
// Watcher orchestrates all monitoring checks on a schedule.
type Watcher struct {
log *slog.Logger
config *config.Config
state *state.State
resolver DNSResolver
portCheck PortChecker
tlsCheck TLSChecker
notify Notifier
cancel context.CancelFunc
done chan struct{} // closed when Run returns
firstRun bool
expiryNotifiedMu sync.Mutex
expiryNotified map[string]time.Time
log *slog.Logger
config *config.Config
state *state.State
resolver DNSResolver
portCheck PortChecker
tlsCheck TLSChecker
notify Notifier
cancel context.CancelFunc
done chan struct{} // closed when Run returns
firstRun bool
}
// New creates a new Watcher instance wired into the fx lifecycle.
@@ -69,15 +66,14 @@ func New(
params Params,
) (*Watcher, error) {
w := &Watcher{
log: params.Logger.Get(),
config: params.Config,
state: params.State,
resolver: params.Resolver,
portCheck: params.PortCheck,
tlsCheck: params.TLSCheck,
notify: params.Notify,
firstRun: true,
expiryNotified: make(map[string]time.Time),
log: params.Logger.Get(),
config: params.Config,
state: params.State,
resolver: params.Resolver,
portCheck: params.PortCheck,
tlsCheck: params.TLSCheck,
notify: params.Notify,
firstRun: true,
}
lifecycle.Append(fx.Hook{
@@ -399,17 +395,30 @@ func (w *Watcher) checkHostname(
// resolveCNAMEAddresses saves in current the addresses at the end of
// hostname's CNAME chain, when the nameservers' answers in current hold
// a CNAME and no address. ResolveIPAddresses looks the name up again
// and follows the chain. When it fails, as when no nameserver of a zone
// in the chain answers, the addresses saved in prev, which may be nil,
// are kept.
// and follows the chain. The addresses saved in prev, which may be nil,
// are kept when none of the name's nameservers answered, and when the
// chain cannot be followed, as when no nameserver of a zone in it
// answers.
func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context,
hostname string,
current, prev *state.HostnameState,
) {
var prevAddresses []string
if prev != nil {
prevAddresses = prev.CNAMEAddresses
}
answered := false
hasCNAME := false
for _, nsState := range current.RecordsByNameserver {
if nsState.Status != statusOK {
continue
}
answered = true
if len(nsState.Records["A"]) > 0 || len(nsState.Records["AAAA"]) > 0 {
return
}
@@ -419,6 +428,12 @@ func (w *Watcher) resolveCNAMEAddresses(
}
}
if !answered {
current.CNAMEAddresses = prevAddresses
return
}
if !hasCNAME {
return
}
@@ -431,9 +446,7 @@ func (w *Watcher) resolveCNAMEAddresses(
"error", err,
)
if prev != nil {
current.CNAMEAddresses = prev.CNAMEAddresses
}
current.CNAMEAddresses = prevAddresses
return
}
@@ -484,6 +497,37 @@ func (w *Watcher) detectHostnameChanges(
w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectNSFailures(ctx, hostname, prev, current)
w.detectInconsistencies(ctx, hostname, prev, current)
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
}
// detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved,
// including when there are none now. When the previous check saved
// none, as in a state file from before they were saved, nothing is
// compared.
func (w *Watcher) detectCNAMEAddressChanges(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
old, cur := prev.CNAMEAddresses, current.CNAMEAddresses
if len(old) == 0 || sliceEqual(old, cur) {
return
}
msg := fmt.Sprintf(
"Hostname: %s\nOld: %s\nNew: %s",
hostname,
strings.Join(old, ", "),
strings.Join(cur, ", "),
)
w.notify.SendNotification(
ctx,
"CNAME Address Change: "+hostname,
msg,
"warning",
)
}
// detectRecordChanges compares each nameserver's records with those of
@@ -1064,22 +1108,6 @@ func (w *Watcher) checkTLSExpiry(
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(
"Host: %s\nIP: %s\nCN: %s\n"+
"Expires: %s (%.0f days)",
+41 -20
View File
@@ -616,33 +616,54 @@ func TestTLSExpiryWarning(t *testing.T) {
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()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour
title := "TLS Expiry Warning: " + host
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,
// so it must not warn again.
var warnings int
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
cfg.TLSInterval = interval
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
warnings = countNotifications(deps, title)
})
// 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,
)
if warnings == 0 {
t.Fatal("expected expiry warnings from the first check")
}
expiresInThreeDays(deps)
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)
if got != warnings {
t.Errorf(
"expected %d expiry warnings (dedup), got %d",
warnings, got,
)
for check := 1; check <= 3; check++ {
w.RunTLSChecks(t.Context())
got := countNotifications(deps, title)
if got != check {
t.Fatalf(
"after check %d: %d expiry warnings, want %d",
check, got, check,
)
}
}
})
}
}