3 Commits
Author SHA1 Message Date
sneak 251f05010f README: correct claims the code does not bear out (closes #108)
check / check (push) Successful in 1m28s
Checked every README claim against the code on next and fixed the ones
that were wrong or missing: what /metrics serves and when, what
DNSWATCHER_MAINTENANCE_MODE does, CORS on the public routes,
notification retries and the in-memory alert history, the certificate
error field and old port entries in the state file, and the Design
tree's missing files. Also corrected: CNAMEs are not followed for
watched names, the root server list is never refreshed, the NS set is
the delegation from the domain's parent zone, notification contents,
and the system resolver being used for webhooks. Code problems found
are filed separately.

Model: opus-5-5
2026-10-02 00:31:28 +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
clawbot 1f1640d4cd resolver: take a domain's NS set from its delegation (closes #200)
check / check (push) Failing after 2m2s
A domain's NS set was taken from whichever of its own servers answered
first, so when they disagree (during a move between DNS providers, or
with a stale secondary) the set could change between checks and send an
NS change notification with nothing changed. The walk now stops at the
referral to the domain from its parent zone's servers and returns that
delegation, which those servers all hold alike; the domain's own
servers are no longer asked for it. The NS records in an answer are
still used where no such referral comes first, as from a server that
holds both the parent zone and the domain. Hostnames get their zone's
servers the same way.

Model: opus-5-5
2026-10-02 01:48:54 +02:00
8 changed files with 264 additions and 125 deletions
+100 -45
View File
@@ -12,7 +12,7 @@ dnswatcher watches configured DNS domains and hostnames for changes, monitors
TCP port availability, tracks TLS certificate expiry, and delivers real-time
notifications via Slack, Mattermost, and/or ntfy webhooks.
It performs all DNS resolution itself via iterative (non-recursive) queries,
It resolves the names it watches itself via iterative (non-recursive) queries,
tracing from root nameservers to authoritative servers directly—never relying on
upstream recursive resolvers.
@@ -69,14 +69,14 @@ notification endpoint set, changes show only on the dashboard; see
- Accepts a list of DNS domain names (apex domains, identified via the
[Public Suffix List](https://publicsuffix.org/)).
- Every **1 hour**, performs a full iterative trace from root servers to
discover all authoritative nameservers (NS records) for each domain.
- Every **1 hour** by default, performs a full iterative trace from root servers
to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
- Stores the NS record set as observed by the delegation chain, and the IPv4 and
IPv6 addresses each nameserver's name resolves to.
- Stores the domain's NS record set, as its parent zone's servers delegate it,
and the IPv4 and IPv6 addresses each nameserver's name resolves to.
- Any change triggers a notification:
- NS added to or removed from the delegation.
- NS address change: a nameserver that stays in the delegation resolves to
- NS added to or removed from that set.
- NS address change: a nameserver that stays in the set resolves to
different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are
@@ -86,7 +86,7 @@ notification endpoint set, changes show only on the dashboard; see
- Accepts a list of DNS hostnames (subdomains, distinguished from apex domains
via the Public Suffix List).
- Every **1 hour**, performs a full iterative trace to discover the
- Every **1 hour** by default, performs a full iterative trace to discover the
authoritative nameservers of the zone the hostname is in, which is not always
its last two labels (a name under `co.uk`, or in a delegated subdomain).
- Queries **each** authoritative nameserver independently for **all** record
@@ -125,13 +125,16 @@ notification endpoint set, changes show only on the dashboard; see
### TCP Port Monitoring
- For every configured domain and hostname, constructs a deduplicated list of
all IPv4 and IPv6 addresses resolved via A, AAAA, and CNAME chain resolution
across all authoritative nameservers.
the IPv4 and IPv6 addresses in the A and AAAA records its authoritative
nameservers returned. A CNAME is not followed: a name whose CNAME points into
another zone usually has no addresses here, so its ports and certificate are
not checked.
- Checks TCP connectivity on ports **80** and **443** for each IP address.
- Every **1 hour**, re-checks all ports.
- Every **1 hour** by default, re-checks all ports.
- Any change in port availability triggers a notification:
- Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change) and its port state was recorded.
- New IP appeared (from DNS change): its port state is recorded without a
port notification; the DNS change notification shows the new address.
- IP disappeared (from DNS change) — noted in the DNS change notification;
port state for that IP is removed. When none of a name's nameservers
answered, its addresses are not known, so the port state saved for them is
@@ -139,14 +142,14 @@ notification endpoint set, changes show only on the dashboard; see
### TLS Certificate Monitoring
- Every **12 hours**, for each IP address listening on port 443, connects via
TLS using the correct SNI hostname.
- Every **12 hours** by default, for each IP address listening on port 443,
connects via TLS using the correct SNI hostname.
- Records the certificate's Subject CN, SANs, issuer, and expiry date.
- Any change triggers a notification:
- Certificate is expiring within **7 days** (warning, repeated each check
until renewed or expired).
- Certificate is expiring within **7 days** by default (warning, repeated
each check until renewed or expired).
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
and new values).
and new CN and issuer).
- TLS connection failure to a previously-reachable IP:443 (handshake error,
timeout, connection refused after previously succeeding).
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
@@ -178,15 +181,25 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname affected.
- **Port changes**: Which IP:port, old state, new state, all associated
hostnames.
- **TLS expiry warnings**: Which certificate, days remaining, CN, issuer,
associated hostname and IP.
- **TLS certificate changes**: Old and new CN/issuer/SANs, associated hostname
and IP.
- **Port changes**: Which IP:port, its new state, all associated hostnames.
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
and IP. A change to the SANs alone is notified, but the SANs are not listed.
- **TLS connection failures/recoveries**: Which IP:port, error details,
associated hostname.
Each endpoint is sent each notification on its own, in the background. A
delivery that fails (a network error, no reply within 10 seconds, or an HTTP
status of 400 or more) is retried up to 5 times: the first retry after about 1
second, each wait after that twice as long up to 60 seconds, every wait varied
at random by up to 25%. A delivery still failing after that is logged and
dropped.
The last 100 notifications, delivered or not, are kept in memory for the
dashboard's Recent alerts. They are not saved to the state file, so a restart
clears them.
### State Management
- All monitoring state is kept in memory and persisted to a JSON file on disk
@@ -230,7 +243,16 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /.well-known/healthcheck` | Health check (JSON) |
| `GET /health` | Health check (JSON, legacy) |
| `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics (optional) |
| `GET /metrics` | Prometheus metrics, see below |
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
process, and counts of `/metrics` requests); dnswatcher records no metrics of
its own.
Every route but `/metrics` may be read from a page on any origin: a cross-origin
`GET` gets `Access-Control-Allow-Origin: *`. Only `GET` is allowed cross-origin,
and without credentials. `/metrics` sends no CORS headers.
#### Server timeouts
@@ -321,8 +343,8 @@ following precedence (highest to lowest):
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Enable maintenance mode | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
@@ -372,13 +394,15 @@ DNSWATCHER_SEND_TEST_NOTIFICATION=true
## DNS Resolution Strategy
dnswatcher never uses the system's configured recursive resolver. Instead, it
performs full iterative resolution:
dnswatcher never uses the system's configured recursive resolver for the names
it watches. Instead, it performs full iterative resolution:
1. **Root servers**: Starts from the IANA root nameserver list (hardcoded, with
periodic refresh).
1. **Root servers**: Starts from the IPv4 addresses of the 13 root servers,
built into the binary; the list is not refreshed.
2. **TLD delegation**: Queries root servers for the TLD NS records.
3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
The delegation they give, from the domain's parent zone, is the domain's NS
record set.
4. **Authoritative query**: Queries all discovered authoritative nameservers
directly for the requested records.
@@ -387,10 +411,13 @@ This approach ensures:
- Independence from any upstream resolver's cache or filtering.
- Ability to detect split-horizon or inconsistent responses across authoritative
servers.
- Visibility into the full delegation chain.
For hostname monitoring, the resolver follows CNAME chains (with a depth limit
to prevent loops) before collecting terminal A/AAAA records.
CNAME chains are followed (with a depth limit to prevent loops) only to find the
addresses of nameservers. A watched name's records are stored as its nameservers
return them, CNAME included, without following it.
Sending a notification or a Sentry report is the one use of the system's
resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
---
@@ -472,12 +499,17 @@ reachability:
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
`records`. A nameserver whose query failed, or that only referred it to other
nameservers, has status `error`, empty `records`, and the reason in `error`.
nameservers, has status `error`, empty `records`, and the reason in `error`. A
certificate entry whose TLS connection or handshake failed likewise has status
`error`, the reason in `error`, and the certificate fields left empty or zero.
`nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in
without a notification.
A port entry in the older format, with one `hostname` instead of the `hostnames`
list, loads as a list of that one name.
---
## Entrypoints
@@ -615,9 +647,10 @@ docker run -d \
- Port checks: every `DNSWATCHER_DNS_INTERVAL`, after DNS completes.
- TLS checks: every `DNSWATCHER_TLS_INTERVAL` (default 12h), after DNS
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.
- Port and TLS checks use the IP addresses found by the DNS phase that
immediately precedes them. When that phase cannot find a name's
nameservers at all, the addresses an earlier check saved for the name 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,
@@ -666,29 +699,51 @@ file, so it survives a restart without an external database.
cmd/dnswatcher/main.go Entry point (uber/fx bootstrap)
internal/
config/config.go Viper-based configuration
config/
config.go Viper-based configuration
classify.go Splits targets into domains and hostnames
(Public Suffix List)
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
middleware/middleware.go HTTP middleware (logging, CORS, security
headers, metrics auth and rate limit)
handlers/handlers.go HTTP request handlers
handlers/
handlers.go Shared handler setup and JSON responses
dashboard.go Web dashboard
templates/dashboard.html Dashboard template (embedded)
status.go /api/v1/status
healthcheck.go Health check handler
server/
server.go HTTP server lifecycle
routes.go Route definitions
state/state.go JSON file state persistence
resolver/resolver.go Iterative DNS resolution engine
resolver/
resolver.go Resolver setup and query status values
iterative.go Iterative DNS resolution engine
dns_client.go UDP and TCP DNS clients
errors.go Resolver errors
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
tlscheck/tlscheck.go TLS certificate inspector
notify/
notify.go Notification service (Slack, Mattermost, ntfy)
retry.go Delivery retries with backoff
history.go Last 100 notifications, for the dashboard
shutdown.go Waits for deliveries at shutdown
watcher/
watcher.go Main monitoring orchestrator and scheduler
interfaces.go The resolver, checkers and notifier it uses
livednstest/livednstest.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
static/
static.go Embeds the CSS served under /s/
css/tailwind.min.css Dashboard stylesheet
```
### Design Principles
- **No recursive resolvers**: All DNS resolution is performed iteratively,
- **No recursive resolvers**: The watched names are resolved iteratively,
tracing from root nameservers through the delegation chain to authoritative
servers.
- **No external database**: State is persisted as a single JSON file.
+6 -1
View File
@@ -19,6 +19,12 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
notification retries, CNAMEs, state file fields, Design tree (closes #108).
- 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
@@ -123,6 +129,5 @@ 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:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
+5
View File
@@ -16,6 +16,11 @@ 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.
func CollectIPs(
results map[string]*NameserverResponse,
+25 -8
View File
@@ -222,9 +222,9 @@ func (r *Resolver) followDelegation(
return nil, err
}
ansNS := extractNSSet(resp.Answer)
if len(ansNS) > 0 {
return ansNS, nil
nsSet := nsSetFrom(resp, domain)
if len(nsSet) > 0 {
return nsSet, nil
}
// An authoritative reply comes from the servers of the zone
@@ -325,6 +325,21 @@ func referralZone(resp *dns.Msg) string {
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)
}
func (r *Resolver) resolveNSIPs(
ctx context.Context,
nsNames []string,
@@ -372,7 +387,7 @@ func (r *Resolver) resolveNSIterative(
return nil, err
}
nsNames := extractNSSet(resp.Answer)
nsNames := nsSetFrom(resp, domain)
if len(nsNames) > 0 {
return nsNames, nil
}
@@ -465,7 +480,8 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the
// given domain. For a name that is not a zone apex it tries each
// given domain, as the delegation from its parent zone's servers lists
// 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
// name is in.
func (r *Resolver) FindAuthoritativeNameservers(
@@ -631,9 +647,10 @@ func (r *Resolver) querySingleType(
// 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
// about the name's records. A parent zone's servers send one when
// every server of the name's own zone failed and
// FindAuthoritativeNameservers moved on to the parent name.
// about the name's records. A server named in the delegation that
// does not hold the zone may send one, as do a parent zone's servers
// when FindAuthoritativeNameservers found no delegation for the
// name's zone and moved on to a parent name.
if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
+56 -4
View File
@@ -41,8 +41,15 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips)
}
// exampleCom is the zone most cases of TestUsableReply are about.
const exampleCom = "example.com."
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 {
@@ -50,7 +57,7 @@ func nsRecord(zone string) *dns.NS {
Hdr: dns.RR_Header{
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
},
Ns: "ns1.example.net.",
Ns: exampleNS,
}
}
@@ -105,7 +112,7 @@ func TestUsableReply(t *testing.T) {
},
{
name: "com refers to example.com", resp: referralTo(exampleCom),
zone: "com.", query: "www.example.com.", want: true,
zone: "com.", query: wwwExampleCom, want: true,
},
{
name: "referral back to the zone", resp: referralTo(exampleCom),
@@ -132,6 +139,51 @@ func TestUsableReply(t *testing.T) {
}
}
// 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) {
t.Parallel()
+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,
}
}
@@ -72,6 +71,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,
+18 -38
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{
@@ -1028,22 +1024,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
@@ -615,33 +615,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,
)
}
}
})
}
}