1 Commits
Author SHA1 Message Date
sneak 87aa5c2d04 resolver: try servers in a random order on each resolution (closes #138)
check / check (push) Failing after 2m19s
Every resolution walked the root servers in a fixed order, so
a.root-servers.net got every first query and its timeouts were paid on
every lookup. Each list of servers the resolver walks, the root servers
and the nameservers of each zone below them, is now walked in a random
order from the standard library's rand.Shuffle, chosen anew each time.
Failover is unchanged: a server that does not reply, or refuses, is
passed over for the next; any other reply, even a SERVFAIL, is used.
The shuffle is passed in, so the tests check the order with a seeded
source; which server a live query reached is not observable, so no
test fails if the walk stops shuffling.

Model: opus-5-5
2026-10-01 22:47:08 +00:00
11 changed files with 134 additions and 477 deletions
+44 -89
View File
@@ -40,29 +40,6 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
--- ---
## Getting Started
You need git and Docker. This builds the image and runs dnswatcher watching
`example.com` and `www.example.com`:
```sh
git clone https://git.eeqj.de/sneak/dnswatcher.git
cd dnswatcher
docker build -t dnswatcher .
docker run -d --name dnswatcher \
-p 8080:8080 \
-v dnswatcher-data:/var/lib/dnswatcher \
-e DNSWATCHER_TARGETS=example.com,www.example.com \
dnswatcher
```
The build also runs the linter and the test suite, which queries live DNS. Once
the container is running, the dashboard is at <http://localhost:8080/>. With no
notification endpoint set, changes show only on the dashboard; see
[Configuration](#configuration) to add one.
---
## Features ## Features
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)
@@ -295,6 +272,48 @@ navigation needs, and its URL may name internal hosts.
--- ---
## Architecture
```
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/
config/config.go Viper-based configuration
globals/globals.go Build-time variables (version)
logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, security
headers, metrics auth and rate limit)
handlers/handlers.go HTTP request handlers
server/
server.go HTTP server lifecycle
routes.go Route definitions
state/state.go JSON file state persistence
resolver/resolver.go Iterative DNS resolution engine
portcheck/portcheck.go TCP port connectivity checker
tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler
livednstest/livednstest.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
```
### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively,
tracing from root nameservers through the delegation chain to authoritative
servers.
- **No external database**: State is persisted as a single JSON file.
- **Dependency injection**: All components are wired via
[uber/fx](https://github.com/uber-go/fx).
- **Structured logging**: All logs use `log/slog` with JSON output in production
(TTY detection for development).
- **Graceful shutdown**: All background goroutines respect context cancellation
and the fx lifecycle. In-flight notification deliveries are drained on
shutdown, bounded by the shutdown timeout.
---
## Configuration ## Configuration
Configuration is loaded via [Viper](https://github.com/spf13/viper) with the Configuration is loaded via [Viper](https://github.com/spf13/viper) with the
@@ -384,10 +403,8 @@ performs full iterative resolution:
In steps 2 and 3 the servers are asked one at a time in a random order, chosen In steps 2 and 3 the servers are asked one at a time in a random order, chosen
anew each time, so no one root server gets every first query. A server that does anew each time, so no one root server gets every first query. A server that does
not reply, refuses the query, or gives an error reply such as SERVFAIL or a not reply, or refuses the query, is passed over for the next one; the first
referral that leads no closer to the name is passed over for the next one. When other reply is used, even a SERVFAIL, and no further server is asked.
a referral names a zone's nameservers without their addresses, the addresses of
all of them are looked up, so that each can be asked.
This approach ensures: This approach ensures:
@@ -655,68 +672,6 @@ configuration.
--- ---
## Rationale
dnswatcher exists to report changes to the DNS records, TCP port availability
and TLS certificates of its configured domains and hostnames, failures and
recoveries included: it is designed as a real-time change feed. It queries the
authoritative nameservers directly, tracing from the root, instead of a
recursive resolver, so no resolver's cache or filtering hides a change and
nameservers that disagree with each other are seen. Its state is a single JSON
file, so it survives a restart without an external database.
---
## Design
```
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/
config/config.go Viper-based configuration
globals/globals.go Build-time variables (version)
logger/logger.go slog structured logging (TTY detection)
healthcheck/healthcheck.go Health check service
middleware/middleware.go HTTP middleware (logging, CORS, security
headers, metrics auth and rate limit)
handlers/handlers.go HTTP request handlers
server/
server.go HTTP server lifecycle
routes.go Route definitions
state/state.go JSON file state persistence
resolver/resolver.go Iterative DNS resolution engine
portcheck/portcheck.go TCP port connectivity checker
tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler
livednstest/livednstest.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
```
### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively,
tracing from root nameservers through the delegation chain to authoritative
servers.
- **No external database**: State is persisted as a single JSON file.
- **Dependency injection**: All components are wired via
[uber/fx](https://github.com/uber-go/fx).
- **Structured logging**: All logs use `log/slog` with JSON output in production
(TTY detection for development).
- **Graceful shutdown**: All background goroutines respect context cancellation
and the fx lifecycle. In-flight notification deliveries are drained on
shutdown, bounded by the shutdown timeout.
---
## TODO
[`TODO.md`](./TODO.md) names the next step and the steps planned after it. The
work for 1.0 is tracked as issues on the
[1.0 milestone](https://git.eeqj.de/sneak/dnswatcher/milestone/7).
---
## License ## License
dnswatcher is released under the MIT License, Copyright (c) 2026 dnswatcher is released under the MIT License, Copyright (c) 2026
+3 -9
View File
@@ -19,16 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: the resolver tries root servers, and every other server list it - 2026-10-01: the resolver tries root servers, and every other server list it
walks, in a random order each time, not always from the top (closes #138). walks, in a random order each time, not always from the top (closes #138).
- 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
Architecture section is now Design, in the order policy sets (closes #173).
- 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no
closer is passed over for the next, as one that times out is (closes #197).
- 2026-10-01: when none of a configured name's nameservers answered, the port - 2026-10-01: when none of a configured name's nameservers answered, the port
state saved for its addresses is kept, not removed (closes #193). state saved for its addresses is kept, not removed (closes #193).
- 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no - 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
@@ -130,4 +122,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs: - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66 https://git.eeqj.de/sneak/dnswatcher/issues/66
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy:
https://git.eeqj.de/sneak/dnswatcher/issues/173
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
-7
View File
@@ -15,13 +15,6 @@ var (
// so whether the name has addresses is unknown. // so whether the name has addresses is unknown.
ErrNoNameserverAnswered = errors.New("no nameserver answered") ErrNoNameserverAnswered = errors.New("no nameserver answered")
// ErrUnusableReply is returned when a server replied with an
// error such as SERVFAIL, or with a referral that leads no
// closer to the name asked about.
ErrUnusableReply = errors.New(
"reply is an error or a referral that leads no closer",
)
// ErrCNAMEDepthExceeded is returned when a CNAME chain // ErrCNAMEDepthExceeded is returned when a CNAME chain
// exceeds MaxCNAMEDepth. // exceeds MaxCNAMEDepth.
ErrCNAMEDepthExceeded = errors.New( ErrCNAMEDepthExceeded = errors.New(
-18
View File
@@ -11,16 +11,6 @@ func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) return extractRecordValue(rr)
} }
// UsableReply exports usableReply for testing.
func UsableReply(resp *dns.Msg, zone string, name string) bool {
return usableReply(resp, zone, name)
}
// NSSetFrom exports nsSetFrom for testing.
func NSSetFrom(resp *dns.Msg, domain string) []string {
return nsSetFrom(resp, domain)
}
// CollectIPs exports collectIPs for testing. // CollectIPs exports collectIPs for testing.
func CollectIPs( func CollectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
@@ -37,14 +27,6 @@ func (r *Resolver) QueryEachNS(
return r.queryEachNS(ctx, nameservers, hostname) return r.queryEachNS(ctx, nameservers, hostname)
} }
// ResolveNSIPs exports resolveNSIPs for testing.
func (r *Resolver) ResolveNSIPs(
ctx context.Context,
nsNames []string,
) []string {
return r.resolveNSIPs(ctx, nsNames)
}
// RootServerList exports rootServerList for testing. // RootServerList exports rootServerList for testing.
func RootServerList() []string { func RootServerList() []string {
return rootServerList() return rootServerList()
+20 -83
View File
@@ -209,24 +209,21 @@ func (r *Resolver) followDelegation(
domain string, domain string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
// servers are the root servers, the servers of zone ".".
zone := "."
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
resp, err := r.queryServers( resp, err := r.queryServers(
ctx, servers, zone, domain, dns.TypeNS, ctx, servers, domain, dns.TypeNS,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
nsSet := nsSetFrom(resp, domain) ansNS := extractNSSet(resp.Answer)
if len(nsSet) > 0 { if len(ansNS) > 0 {
return nsSet, nil return ansNS, nil
} }
// An authoritative reply comes from the servers of the zone // An authoritative reply comes from the servers of the zone
@@ -255,7 +252,6 @@ func (r *Resolver) followDelegation(
} }
servers = nextServers servers = nextServers
zone = referralZone(resp)
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
@@ -277,13 +273,11 @@ func shuffled(
return order return order
} }
// queryServers asks servers, the servers of zone, about name in a random // queryServers asks the servers in a random order and returns the
// order until one gives a usable reply. A server that times out, refuses // first reply; it fails only when every server has failed.
// or gives a reply that is not usable is passed over for the next.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
zone string,
name string, name string,
qtype uint16, qtype uint16,
) (*dns.Msg, error) { ) (*dns.Msg, error) {
@@ -295,12 +289,6 @@ func (r *Resolver) queryServers(
} }
resp, err := r.queryDNS(ctx, ip, name, qtype) resp, err := r.queryDNS(ctx, ip, name, qtype)
if err == nil && !usableReply(resp, zone, name) {
err = fmt.Errorf(
"query %s @%s: %w", name, ip, ErrUnusableReply,
)
}
if err == nil { if err == nil {
return resp, nil return resp, nil
} }
@@ -311,68 +299,23 @@ func (r *Resolver) queryServers(
return nil, fmt.Errorf("all servers failed: %w", lastErr) return nil, fmt.Errorf("all servers failed: %w", lastErr)
} }
// usableReply reports whether resp, a reply from one of the servers of // resolveNSIPs returns the addresses of one of the nameservers, trying
// zone to a query about name, is usable. An error reply such as SERVFAIL // their names in a random order until one resolves.
// is not. Nor is a referral, unless it refers the query to a zone below
// zone that name is in: a server that refers it back to zone, up or
// sideways does not serve zone as it should.
func usableReply(resp *dns.Msg, zone string, name string) bool {
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
return false
}
child := referralZone(resp)
if resp.Authoritative || len(resp.Answer) > 0 || child == "" {
return true
}
return child != zone && dns.IsSubDomain(zone, child) &&
dns.IsSubDomain(child, name)
}
// referralZone returns the zone a referral refers the query to: the
// owner name of the NS records in resp's authority section, or "" when
// there are none.
func referralZone(resp *dns.Msg) string {
for _, rr := range resp.Ns {
if ns, ok := rr.(*dns.NS); ok {
return strings.ToLower(ns.Hdr.Name)
}
}
return ""
}
// nsSetFrom returns the NS set of domain that resp, a reply to a query
// for domain's NS records, gives: the delegation in a referral to domain
// itself, or else the NS records in the answer; empty when it gives
// neither. A referral to domain comes from its parent zone's servers,
// which all hold the same delegation, so the set does not depend on
// which of them answered. domain's own servers, which can disagree about
// their NS records, are then not asked.
func nsSetFrom(resp *dns.Msg, domain string) []string {
if referralZone(resp) == domain {
return extractNSSet(resp.Ns)
}
return extractNSSet(resp.Answer)
}
// resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, for a referral that carries none. The walk can
// then go on to the zone's other nameservers when one gives no usable
// reply.
func (r *Resolver) resolveNSIPs( func (r *Resolver) resolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
) []string { ) []string {
var ips []string var ips []string
for _, ns := range nsNames { for _, ns := range shuffled(nsNames, rand.Shuffle) {
resolved, err := r.resolveARecord(ctx, ns) resolved, err := r.resolveARecord(ctx, ns)
if err == nil { if err == nil {
ips = append(ips, resolved...) ips = append(ips, resolved...)
} }
if len(ips) > 0 {
break
}
} }
return ips return ips
@@ -391,7 +334,6 @@ func (r *Resolver) resolveNSIterative(
domain = dns.Fqdn(domain) domain = dns.Fqdn(domain)
servers := rootServerList() servers := rootServerList()
zone := "."
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
@@ -399,13 +341,13 @@ func (r *Resolver) resolveNSIterative(
} }
resp, err := r.queryServers( resp, err := r.queryServers(
ctx, servers, zone, domain, dns.TypeNS, ctx, servers, domain, dns.TypeNS,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
nsNames := nsSetFrom(resp, domain) nsNames := extractNSSet(resp.Answer)
if len(nsNames) > 0 { if len(nsNames) > 0 {
return nsNames, nil return nsNames, nil
} }
@@ -424,7 +366,6 @@ func (r *Resolver) resolveNSIterative(
} }
servers = nextServers servers = nextServers
zone = referralZone(resp)
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
@@ -442,7 +383,6 @@ func (r *Resolver) resolveARecord(
hostname = dns.Fqdn(hostname) hostname = dns.Fqdn(hostname)
servers := rootServerList() servers := rootServerList()
zone := "."
for range maxDelegation { for range maxDelegation {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
@@ -450,7 +390,7 @@ func (r *Resolver) resolveARecord(
} }
resp, err := r.queryServers( resp, err := r.queryServers(
ctx, servers, zone, hostname, dns.TypeA, ctx, servers, hostname, dns.TypeA,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -488,7 +428,6 @@ func (r *Resolver) resolveARecord(
} }
servers = nextServers servers = nextServers
zone = referralZone(resp)
} }
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -498,8 +437,7 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from // FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the // root servers to discover all authoritative nameservers for the
// given domain, as the delegation from its parent zone's servers lists // given domain. For a name that is not a zone apex it tries each
// them. For a name that is not a zone apex it tries each
// parent name in turn, so it returns the nameservers of the zone the // parent name in turn, so it returns the nameservers of the zone the
// name is in. // name is in.
func (r *Resolver) FindAuthoritativeNameservers( func (r *Resolver) FindAuthoritativeNameservers(
@@ -665,10 +603,9 @@ func (r *Resolver) querySingleType(
// A reply with no answer that lists other nameservers, from a server // A reply with no answer that lists other nameservers, from a server
// that does not hold the name's zone, is a referral and says nothing // that does not hold the name's zone, is a referral and says nothing
// about the name's records. A server named in the delegation that // about the name's records. A parent zone's servers send one when
// does not hold the zone may send one, as do a parent zone's servers // every server of the name's own zone failed and
// when FindAuthoritativeNameservers found no delegation for the // FindAuthoritativeNameservers moved on to the parent name.
// name's zone and moved on to a parent name.
if !msg.Authoritative && len(msg.Answer) == 0 && if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 { len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true state.gotReferral = true
-143
View File
@@ -43,149 +43,6 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips) assert.Empty(t, ips)
} }
const (
// exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it.
exampleCom = "example.com."
wwwExampleCom = "www.example.com."
// exampleNS is the server the NS records nsRecord builds name.
exampleNS = "ns1.example.net."
)
// nsRecord builds an NS record that names a server of zone.
func nsRecord(zone string) *dns.NS {
return &dns.NS{
Hdr: dns.RR_Header{
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
},
Ns: exampleNS,
}
}
// referralTo builds a reply that refers the query to the servers of
// zone.
func referralTo(zone string) *dns.Msg {
msg := new(dns.Msg)
msg.Ns = []dns.RR{nsRecord(zone)}
return msg
}
// TestUsableReply checks which replies from one of a zone's servers are
// used. A reply that is not usable moves the query on to the zone's
// next server.
func TestUsableReply(t *testing.T) {
t.Parallel()
servfail := new(dns.Msg)
servfail.Rcode = dns.RcodeServerFailure
answer := new(dns.Msg)
answer.Authoritative = true
answer.Answer = []dns.RR{nsRecord(exampleCom)}
nxdomain := new(dns.Msg)
nxdomain.Authoritative = true
nxdomain.Rcode = dns.RcodeNameError
tests := []struct {
name string
resp *dns.Msg
zone string
query string
want bool
}{
{
name: "SERVFAIL", resp: servfail,
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "answer", resp: answer,
zone: exampleCom, query: exampleCom, want: true,
},
{
name: "NXDOMAIN", resp: nxdomain,
zone: ".", query: exampleCom, want: true,
},
{
name: "root refers to com", resp: referralTo("com."),
zone: ".", query: exampleCom, want: true,
},
{
name: "com refers to example.com", resp: referralTo(exampleCom),
zone: "com.", query: wwwExampleCom, want: true,
},
{
name: "referral back to the zone", resp: referralTo(exampleCom),
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "referral up to the root", resp: referralTo("."),
zone: exampleCom, query: exampleCom, want: false,
},
{
name: "referral sideways", resp: referralTo("net."),
zone: ".", query: exampleCom, want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want,
resolver.UsableReply(tt.resp, tt.zone, tt.query),
)
})
}
}
// TestNSSetFrom checks which NS set a reply gives for a domain; a set
// that is not empty ends the walk. The referral to example.com that
// com's servers all send alike gives its delegation, so the set is the
// same whichever of them answered, and example.com's own servers, which
// can disagree, are not asked.
func TestNSSetFrom(t *testing.T) {
t.Parallel()
answer := new(dns.Msg)
answer.Authoritative = true
answer.Answer = []dns.RR{nsRecord(exampleCom)}
tests := []struct {
name string
resp *dns.Msg
domain string
want []string
}{
{
name: "com refers to example.com", resp: referralTo(exampleCom),
domain: exampleCom, want: []string{exampleNS},
},
{
name: "com refers on, for www.example.com",
resp: referralTo(exampleCom), domain: wwwExampleCom,
want: nil,
},
{
name: "answer from a server that holds example.com",
resp: answer, domain: exampleCom,
want: []string{exampleNS},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.ElementsMatch(t, tt.want,
resolver.NSSetFrom(tt.resp, tt.domain),
)
})
}
}
func TestExtractRecordValue_LetterCase(t *testing.T) { func TestExtractRecordValue_LetterCase(t *testing.T) {
t.Parallel() t.Parallel()
-34
View File
@@ -383,37 +383,3 @@ func liveResolveIPsAllowingEmpty(
return out return out
} }
// liveResolveNSIPs looks up the addresses of the nameservers named
// names, retrying until there are at least atLeast of them: a name
// whose lookup got no reply is left out of the result, not an error.
func liveResolveNSIPs(
t *testing.T,
r *resolver.Resolver,
names []string,
atLeast int,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"ResolveNSIPs("+strings.Join(names, ", ")+")",
func(ctx context.Context) error {
ips := r.ResolveNSIPs(ctx, names)
if len(ips) < atLeast {
return fmt.Errorf(
"%w: %d addresses, expected at least %d",
livednstest.ErrNoAnswer, len(ips), atLeast,
)
}
out = ips
return nil
},
)
return out
}
-22
View File
@@ -139,28 +139,6 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
} }
} }
// TestResolveNSIPs_EveryNameserver looks up the addresses of two of
// google.com's nameservers together, as the walk does when a referral
// names a zone's nameservers without their addresses, and compares them
// with each looked up alone. Together they must give the addresses of
// both, not only of the first that resolves, so that when one gives no
// usable reply the walk goes on to the other.
func TestResolveNSIPs_EveryNameserver(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
names := []string{"ns3.google.com.", "ns4.google.com."}
want := make([]string, 0, len(names))
for _, name := range names {
want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...)
}
got := liveResolveNSIPs(t, r, names, len(want))
assert.ElementsMatch(t, want, got)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// QueryNameserver tests // QueryNameserver tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
+9 -13
View File
@@ -20,14 +20,15 @@ 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),
} }
} }
@@ -71,11 +72,6 @@ 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,
+38 -18
View File
@@ -7,6 +7,7 @@ import (
"slices" "slices"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -48,16 +49,18 @@ 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.
@@ -66,14 +69,15 @@ 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{
@@ -1024,6 +1028,22 @@ 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)",
+20 -41
View File
@@ -615,54 +615,33 @@ func TestTLSExpiryWarning(t *testing.T) {
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
} }
// TestTLSExpiryWarningEachCheck runs the TLS checks three times in a func TestTLSExpiryWarningDedup(t *testing.T) {
// 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()
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} { title := "TLS Expiry Warning: " + testHost
t.Run(interval.String(), func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t) // The second check comes within the TLS interval of the first,
cfg.Hostnames = []string{host} // so it must not warn again.
cfg.TLSInterval = interval var warnings int
// The TLS checks read the saved hostname and port state and deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
// look nothing up, so the watcher has no resolver. warnings = countNotifications(deps, title)
deps := newTestDeps(t, cfg) })
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
expiresInThreeDays(deps) if warnings == 0 {
deps.state.SetHostnameState(host, saved( t.Fatal("expected expiry warnings from the first check")
map[string]*state.NameserverRecordState{ }
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{host},
})
for check := 1; check <= 3; check++ { got := countNotifications(deps, title)
w.RunTLSChecks(t.Context()) if got != warnings {
t.Errorf(
got := countNotifications(deps, title) "expected %d expiry warnings (dedup), got %d",
if got != check { warnings, got,
t.Fatalf( )
"after check %d: %d expiry warnings, want %d",
check, got, check,
)
}
}
})
} }
} }