8 Commits
Author SHA1 Message Date
clawbot b79726acc6 watcher: a hostname whose zone's nameservers all stop answering gets NS Failure (closes #284)
check / check (push) Waiting to run
When none of the nameservers of a hostname's zone answered, the walk to
them failed and the check was only logged. The walk now returns a
NoAnswerError naming the nameservers of the zone it reached through a
referral when none of them answers. When those are the nameservers the
previous check saved for the name, each is saved as failed with the
reason, so it gets an NS Failure and later an NS Recovery. A domain with
no delegation of its own, whose NS lookup is that same walk, gets the
same. A walk that fails before that zone, as when the host loses its
network, is still only logged.

Model: opus-5-5
2026-10-14 00:27:29 +02:00
clawbot aaccabad50 watcher: an IPv6 address is written in brackets before its port (closes #270)
check / check (push) Canceled after 0s
Port and certificate keys were joined as address:port, so an IPv6
address ran into its port: 2a01:4f8:2200:fd11::1:80 is also an address
on its own. Keys are now joined with net.JoinHostPort and split with
net.SplitHostPort, giving [2a01:4f8:2200:fd11::1]:80 in the state file,
/api/v1/status, the dashboard and Port Change notifications; an IPv4
address is unchanged. A certificate key still splits at its last colon,
since a hostname has none. Tests that built IPv6 keys by hand now use
the bracketed form, and a test checks that an IPv6 address's
certificate entry is removed when its port 443 closes.

Model: opus-5-5
2026-10-13 22:27:34 +02:00
clawbot 6b580a5a0f docs: TODO.md names only open issues as next steps (closes #281)
check / check (push) Canceled after 0s
Next Step was the trial run in issue 149 and Future Steps listed issue
144, both closed. Next Step is now the 1.0 tracking issue, 66, and
Future Steps holds only DNSSEC validation, 59, which comes after 1.0.
Status drops "No git tags", which stops being true once a tag is made.

Issue 149 is not added to Completed Steps: the issue asked for no change
there beyond this entry.

Model: opus-5-5
2026-10-13 22:00:04 +02:00
clawbot cb89e4f8cf watcher: a port check that finds 443 closed removes its certificate entries (closes #265)
check / check (push) Canceled after 0s
The TLS check runs only on an open port 443, and the certificate
cleanup removed an entry only when its name stopped resolving to the
address, so an entry stayed ok for as long as its port was closed.
The cleanup after the port checks now also removes the entries of an
address whose port 443 was found closed. Nothing is notified for the
removal; the Port Change already says the port closed. When the port
opens again, the next TLS check saves the certificate as new, with no
change or recovery notification. The README says both.

Judgement call: the entry goes even while none of the name's
nameservers answer, since the port check reached the address itself.

Model: opus-5-5
2026-10-13 21:27:34 +02:00
clawbot 9e6871817a state: a state file without a section loads it empty (closes #267)
check / check (push) Canceled after 0s
Load fills in each of domains, hostnames, ports and certificates that
the file leaves out or holds as null with an empty map. Before, such a
section loaded as a nil map, and the first check's write into it
panicked, so a state file holding {} stopped the watcher at every
start.

A state test loads {} and a file missing each section in turn, and
checks that every section is an empty map. A watcher test loads {}, and
a file without the certificates section, runs the first check against
live DNS, and checks that it saved domain, hostname, port and
certificate entries.

Model: opus-5-5
2026-10-13 20:27:27 +02:00
clawbot 3592302615 watcher: a stop during the first scan sends no startup notification (closes #271)
check / check (push) Canceled after 0s
Run sent the startup notification right after the first scan without
checking the context. RunOnce returns as usual when shutdown cuts the
scan short, so a stop during the first scan sent "has started and
completed its initial scan" to every endpoint as the process shut down.
Run now sends it only when the context was not cancelled.

The new test cancels the context before Run starts, so the scan never
sends its lookup; a stop part way through a scan follows the same path.

Model: opus-5-5
2026-10-13 20:00:00 +02:00
clawbot 79761c29e1 watcher: a removed nameserver is an NS Change, not an NS Failure (closes #262)
check / check (push) Canceled after 0s
A nameserver that left a domain's delegation was reported twice: as the
domain's NS Change, and by the check of the domain's own records as an
error-level NS Failure saying it disappeared. The check of a name's
records now sends one NS Change at warning level, starting Hostname: or
Domain:, listing the nameservers it was asked at before and not now.
For a domain it leaves out those of its saved delegation, which the
domain's own NS Change lists, so a domain's new delegation is now
notified and saved only after, and only together with, its records.
Nothing is sent for a nameserver that joins.

A nameserver that was failing before it left is now listed too; the
NS Failure skipped it.

Model: opus-5-5
2026-10-13 19:27:36 +02:00
clawbot b9686c6d7b config: a metrics username without a password stops startup (closes #268)
check / check (push) Canceled after 0s
DNSWATCHER_METRICS_USERNAME turns on /metrics, and its Basic Auth used
DNSWATCHER_METRICS_PASSWORD as it was, empty included, so the username
with an empty password got in. config.New now refuses a username
without a password with ErrNoMetricsPassword, whose message names both
variables. The README's variable table says the password is required
with the username.

Judgement call: the check is in New, after buildConfig, since it
compares two settings buildConfig has already read; buildConfig is
also at the linter's length limit.

Model: opus-5-5
2026-10-13 19:10:58 +02:00
18 changed files with 884 additions and 123 deletions
+47 -13
View File
@@ -78,8 +78,9 @@ notification endpoint set, changes show only on the dashboard; see
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
not existing (see Web Dashboard and HTTP API). A domain that exists but has no not existing (see Web Dashboard and HTTP API). A domain that exists but has no
delegation of its own, such as `octocat.github.io`, has no nameservers either. delegation of its own, such as `octocat.github.io`, has no nameservers either.
When the parent zone's servers do not answer, the check fails and the set from When the parent zone's servers do not answer, or the lookup of the domain's
the previous check is kept. own records (below) fails, the check fails and the set from the previous check
is kept.
- Any change triggers a notification: - Any change triggers a notification:
- NS added to or removed from that set. A domain that had nameservers on the - NS added to or removed from that set. A domain that had nameservers on the
previous check and no longer exists gets one with all of them removed. previous check and no longer exists gets one with all of them removed.
@@ -99,11 +100,18 @@ notification endpoint set, changes show only on the dashboard; see
records, stored per nameserver. Their changes are notified as a hostname's records, stored per nameserver. Their changes are notified as a hostname's
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
address change, in a message that starts `Domain:` where a hostname's starts address change, in a message that starts `Domain:` where a hostname's starts
`Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no `Hostname:`. A nameserver removed from the domain's delegation gets only the
record change or inconsistency notification. A domain with no delegation of NS change notification above. A domain listed in
its own has these records asked at the servers of the zone it is in, as a `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
hostname has. A domain that does not exist has none: they are not asked for, notification. A domain with no delegation of its own has these records asked
and those saved by an earlier check are removed without a notification. at the servers of the zone it is in, as a hostname has. When none of those
servers answers, the lookup of its NS record set fails, and they are saved as
failed and notified as a hostname's are (see below). A nameserver the domain's
records were asked at on the previous check but not on this one gets a
hostname's NS change notification (see below), unless it was in the domain's
delegation on the previous check, as when that delegation is withdrawn. A
domain that does not exist has none: they are not asked for, and those saved
by an earlier check are removed without a notification.
### DNS Hostname Monitoring (Subdomains) ### DNS Hostname Monitoring (Subdomains)
@@ -115,7 +123,13 @@ notification endpoint set, changes show only on the dashboard; see
trace moves from a name to its parent only when the servers asked answer that trace moves from a name to its parent only when the servers asked answer that
the name has no delegation of its own, or does not exist. When they do not the name has no delegation of its own, or does not exist. When they do not
answer, the check fails and the hostname's records from the previous check are answer, the check fails and the hostname's records from the previous check are
kept. kept, unless they are the nameservers the previous check saved for the
hostname. Each of those is then saved as failed, with the reason, as a
domain's nameservers are when none of them answers, so it gets an NS query
failure notification, and an NS recovery notification when it answers again
(see below). A trace that gets no answer before it reaches them, as when the
host running dnswatcher loses its network, fails the check without a
notification.
- Queries **each** authoritative nameserver independently for **all** record - Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types - Each record type is a query of its own. When a nameserver answers some types
@@ -156,6 +170,10 @@ notification endpoint set, changes show only on the dashboard; see
- **NS recovery**: A previously-unreachable nameserver starts responding - **NS recovery**: A previously-unreachable nameserver starts responding
again. Its records are not compared with those from before it failed, so a again. Its records are not compared with those from before it failed, so a
change made while it was failing is not reported as a record change. change made while it was failing is not reported as a record change.
- **NS change**: A nameserver of the zone the hostname is in on the previous
check is not one now, whether or not it answered. One notification lists
every such nameserver on a `Removed:` line. Nothing is sent for a
nameserver that joins the zone.
- **Inconsistency detected**: Two nameservers return different record sets - **Inconsistency detected**: Two nameservers return different record sets
for the same hostname and did not already differ on the previous check. for the same hostname and did not already differ on the previous check.
Every pair of nameservers is compared. The alert is sent once for each Every pair of nameservers is compared. The alert is sent once for each
@@ -213,8 +231,14 @@ notification endpoint set, changes show only on the dashboard; see
- Certificate CN, issuer, or SANs changed (replacement detected, reports old - Certificate CN, issuer, or SANs changed (replacement detected, reports old
and new CN and issuer). and new CN and issuer).
- TLS connection failure to a previously-reachable IP:443 (handshake error, - TLS connection failure to a previously-reachable IP:443 (handshake error,
timeout, connection refused after previously succeeding). timeout, or connection refused when port 443 closed after the port check
that found it open).
- TLS recovery: a previously-failing IP:443 now completes a handshake again. - TLS recovery: a previously-failing IP:443 now completes a handshake again.
- A port check that finds port 443 closed removes the certificate entries for
that IP address, without a notification of its own: the port change
notification reports the closed port. When the port opens again, the next TLS
check saves the certificate as new, without a certificate change or recovery
notification.
### Notifications ### Notifications
@@ -237,7 +261,9 @@ includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what - **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values. record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed. - **DNS NS changes**: Which domain, which nameservers were added to or removed
from its delegation. For a hostname, or a domain with no delegation of its
own, which nameservers left the zone it is in.
- **NS address changes**: Which domain, which nameserver, its old and new - **NS address changes**: Which domain, which nameserver, its old and new
addresses. addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses - **CNAME address changes**: Which hostname or domain, the old and new addresses
@@ -282,8 +308,10 @@ clears them.
port entry left with no name goes, so the dashboard and `/api/v1/status` no port entry left with no name goes, so the dashboard and `/api/v1/status` no
longer list or count it. The first check's port checks remove the port entries longer list or count it. The first check's port checks remove the port entries
of addresses no configured name has. of addresses no configured name has.
- Each port check also removes the certificate entries for an address their name - Each port check also removes, without a notification, the certificate entries
no longer resolves to, except while none of the name's nameservers answer. for an address whose port 443 it found closed, and those for an address their
name no longer resolves to, except while none of the name's nameservers
answer.
### Web Dashboard ### Web Dashboard
@@ -437,7 +465,7 @@ following precedence (highest to lowest):
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` | | `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` | | `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_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` | | `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics; required when `DNSWATCHER_METRICS_USERNAME` is set, and startup stops without it | `""` |
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` | | `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no **`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
@@ -646,6 +674,12 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
earlier check saved one. A state file without it loads, and the first check earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification. after that saves it without a notification.
A port entry's key is its address and port, and a certificate entry's key its
address, port 443 and hostname, joined by colons. An IPv6 address is written in
brackets in these keys, as in `[2001:db8::1]:443` and
`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the
same keys.
A port entry's `hostnames` lists every name that resolves to its address, A port entry's `hostnames` lists every name that resolves to its address,
domains included. A port entry in the older format, with one `hostname` instead domains included. A port entry in the older format, with one `hostname` instead
of the `hostnames` list, loads as a list of that one name. of the `hostnames` list, loads as a list of that one name.
+18 -6
View File
@@ -10,17 +10,31 @@
# Status # Status
pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0
on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step # Next Step
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66
# Completed Steps # Completed Steps
- 2026-10-13: a hostname or a domain whose zone's nameservers all stop answering
gets an NS Failure for each, then an NS Recovery (closes #284).
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
Status says nothing about git tags (closes #281).
- 2026-10-13: a port check that finds port 443 closed removes that address's
certificate entries, so none is left shown as `ok` (closes #265).
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with - 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
that section empty; the first check no longer crashes on it (closes #267). that section empty; the first check no longer crashes on it (closes #267).
- 2026-10-13: a stop before the first scan completes sends no startup
notification; a scan that ran to the end still sends it (closes #271).
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without
`DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268).
- 2026-10-13: a save syncs the state file to disk before renaming it into place, - 2026-10-13: a save syncs the state file to disk before renaming it into place,
so a crash cannot leave it empty and stop the next start (closes #264). so a crash cannot leave it empty and stop the next start (closes #264).
- 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming - 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming
@@ -193,6 +207,4 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Future Steps # Future Steps
- 1.0 readiness: run it with a real config and read the logs: - DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59
https://git.eeqj.de/sneak/dnswatcher/issues/66
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
+11
View File
@@ -61,6 +61,13 @@ var ErrInvalidTLSExpiryWarning = errors.New(
// unset and means the default. // unset and means the default.
var ErrInvalidBool = errors.New("value must be true or false") var ErrInvalidBool = errors.New("value must be true or false")
// ErrNoMetricsPassword is returned when DNSWATCHER_METRICS_USERNAME is set and
// DNSWATCHER_METRICS_PASSWORD is empty. Basic Auth on /metrics would otherwise
// let in anyone who sends the username with an empty password.
var ErrNoMetricsPassword = errors.New(
"DNSWATCHER_METRICS_PASSWORD must be set when DNSWATCHER_METRICS_USERNAME is set",
)
// Params contains dependencies for Config. // Params contains dependencies for Config.
type Params struct { type Params struct {
fx.In fx.In
@@ -108,6 +115,10 @@ func New(_ fx.Lifecycle, params Params) (*Config, error) {
return nil, err return nil, err
} }
if cfg.MetricsUsername != "" && cfg.MetricsPassword == "" {
return nil, ErrNoMetricsPassword
}
configureDebugLogging(cfg, params) configureDebugLogging(cfg, params)
return cfg, nil return cfg, nil
+18
View File
@@ -354,6 +354,24 @@ func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
require.ErrorContains(t, err, `"www.example.net"`) require.ErrorContains(t, err, `"www.example.net"`)
} }
// TestNew_MetricsUsernameWithoutPasswordStopsStartup checks an unset and an
// empty password; TestNew_EnvironmentOverrides checks a username with one.
func TestNew_MetricsUsernameWithoutPasswordStopsStartup(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", "prom")
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password unset")
require.ErrorContains(t, err, "DNSWATCHER_METRICS_PASSWORD")
viper.Reset()
t.Setenv("DNSWATCHER_METRICS_PASSWORD", "")
_, err = config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNoMetricsPassword, "password empty")
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) { func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset() viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_TARGETS", "example.com")
+17
View File
@@ -2,6 +2,23 @@ package resolver
import "errors" import "errors"
// NoAnswerError is returned when the walk to a name's nameservers
// reached a zone through a referral and none of that zone's
// nameservers, Nameservers as the referral names them, answered. Err
// says why they did not.
type NoAnswerError struct {
Nameservers []string
Err error
}
func (e *NoAnswerError) Error() string {
return e.Err.Error()
}
func (e *NoAnswerError) Unwrap() error {
return e.Err
}
// Sentinel errors returned by the resolver. // Sentinel errors returned by the resolver.
var ( var (
// ErrNoNameservers is returned when no authoritative NS // ErrNoNameservers is returned when no authoritative NS
+3 -1
View File
@@ -31,9 +31,11 @@ func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
func (r *Resolver) FollowDelegation( func (r *Resolver) FollowDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
return r.followDelegation(ctx, domain, servers) return r.followDelegation(ctx, domain, zone, nameservers, servers)
} }
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers // FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
+23 -11
View File
@@ -204,20 +204,24 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips return ips
} }
// followDelegation follows referrals from servers, the root servers, to // followDelegation follows referrals to domain from servers, the
// domain and returns the NS set of domain's delegation. When the servers // addresses of the servers of zone, and returns the NS set of domain's
// of the zone domain is in answer that domain does not exist, the error // delegation. nameservers are the names a referral gave the servers of
// is ErrNXDomain. When they answer that it has no delegation of its own, // zone; the walk to a name's nameservers starts at the root servers, the
// servers of zone ".", which no referral names. When the servers of the
// zone domain is in answer that domain does not exist, the error is
// ErrNXDomain. When they answer that it has no delegation of its own,
// because it is not the zone's apex, the set is empty and there is no // because it is not the zone's apex, the set is empty and there is no
// error. Any other error means that no such answer came. // error. Any other error means that no such answer came; it is a
// *NoAnswerError when the servers that did not answer are those of a
// zone a referral named.
func (r *Resolver) followDelegation( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
zone string,
nameservers []string,
servers []string, servers []string,
) ([]string, error) { ) ([]string, error) {
// servers are the root servers, the servers of zone ".".
zone := "."
var withoutAddresses []string var withoutAddresses []string
for range maxDelegation { for range maxDelegation {
@@ -228,6 +232,12 @@ func (r *Resolver) followDelegation(
resp, err := r.queryZone( resp, err := r.queryZone(
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0, ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
) )
// A query that ctx cut short says nothing about the servers.
if err != nil && len(nameservers) > 0 && checkCtx(ctx) == nil {
return nil, &NoAnswerError{Nameservers: nameservers, Err: err}
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -256,6 +266,7 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
nameservers = authNS
servers, withoutAddresses = referralNameservers(resp) servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp) zone = referralZone(resp)
} }
@@ -626,7 +637,8 @@ func (r *Resolver) resolveARecord(
// them. When the servers asked answer that the name has no delegation // them. When the servers asked answer that the name has no delegation
// of its own, or does not exist, it tries each parent name in turn, so // of its own, or does not exist, it tries each parent name in turn, so
// it returns the nameservers of the zone the name is in. When they do // it returns the nameservers of the zone the name is in. When they do
// not answer, it returns the error and tries no parent name. // not answer, it returns the error and tries no parent name; when a
// referral named them, the error is a *NoAnswerError naming them.
func (r *Resolver) FindAuthoritativeNameservers( func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -655,7 +667,7 @@ func (r *Resolver) findAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(ctx, candidate, servers) nsNames, err := r.followDelegation(ctx, candidate, ".", nil, servers)
if err != nil && !errors.Is(err, ErrNXDomain) { if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err return nil, err
} }
@@ -1067,7 +1079,7 @@ func (r *Resolver) LookupNS(
} }
return r.followDelegation( return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(), ctx, dns.Fqdn(strings.ToLower(domain)), ".", nil, rootServerList(),
) )
} }
+29 -2
View File
@@ -121,7 +121,9 @@ func TestFindAuthoritativeNameservers_DelegatedSubdomain(
// delegation of its own, so the lookup returns that walk's error, about // delegation of its own, so the lookup returns that walk's error, about
// www.google.com, and tries no parent name: trying google.com and com // www.google.com, and tries no parent name: trying google.com and com
// would end in ErrNoNameservers, or in the error of a walk for one of // would end in ErrNoNameservers, or in the error of a walk for one of
// them. // them. The servers that did not answer are those the walk started at,
// which no referral named, so the error is no NoAnswerError, as when
// the host running dnswatcher has lost its network.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) { func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel() t.Parallel()
@@ -134,6 +136,9 @@ func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
require.NotErrorIs(t, err, resolver.ErrNoNameservers) require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress) assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers) assert.Empty(t, nameservers)
var noAnswer *resolver.NoAnswerError
assert.NotErrorAs(t, err, &noAnswer)
} }
func TestFindAuthoritativeNameservers_ReturnsSorted( func TestFindAuthoritativeNameservers_ReturnsSorted(
@@ -954,12 +959,34 @@ func TestFollowDelegation_NoAnswer(t *testing.T) {
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond) r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation( nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", []string{noAnswerAddress}, t.Context(), "google.com.", ".", nil, []string{noAnswerAddress},
) )
require.Error(t, err) require.Error(t, err)
assert.Empty(t, nameservers) assert.Empty(t, nameservers)
} }
// TestFollowDelegation_NoAnswerAfterReferral starts the walk for
// www.example.net at the servers of example.net as a referral names
// them, ns1.example.net and ns2.example.net, at 192.0.2.1, where nothing
// answers. The error is a NoAnswerError naming them, which the watcher
// compares with the nameservers it saved for the name.
func TestFollowDelegation_NoAnswerAfterReferral(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers := []string{"ns1.example.net.", "ns2.example.net."}
_, err := r.FollowDelegation(
t.Context(), "www.example.net.", "example.net.", nameservers,
[]string{noAnswerAddress},
)
var noAnswer *resolver.NoAnswerError
require.ErrorAs(t, err, &noAnswer)
assert.Equal(t, nameservers, noAnswer.Nameservers)
assert.Contains(t, err.Error(), "query www.example.net. @"+noAnswerAddress)
}
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers // TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in // of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error. // TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
+4 -17
View File
@@ -696,9 +696,9 @@ func TestLoadMissingFile(t *testing.T) {
} }
// TestLoadFileWithoutSections loads a state file holding {}, and files // TestLoadFileWithoutSections loads a state file holding {}, and files
// that each leave out one section. Every section loads as an empty map, // that each leave out one section. Every section loads as an empty map.
// and the first check can then save an entry in each; a section loaded // TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests
// as nil makes that panic. // runs the first check after such a load.
func TestLoadFileWithoutSections(t *testing.T) { func TestLoadFileWithoutSections(t *testing.T) {
t.Parallel() t.Parallel()
@@ -741,20 +741,7 @@ func TestLoadFileWithoutSections(t *testing.T) {
} }
if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) { if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) {
t.Fatalf("loaded %#v, want %#v", got, want) t.Errorf("loaded %#v, want %#v", got, want)
}
s.SetDomainState("example.com", &state.DomainState{})
s.SetHostnameState(testHostname, &state.HostnameState{})
s.SetPortState(testIP+":443", &state.PortState{})
s.SetCertificateState(
testIP+":443:"+testHostname, &state.CertificateState{},
)
snap := s.GetSnapshot()
if len(snap.Domains) != 1 || len(snap.Hostnames) != 1 ||
len(snap.Ports) != 1 || len(snap.Certificates) != 1 {
t.Errorf("saved one entry in each section, got %#v", snap)
} }
}) })
} }
+24
View File
@@ -83,6 +83,30 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
} }
} }
// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with
// the startup notification on and its context already cancelled, so the
// first scan is cut short before its lookup is sent. The scan did not
// complete, so no startup notification may be sent.
func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg)
ctx, cancel := context.WithCancel(t.Context())
cancel()
w.Run(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) != 0 {
t.Errorf("sent %v, want no notifications", notifications)
}
}
// newLoggingWatcher returns a watcher for a domain and a hostname, with // newLoggingWatcher returns a watcher for a domain and a hostname, with
// the real resolver, that writes what it logs at warning level or above // the real resolver, that writes what it logs at warning level or above
// into the returned buffer. // into the returned buffer.
+28
View File
@@ -122,6 +122,34 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
w.runTLSChecks(ctx) w.runTLSChecks(ctx)
} }
// CheckHostnameResult exports checkHostnameResult for testing.
func (w *Watcher) CheckHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
return w.checkHostnameResult(ctx, hostname, results, err)
}
// CheckDomainResult exports checkDomainResult for testing.
func (w *Watcher) CheckDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
w.checkDomainResult(ctx, domain, nameservers, err)
}
// FailedNameservers exports failedNameservers for testing.
func FailedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
return failedNameservers(err, prev)
}
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
+4 -1
View File
@@ -20,7 +20,10 @@ type DNSResolver interface {
) ([]string, error) ) ([]string, error)
// LookupAllRecords queries all record types for a hostname, // LookupAllRecords queries all record types for a hostname,
// returning each nameserver's response keyed by nameserver. // returning each nameserver's response keyed by nameserver. When
// the walk to the nameservers reached a zone through a referral and
// none of that zone's nameservers answered, the error is a
// *resolver.NoAnswerError naming them.
LookupAllRecords( LookupAllRecords(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
+83
View File
@@ -0,0 +1,83 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestIPv6AddressInBrackets runs the port checks, not the watcher's
// first, on hostname state built here for a hostname at an IPv6 address
// whose port 443 the previous check found closed. It is open now. Then
// it runs the TLS checks and the port checks again. The port and
// certificate keys and the Port Change notification write the address
// in brackets, so its last group cannot be read as the port, and the
// second port checks keep the certificate entry, whose address they read
// back from its key. When a last port check finds port 443 closed, the
// certificate entry is removed. Nothing is looked up.
func TestIPv6AddressInBrackets(t *testing.T) {
t.Parallel()
const aaaaType = "AAAA"
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}),
},
))
deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{
Hostnames: []string{host},
})
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
w.CheckAllPorts(t.Context())
snap := deps.state.GetSnapshot()
gotPorts := slices.Sorted(maps.Keys(snap.Ports))
wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"}
if !slices.Equal(gotPorts, wantPorts) {
t.Errorf("port entries %v, want %v", gotPorts, wantPorts)
}
gotCerts := slices.Sorted(maps.Keys(snap.Certificates))
wantCerts := []string{"[2001:db8::1]:443:www.example.net"}
if !slices.Equal(gotCerts, wantCerts) {
t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts)
}
title := "Port Change: [2001:db8::1]:443"
want := `Hostnames: www.example.net
Address: [2001:db8::1]:443
Port now open`
got := deps.notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
if len(gotCerts) != 0 {
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
}
}
+91 -12
View File
@@ -5,7 +5,6 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
@@ -74,21 +73,19 @@ b.ns.example.net.: 192.0.2.2`,
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) { func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Parallel() t.Parallel()
notifier := &mockNotifier{} cfg := defaultTestConfig(t)
w := watcher.NewForTest( cfg.Domains = []string{domain}
&config.Config{Domains: []string{domain}},
nil, nil, nil, nil, notifier, deps := newTestDeps(t, cfg)
) w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
// nsA's address changes, which also makes it differ from nsC; nsB // nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again; nsD is gone. // fails; nsC answers again.
nsD := "d.ns.example.net."
w.DetectHostnameChanges(t.Context(), domain, w.DetectHostnameChanges(t.Context(), domain,
saved(map[string]*state.NameserverRecordState{ saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}), nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}), nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(), nsC: failed(),
nsD: answered(map[string][]string{"A": {ip1}}),
}), }),
saved(map[string]*state.NameserverRecordState{ saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip2}}), nsA: answered(map[string][]string{"A": {ip2}}),
@@ -102,18 +99,17 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Context(), domain, cnameState(ip1), cnameState(ip2), t.Context(), domain, cnameState(ip1), cnameState(ip2),
) )
// NS Failure is sent for nsB failing and for nsD being gone.
want := map[string]int{ want := map[string]int{
"Record Change": 1, "Record Change": 1,
"Inconsistency": 1, "Inconsistency": 1,
"NS Failure": 2, "NS Failure": 1,
"NS Recovery": 1, "NS Recovery": 1,
"CNAME Address Change": 1, "CNAME Address Change": 1,
} }
sent := make(map[string]int) sent := make(map[string]int)
for _, n := range notifier.getNotifications() { for _, n := range deps.notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":") kind, _, _ := strings.Cut(n.Title, ":")
sent[kind]++ sent[kind]++
@@ -128,6 +124,89 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
} }
} }
// nsB, which answered, and nsC, which did not, are no longer among the
// nameservers. One NS Change lists both, for the hostname and for the
// configured apex domain with no delegation of its own. Nothing is sent
// for the domain whose delegation on the previous check listed them:
// the check of its delegation sends its NS Change.
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target string
// The domain's nameservers saved by the previous check.
delegation []string
// The NS Change sent, or "" for none.
want string
}{
{
name: "hostname",
target: host,
want: `Hostname: www.example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain with no delegation of its own",
target: domain,
delegation: []string{},
want: `Domain: example.net
Removed: b.ns.example.net., c.ns.example.net.`,
},
{
name: "domain whose delegation listed them",
target: domain,
delegation: []string{nsA, nsB, nsC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil, nil, nil, deps.notifier,
)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: tt.delegation,
})
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
})
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
got := deps.notifier.getNotifications()
if tt.want == "" {
if len(got) != 0 {
t.Errorf("sent %v, want nothing", got)
}
return
}
title := "NS Change: " + tt.target
if len(got) != 1 || got[0].Title != title ||
got[0].Message != tt.want {
t.Errorf("sent %v, want one %q with message:\n%s",
got, title, tt.want)
}
})
}
}
// The startup notification counts the configured domains and hostnames, // The startup notification counts the configured domains and hostnames,
// although the state's hostnames also hold the apex domain's own // although the state's hostnames also hold the apex domain's own
// records. Nothing is looked up: the watcher has no resolver. // records. Nothing is looked up: the watcher has no resolver.
+133
View File
@@ -2,6 +2,7 @@ package watcher_test
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -14,6 +15,10 @@ import (
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
// errNoReply is why a walk that got no reply from a zone's servers
// failed.
var errNoReply = errors.New("all servers failed: i/o timeout")
// answered is what a check saves for a nameserver that answered with // answered is what a check saves for a nameserver that answered with
// these records. // these records.
func answered(records map[string][]string) *state.NameserverRecordState { func answered(records map[string][]string) *state.NameserverRecordState {
@@ -183,6 +188,134 @@ func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
} }
} }
// TestZoneNameserversAllStopAnswering runs three checks of host from
// the point where its lookup has returned. On the first, nsA and nsB,
// the nameservers of its zone, answer. On the second, the lookup fails
// with the error the walk to them returns when neither answers (see
// the resolver's TestFollowDelegation_NoAnswerAfterReferral), given
// here because no live zone has nameservers that never answer. For a
// hostname it is the lookup of its records that fails, and for a domain
// with no delegation of its own the lookup of its delegation. Both are
// saved as failed, with the reason, and each gets an NS Failure. On the
// third, both answer, and each gets an NS Recovery. A domain's records
// are checked as a hostname's, so its first and third checks are too.
func TestZoneNameserversAllStopAnswering(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
noAnswer := &resolver.NoAnswerError{
Nameservers: []string{nsA, nsB}, Err: errNoReply,
}
tests := []struct {
name string
isDomain bool
}{
{"hostname", false},
{"domain with no delegation of its own", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
if tt.isDomain {
cfg.Domains = []string{host}
} else {
cfg.Hostnames = []string{host}
}
// The watcher is given each lookup's result, so it has no
// resolver.
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if tt.isDomain {
w.CheckDomainResult(t.Context(), host, nil, noAnswer)
} else {
w.CheckHostnameResult(t.Context(), host, nil, noAnswer)
}
current, _ := deps.state.GetHostnameState(host)
for _, ns := range []string{nsA, nsB} {
got := current.RecordsByNameserver[ns]
if got.Status != failed().Status || got.Error != errNoReply.Error() {
t.Errorf(
"%s saved with status %q, error %q; want %q, %q",
ns, got.Status, got.Error, failed().Status, errNoReply,
)
}
}
if n := countNotifications(deps, "NS Failure: "+host); n != 2 {
t.Errorf("sent %d NS Failure notifications, want 2", n)
}
w.CheckHostnameResult(t.Context(), host, bothAnswer, nil)
if n := countNotifications(deps, "NS Recovery: "+host); n != 2 {
t.Errorf("sent %d NS Recovery notifications, want 2", n)
}
})
}
}
// TestFailedNameserversOnlyForThoseOfThePreviousCheck gives
// FailedNameservers lookup errors after a check in which nsA and nsB
// answered. Only an error that names both makes them failed.
func TestFailedNameserversOnlyForThoseOfThePreviousCheck(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswered := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(records),
})
noAnswer := func(nameservers ...string) error {
return &resolver.NoAnswerError{Nameservers: nameservers, Err: errNoReply}
}
tests := []struct {
name string
err error
prev *state.HostnameState
wantFailed bool
}{
{"both did not answer", noAnswer(nsA, nsB), bothAnswered, true},
{
"the servers of a parent zone did not answer",
noAnswer(nsC), bothAnswered, false,
},
{
"the zone's delegation lists only nsA now",
noAnswer(nsA), bothAnswered, false,
},
{"no root server answered", errNoReply, bothAnswered, false},
{"no previous check", noAnswer(nsA, nsB), nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := watcher.FailedNameservers(tt.err, tt.prev)
if (got != nil) != tt.wantFailed {
t.Errorf("returned %v, want failed: %v", got, tt.wantFailed)
}
})
}
}
// TestNameserverThatNeverAnswers asks a nameserver address where // TestNameserverThatNeverAnswers asks a nameserver address where
// nothing answers, 192.0.2.1, and checks what the watcher saves for it. // nothing answers, 192.0.2.1, and checks what the watcher saves for it.
// The deadline outlasts the resolver's first two-second try, as in the // The deadline outlasts the resolver's first two-second try, as in the
+70 -3
View File
@@ -2,7 +2,9 @@ package watcher_test
import ( import (
"maps" "maps"
"net"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -172,7 +174,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
"A": {ip1}, "AAAA": {ip6}, "A": {ip1}, "AAAA": {ip6},
}), }),
}), }),
[]string{ip1 + ":443:" + host, ip6 + ":443:" + host}, []string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host},
}, },
{ {
"no nameserver answered", "no nameserver answered",
@@ -182,7 +184,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
[]string{ []string{
ip1 + ":443:" + host, ip1 + ":443:" + host,
ip2 + ":443:" + host, ip2 + ":443:" + host,
ip6 + ":443:" + host, "[" + ip6 + "]:443:" + host,
}, },
}, },
} }
@@ -205,7 +207,8 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
for _, ip := range []string{ip1, ip2, ip6} { for _, ip := range []string{ip1, ip2, ip6} {
deps.state.SetCertificateState( deps.state.SetCertificateState(
ip+":443:"+host, &state.CertificateState{Status: "ok"}, net.JoinHostPort(ip, "443")+":"+host,
&state.CertificateState{Status: "ok"},
) )
} }
@@ -222,3 +225,67 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
}) })
} }
} }
// TestCertificateStateForAPortClosed runs the port checks on hostname
// and port state built here for a configured hostname at ip1, whose
// port 443 the previous check found open and whose certificate entry
// is ok. The port is closed now: the certificate entry is removed, and
// only the Port Change is notified. When the port opens again, the next
// TLS check saves the certificate as new and notifies nothing about it.
// Nothing is looked up.
func TestCertificateStateForAPortClosed(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
certKey := ip1 + ":443:" + host
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},
})
deps.state.SetCertificateState(certKey, &state.CertificateState{
CommonName: host, Status: "ok",
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
if _, ok := deps.state.GetCertificateState(certKey); ok {
t.Errorf("certificate entry %s kept with port 443 closed", certKey)
}
sent := deps.notifier.getNotifications()
if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 ||
sent[0].Title != title {
t.Errorf("sent %v, want one %q", sent, title)
}
deps.portChecker.closed = false
w.CheckAllPorts(t.Context())
w.RunTLSChecks(t.Context())
cert, ok := deps.state.GetCertificateState(certKey)
if !ok || cert.Status != "ok" {
t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert)
}
for _, n := range deps.notifier.getNotifications() {
if strings.HasPrefix(n.Title, "TLS") {
t.Errorf("sent %v, want no TLS notification", n)
}
}
}
+163 -48
View File
@@ -6,8 +6,10 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
"net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -133,7 +135,12 @@ func (w *Watcher) Run(ctx context.Context) {
w.cleanupRemovedTargets() w.cleanupRemovedTargets()
w.RunOnce(ctx) w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
// RunOnce returns as usual when shutdown cuts the scan short, and
// the startup notification says the scan completed.
if ctx.Err() == nil {
w.maybeSendTestNotification(ctx)
}
dnsTicker := time.NewTicker(w.config.DNSInterval) dnsTicker := time.NewTicker(w.config.DNSInterval)
tlsTicker := time.NewTicker(w.config.TLSInterval) tlsTicker := time.NewTicker(w.config.TLSInterval)
@@ -284,12 +291,25 @@ func (w *Watcher) logFailedLookup(
w.log.Error(msg, args...) w.log.Error(msg, args...)
} }
// checkDomain looks up domain's delegation and checks domain with it
// (see checkDomainResult).
func (w *Watcher) checkDomain( func (w *Watcher) checkDomain(
ctx context.Context, ctx context.Context,
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) nameservers, err := w.resolver.LookupNS(ctx, domain)
w.checkDomainResult(ctx, domain, nameservers, err)
}
// checkDomainResult finishes a check of domain from nameservers and err,
// what the lookup of its delegation returned.
func (w *Watcher) checkDomainResult(
ctx context.Context,
domain string,
nameservers []string,
err error,
) {
// A domain that does not exist has no nameservers. // A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain) nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain { if nxdomain {
@@ -304,6 +324,12 @@ func (w *Watcher) checkDomain(
"error", err, "error", err,
) )
// For a domain with no delegation of its own, the servers that
// did not answer can be those of the zone it is in, at which
// its records are asked. They are then saved as failed, as a
// hostname's are.
w.checkHostnameResult(ctx, domain, nil, err)
return return
} }
@@ -322,6 +348,20 @@ func (w *Watcher) checkDomain(
ctx, nameservers, prevAddresses, ctx, nameservers, prevAddresses,
) )
// A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed.
// Otherwise the apex domain's records are also checked and saved as
// a hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine). When
// their lookup fails, the check fails as when the NS lookup does, so
// the saved delegation and the saved records come from the same
// check, as detectNSDisappearances needs.
if nxdomain {
w.state.DeleteHostnameState(domain)
} else if !w.checkHostname(ctx, domain) {
return
}
if hasPrev && !w.firstRun { if hasPrev && !w.firstRun {
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers) w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses) w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
@@ -333,19 +373,6 @@ func (w *Watcher) checkDomain(
NXDomain: nxdomain, NXDomain: nxdomain,
LastChecked: now, LastChecked: now,
}) })
// A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed.
if nxdomain {
w.state.DeleteHostnameState(domain)
return
}
// The apex domain's records are also checked and saved as a
// hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine).
w.checkHostname(ctx, domain)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -456,10 +483,12 @@ func (w *Watcher) detectNSAddressChanges(
} }
} }
// checkHostname looks up hostname's records and checks them (see
// checkHostnameResult). It reports whether it saved them.
func (w *Watcher) checkHostname( func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) { ) bool {
results, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil { if err != nil {
w.logFailedLookup( w.logFailedLookup(
@@ -468,15 +497,70 @@ func (w *Watcher) checkHostname(
"hostname", hostname, "hostname", hostname,
"error", err, "error", err,
) )
return
} }
return w.checkHostnameResult(ctx, hostname, results, err)
}
// checkHostnameResult finishes a check of hostname from results and err,
// what the lookup of its records returned, and reports whether it saved
// them. When the lookup failed, it saves nothing, unless the nameservers
// that did not answer are those of the previous check (see
// failedNameservers).
func (w *Watcher) checkHostnameResult(
ctx context.Context,
hostname string,
results map[string]*resolver.NameserverResponse,
err error,
) bool {
prev, _ := w.state.GetHostnameState(hostname) prev, _ := w.state.GetHostnameState(hostname)
if err != nil {
results = failedNameservers(err, prev)
if results == nil {
return false
}
}
w.updateHostnameState( w.updateHostnameState(
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()), ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
) )
return true
}
// failedNameservers returns a failed response for each nameserver in
// prev, a name's state from the previous check, with err as the reason,
// when err says that the walk to the name's nameservers reached their
// zone and none of them answered. For any other error, as when the walk
// got no answer before it reached their zone, or when prev is nil, it
// returns nil.
func failedNameservers(
err error,
prev *state.HostnameState,
) map[string]*resolver.NameserverResponse {
var noAnswer *resolver.NoAnswerError
if !errors.As(err, &noAnswer) || prev == nil {
return nil
}
saved := slices.Sorted(maps.Keys(prev.RecordsByNameserver))
if !slices.Equal(noAnswer.Nameservers, saved) {
return nil
}
results := make(map[string]*resolver.NameserverResponse)
for _, ns := range saved {
results[ns] = &resolver.NameserverResponse{
Nameserver: ns,
Records: make(map[string][]string),
Status: resolver.StatusError,
Error: err.Error(),
}
}
return results
} }
// updateHostnameState finishes a check of hostname from newState, built // updateHostnameState finishes a check of hostname from newState, built
@@ -772,28 +856,53 @@ func (w *Watcher) detectRecordChanges(
} }
} }
// detectNSDisappearances sends one NS Change listing the nameservers of
// the previous check that are no longer those of the zone hostname is
// in. For a configured apex domain it leaves out those of the domain's
// saved delegation, which detectNSChanges notifies when they are removed
// from it. checkDomain saves a new delegation only after this runs, and
// only together with the records, so the saved delegation and the
// nameservers in prev come from the same check.
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev, current *state.HostnameState, prev, current *state.HostnameState,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { var delegation []string
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
continue if w.isDomain(hostname) {
if domainState, ok := w.state.GetDomainState(hostname); ok {
delegation = domainState.Nameservers
} }
msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared",
w.nameLine(hostname), ns,
)
w.notify.SendNotification(
ctx,
"NS Failure: "+hostname,
msg,
"error",
)
} }
var removed []string
for ns := range prev.RecordsByNameserver {
_, ok := current.RecordsByNameserver[ns]
if !ok && !slices.Contains(delegation, ns) {
removed = append(removed, ns)
}
}
if len(removed) == 0 {
return
}
slices.Sort(removed)
msg := fmt.Sprintf(
"%s\nRemoved: %s",
w.nameLine(hostname),
strings.Join(removed, ", "),
)
w.notify.SendNotification(
ctx,
"NS Change: "+hostname,
msg,
"warning",
)
} }
// detectNSFailures notifies when a nameserver that answered on the // detectNSFailures notifies when a nameserver that answered on the
@@ -943,7 +1052,8 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
// Phase 3: Remove port state entries that no longer have // Phase 3: Remove port state entries that no longer have
// any hostname referencing them, and certificate entries for // any hostname referencing them, and certificate entries for
// an address their name no longer has. // an address whose port 443 is closed or that their name no
// longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -964,7 +1074,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
ips := w.collectIPs(name) ips := w.collectIPs(name)
for _, ip := range ips { for _, ip := range ips {
for _, port := range monitoredPorts { for _, port := range monitoredPorts {
key := fmt.Sprintf("%s:%d", ip, port) key := net.JoinHostPort(ip, strconv.Itoa(port))
if assoc[key] == nil { if assoc[key] == nil {
assoc[key] = make(map[string]bool) assoc[key] = make(map[string]bool)
} }
@@ -989,18 +1099,15 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
return result return result
} }
// parsePortKey splits an "ip:port" key into its components. // parsePortKey splits an "ip:port" key, written by net.JoinHostPort,
// into its components.
func parsePortKey(key string) (string, int) { func parsePortKey(key string) (string, int) {
lastColon := strings.LastIndex(key, ":") ip, port, err := net.SplitHostPort(key)
if lastColon < 0 { if err != nil {
return key, 0 return key, 0
} }
ip := key[:lastColon] p, err := strconv.Atoi(port)
var p int
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
if err != nil { if err != nil {
return ip, 0 return ip, 0
} }
@@ -1030,13 +1137,21 @@ func (w *Watcher) cleanupStalePorts(
} }
// cleanupStaleCertificates removes the certificate entries for an // cleanupStaleCertificates removes the certificate entries for an
// address their name no longer resolves to. An entry saved for a name // address whose port 443 was found closed, and for an address their
// none of whose nameservers answered is kept: that name's addresses are // name no longer resolves to. An entry saved for a name none of whose
// not known, not gone. // nameservers answered is kept unless its port is closed: that name's
// addresses are not known, not gone.
func (w *Watcher) cleanupStaleCertificates() { func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() { for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key) ip, hostname := parseCertKey(key)
ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort)))
if ok && !ps.Open {
w.state.DeleteCertificateState(key)
continue
}
if slices.Contains(w.collectIPs(hostname), ip) || if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) { w.noNameserverAnswered(hostname) {
continue continue
@@ -1145,7 +1260,7 @@ func (w *Watcher) checkSinglePort(
return return
} }
key := fmt.Sprintf("%s:%d", ip, port) key := net.JoinHostPort(ip, strconv.Itoa(port))
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetPortState(key) prev, hasPrev := w.state.GetPortState(key)
@@ -1207,7 +1322,7 @@ func (w *Watcher) checkTLSForHostname(
ips := w.collectIPs(hostname) ips := w.collectIPs(hostname)
for _, ip := range ips { for _, ip := range ips {
portKey := fmt.Sprintf("%s:%d", ip, tlsPort) portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort))
ps, ok := w.state.GetPortState(portKey) ps, ok := w.state.GetPortState(portKey)
if !ok || !ps.Open { if !ok || !ps.Open {
@@ -1231,7 +1346,7 @@ func (w *Watcher) checkTLSCert(
return return
} }
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname) certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname
now := time.Now().UTC() now := time.Now().UTC()
prev, hasPrev := w.state.GetCertificateState(certKey) prev, hasPrev := w.state.GetCertificateState(certKey)
+118 -9
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"slices" "slices"
"strings" "strings"
@@ -139,6 +140,22 @@ func (m *mockNotifier) getNotifications() []notification {
return result return result
} }
// cancelOnNotify is a mockNotifier that calls cancel at each
// notification, as if shutdown cut the check short there.
type cancelOnNotify struct {
*mockNotifier
cancel context.CancelFunc
}
func (n cancelOnNotify) SendNotification(
ctx context.Context,
title, message, priority string,
) {
n.mockNotifier.SendNotification(ctx, title, message, priority)
n.cancel()
}
// --- Helpers to build a Watcher and run its checks against live DNS --- // --- Helpers to build a Watcher and run its checks against live DNS ---
type testDeps struct { type testDeps struct {
@@ -461,6 +478,54 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
} }
} }
// TestFirstCheckAfterLoadingFileWithoutSections loads a state file
// holding {}, and one that leaves out the certificates section, and runs
// the first check. It saves domain, hostname, port and certificate
// entries; a section loaded as nil made it panic.
func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
}{
{"no sections", `{}`},
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
_, deps := runChecks(t, cfg, func(deps *testDeps) {
err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600)
if err != nil {
t.Fatalf("writing the state file: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state file: %v", err)
}
})
snap := deps.state.GetSnapshot()
if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 ||
len(snap.Ports) == 0 || len(snap.Certificates) == 0 {
t.Errorf(
"saved %d domain, %d hostname, %d port and %d "+
"certificate entries, want at least one of each",
len(snap.Domains), len(snap.Hostnames),
len(snap.Ports), len(snap.Certificates),
)
}
})
}
}
func TestNSChangeDetection(t *testing.T) { func TestNSChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -558,6 +623,11 @@ func TestDomainThatDoesNotExist(t *testing.T) {
// nameservers and without nxdomain, and its records, asked at the // nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two // codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight. // nameservers; github.io, the zone of the README's example, has eight.
// The previous check saved a delegation to oldNS1, now withdrawn, and
// records asked at oldNS1. A first check is cut short at its first
// notification, as shutdown would, and a second runs in full: across
// them the NS Change of its delegation removes oldNS1, and no other NS
// Change lists it again.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) { func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel() t.Parallel()
@@ -569,9 +639,30 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
var deps *testDeps var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error { livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher deps = newTestDeps(t, cfg)
w, deps = newTestWatcher(t, cfg) firstCtx, cancel := context.WithCancel(ctx)
defer cancel()
w := watcher.NewForTest(
cfg,
deps.state,
resolver.NewFromLogger(slog.Default()),
deps.portChecker,
deps.tlsChecker,
cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel},
)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1},
})
deps.state.SetHostnameState(domain, saved(
map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}),
},
))
w.RunOnce(firstCtx)
err := checkOnce(ctx, w, deps) err := checkOnce(ctx, w, deps)
@@ -593,6 +684,22 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
if _, ok := deps.state.GetHostnameState(domain); !ok { if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain) t.Errorf("no records saved for %s", domain)
} }
title := "NS Change: " + domain
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
var changes []notification
for _, n := range deps.notifier.getNotifications() {
if n.Title == title {
changes = append(changes, n)
}
}
if len(changes) != 1 || changes[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s",
changes, title, want)
}
} }
func TestNSAddressChangeDetection(t *testing.T) { func TestNSAddressChangeDetection(t *testing.T) {
@@ -718,7 +825,8 @@ func TestPortStateChange(t *testing.T) {
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
assertNotified( assertNotified(
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning", t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"),
"warning",
) )
} }
@@ -925,11 +1033,12 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
// Port and TLS checks must use the addresses this check found. // Port and TLS checks must use the addresses this check found.
for _, ip := range addresses(snap.Hostnames[testHost]) { for _, ip := range addresses(snap.Hostnames[testHost]) {
if _, ok := snap.Ports[ip+":80"]; !ok { portKey := net.JoinHostPort(ip, "80")
t.Error("port check used stale DNS: missing " + ip + ":80") if _, ok := snap.Ports[portKey]; !ok {
t.Error("port check used stale DNS: missing " + portKey)
} }
certKey := ip + ":443:" + testHost certKey := net.JoinHostPort(ip, "443") + ":" + testHost
if _, ok := snap.Certificates[certKey]; !ok { if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey) t.Error("TLS check used stale DNS: missing " + certKey)
} }
@@ -1049,7 +1158,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
} }
} }
func TestNSFailureAndRecovery(t *testing.T) { func TestNSChangeAndRecovery(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
@@ -1059,7 +1168,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
// The saved state has every nameserver live DNS lists as one that // The saved state has every nameserver live DNS lists as one that
// did not answer, and, as answering, one that live DNS does not // did not answer, and, as answering, one that live DNS does not
// list, which then disappears. // list, which the check then finds removed.
_, deps := runChecks(t, cfg, func(deps *testDeps) { _, deps := runChecks(t, cfg, func(deps *testDeps) {
byNameserver := map[string]*state.NameserverRecordState{ byNameserver := map[string]*state.NameserverRecordState{
oldNS1: answered(map[string][]string{"A": {oldIP}}), oldNS1: answered(map[string][]string{"A": {oldIP}}),
@@ -1071,7 +1180,7 @@ func TestNSFailureAndRecovery(t *testing.T) {
deps.state.SetHostnameState(testHost, saved(byNameserver)) deps.state.SetHostnameState(testHost, saved(byNameserver))
}) })
assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Change: "+testHost, "warning")
assertNotified(t, deps, "NS Recovery: "+testHost, "success") assertNotified(t, deps, "NS Recovery: "+testHost, "success")
// A nameserver that did not answer has no records to compare, so // A nameserver that did not answer has no records to compare, so