1 Commits
Author SHA1 Message Date
sneak 1b617847eb watcher: follow a watched name's CNAME for port and TLS checks (closes #203)
check / check (push) Successful in 1m53s
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, only the A and AAAA records in the answers were used, so a
CNAME into another zone got no port or TLS checks. When following
fails, 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-01 23:49:26 +00:00
6 changed files with 79 additions and 259 deletions
+6 -19
View File
@@ -121,22 +121,15 @@ 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, 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.
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.
- 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:
@@ -183,8 +176,6 @@ 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.
@@ -494,10 +485,8 @@ 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, 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.
chain, found when its nameservers answered with a CNAME and no address. It is
left out otherwise.
---
@@ -638,9 +627,7 @@ 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, 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.
cycle.
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,8 +21,6 @@ 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,115 +85,3 @@ 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)
}
}
+9 -13
View File
@@ -20,14 +20,15 @@ func NewForTest(
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
@@ -80,11 +81,6 @@ 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,
+44 -72
View File
@@ -7,6 +7,7 @@ import (
"slices"
"sort"
"strings"
"sync"
"time"
"go.uber.org/fx"
@@ -48,16 +49,18 @@ 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
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
}
// New creates a new Watcher instance wired into the fx lifecycle.
@@ -66,14 +69,15 @@ 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,
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),
}
lifecycle.Append(fx.Hook{
@@ -395,30 +399,17 @@ 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. 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.
// 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.
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
}
@@ -428,12 +419,6 @@ func (w *Watcher) resolveCNAMEAddresses(
}
}
if !answered {
current.CNAMEAddresses = prevAddresses
return
}
if !hasCNAME {
return
}
@@ -446,7 +431,9 @@ func (w *Watcher) resolveCNAMEAddresses(
"error", err,
)
current.CNAMEAddresses = prevAddresses
if prev != nil {
current.CNAMEAddresses = prev.CNAMEAddresses
}
return
}
@@ -497,37 +484,6 @@ 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
@@ -1108,6 +1064,22 @@ 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)",
+20 -41
View File
@@ -616,54 +616,33 @@ func TestTLSExpiryWarning(t *testing.T) {
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
}
// 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) {
func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel()
title := "TLS Expiry Warning: " + host
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour
for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
t.Run(interval.String(), func(t *testing.T) {
t.Parallel()
title := "TLS Expiry Warning: " + testHost
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
cfg.TLSInterval = interval
// The second check comes within the TLS interval of the first,
// so it must not warn again.
var warnings int
// 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,
)
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
warnings = countNotifications(deps, title)
})
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},
})
if warnings == 0 {
t.Fatal("expected expiry warnings from the first check")
}
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,
)
}
}
})
got := countNotifications(deps, title)
if got != warnings {
t.Errorf(
"expected %d expiry warnings (dedup), got %d",
warnings, got,
)
}
}