13 Commits
Author SHA1 Message Date
clawbot 6822996134 resolver: a nameserver with a failed record type is not nodata (closes #253)
check / check (push) Successful in 1m16s
classifyResponse set nodata when every record type that answered had
no records, even when another type's query got no usable reply. That
type is listed in FailedTypes and its records are unknown, so the
nameserver has not said it has none. It now stays ok, the status
README describes for a nameserver with a failed type, and nodata is
set only when no type failed. The watcher saved nodata as ok already,
so saved state is unchanged; the live test that rejects nodata no
longer fails when one of a nameserver's queries is lost.

Model: opus-5-5
2026-10-02 13:16:42 +02:00
clawbot 67b67b8475 resolver, watcher: a domain's nameservers are only its own delegation (closes #222)
check / check (push) Canceled after 0s
When a domain's parent zone's servers answer NXDOMAIN, LookupNS returns
ErrNXDomain. The watcher then saves the domain with no nameservers and
nxdomain set, shown on the dashboard and in /api/v1/status, asks for none
of its records and removes those saved, so its old nameservers go in one
NS Change. A domain with no delegation of its own gets an empty set and
its records are still asked at the zone it is in.
FindAuthoritativeNameservers moves to a parent name only on one of those
two answers; when the servers do not answer, it returns the error. After
an upgrade, a domain without its own delegation that was saved with its
parent zone's nameservers gets one NS Change; the README says so.

Model: opus-5-5
2026-10-02 12:38:45 +02:00
clawbot 9bd1a71d8f resolver: the refused-query test asks several operators, one query each (closes #251)
check / check (push) Canceled after 0s
The test asked one operator's recursive resolver for eight record types
at each of its two addresses, and passed only when all eight were
refused within one attempt; when that operator stopped answering, next
went red. It is now TestQueryServers_RecursiveResolverRefused: through
the existing QueryServers test export it sends one A query to public
resolvers of four operators in turn, inside livednstest.Retry, moving
on when one gives no reply. Each refuses a query not asking for
recursion and answers one that does, so putting the resend asking for
recursion back still fails the test at once.

Model: opus-5-5
2026-10-02 12:00:48 +02:00
clawbot 6332b48379 watcher: a name removed from the targets leaves the state (closes #223)
check / check (push) Canceled after 0s
At startup, before the first check, Run removes from the loaded state
the domain, hostname and certificate entries of names no longer in
DNSWATCHER_TARGETS, takes those names off each port entry's list of
names and removes a port entry left with none, so the dashboard,
/api/v1/status and the startup notification count only configured
names. A configured domain's own records, saved as a hostname entry
under its name, are kept. Nothing is notified. Each port check, next to
the removal of stale port entries, now also removes the certificate
entries for an address a name no longer resolves to, except while none
of its nameservers answered, as port entries already were.

Model: opus-5-5
2026-10-02 11:17:59 +02:00
clawbot 9b9e26d6b2 resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
check / check (push) Canceled after 0s
The resolver lists in FailedTypes each record type whose query to a
nameserver got no usable reply (no reply, a code other than NOERROR or
NXDOMAIN, a referral, or a truncated reply whose TCP retry failed) and
logs it unless shutdown cut it short. A nameserver that answered no
type has failed.
The watcher saves such a type in failedTypes with the previous check's
records, leaves it out of the comparison with other nameservers on that
check, and compares it with the next answer. When the previous check
did not know its records either, it is also in unknownTypes and not
compared until it answers. A nameserver whose A, AAAA or CNAME query
failed is no answer when following a CNAME or resolving addresses.

Model: opus-5-5
2026-10-02 10:51:23 +02:00
clawbot 9b524e9d63 watcher: Port Change notifications list domains apart from hostnames (closes #248)
check / check (push) Canceled after 0s
A Port Change notification's `Hosts:` line listed a port's apex domains
and hostnames together. It now has a `Domains:` line and a `Hostnames:`
line, and leaves out one that would name nothing. A name is a domain
when it is a configured domain, the rule record notifications already
use to start `Domain:` or `Hostname:`; that rule is now one method,
isDomain, which both use. The port entries saved in the state are
unchanged. README describes the new lines.

Model: opus-5-5
2026-10-02 10:42:07 +02:00
clawbot b43402a631 dashboard and status API list a port's domains apart from its hostnames (closes #245)
check / check (push) Canceled after 0s
A port entry in the state saves the apex domains that resolve to its
address with its hostnames. The dashboard's Ports table now has a
Domains column next to Hostnames, and a port entry in /api/v1/status
has a `domains` list, with `hostnames` no longer holding a domain. A
name is taken as a domain when it has a domain entry, as the dashboard
and API already tell a domain's own records from a hostname's. Both
read the split from one function, buildPorts. The state file is
unchanged; README says its port `hostnames` include domains.

Model: opus-5-5
2026-10-02 10:31:07 +02:00
clawbot 008ec5d13a resolver: ask a referral's nameservers that come without addresses (closes #221)
check / check (push) Canceled after 0s
Looking up a nameserver's own address followed only the addresses a
referral gave, so a nameserver whose zone is delegated without them,
such as a.ntpns.org of pool.ntp.org, never resolved. The walk to a
name's nameservers looked addresses up only when a referral gave none.
Both now go through queryZone, which asks the nameservers whose
addresses the referral gives first and, if none of them gives a usable
reply, looks up and asks the others. maxLookupDepth stops lookups three
deep, so delegations that point at each other still end; when the limit
is why no address was found, the error is ErrLookupDepthExceeded, not
"no address".

Model: opus-5-5
2026-10-02 10:12:08 +02:00
clawbot e46db71821 dashboard, status API and notifications tell apex domains from hostnames (closes #224)
check / check (push) Canceled after 0s
An apex domain's own records are still saved with the hostnames'
records, under the domain's name, so the port and TLS checks find its
addresses. Notifications about them now start `Domain:`, decided by the
configured domains. The dashboard and /api/v1/status, which read only
the saved state, take a hostname entry whose name also has a domain
entry as that domain's own records: the dashboard shows them in a second
table under Domains, the API in the domain's `recordsByNameserver`, and
neither lists or counts them as hostnames. The startup notification
counts domains and hostnames from the configuration. README says which
of a domain's own records are watched and how their changes are
notified.

Model: opus-5-5
2026-10-02 10:08:32 +02:00
clawbot 1218df9467 dashboard: list record types in the README's order (closes #226)
check / check (push) Canceled after 0s
Each row of the Hostnames table listed a nameserver's record types in
the order Go happens to walk the record map, which changed from row to
row and on every page load, so two nameservers with the same records
looked different. formatRecords now sorts the types by their place in
the README's list (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS); any other
type comes after them in alphabetical order. Values within a type were
already sorted by the resolver.

Model: opus-5-5
2026-10-02 09:55:07 +02:00
clawbot 250f3dd687 dashboard and status API show why a check failed (closes #225)
check / check (push) Canceled after 0s
/api/v1/status now gives `error` for each nameserver entry and
certificate entry whose status is `error`, copied from the state, which
already kept it. The dashboard shows that reason in place of the records
for a failed nameserver, which used to show the same `-` as one that
answered with no records, and across the CN, issuer and expiry cells
for a failed certificate, wrapped at a width of 20rem so the long TLS
error does not narrow the Endpoint column. The dashboard stylesheet is a
trimmed build, so the new markup uses only classes the page already had.
README Web Dashboard and HTTP API say so.

Model: opus-5-5
2026-10-02 09:14:28 +02:00
clawbot c07976a73a resolver: store a name's CNAME once per nameserver (closes #220)
check / check (push) Canceled after 0s
For a name with a CNAME, a nameserver answers a query of any type with
that CNAME, and the records of every answer were added, so the CNAME
was stored once for each of the eight record types asked for.
collectAnswerRecords now adds each value once per record type.

A state file saved before this holds the repeated values. Load keeps
each record value once, so the first check after upgrading sees no
record change and notifies nothing for them.

Model: opus-5-5
2026-10-02 09:09:51 +02:00
clawbot b047c3c64c watcher: a lookup cut short by shutdown is not logged as an error (closes #229)
check / check (push) Canceled after 0s
Stopping dnswatcher during a DNS check logged every lookup the stop cut
short as an error, "context canceled". The four places in the watcher
that log a failed lookup, checkDomain, checkHostname,
resolveNameserverAddresses and resolveCNAMEAddresses, now do it through
logFailedLookup, which logs nothing when the watcher's context was
cancelled. It asks the context, not the lookup's error: the resolver
reports a cancelled lookup with its own error, which does not wrap
context.Canceled. A context whose deadline passed is not cancelled, so a
lookup that ran out of time is still logged. The tests run each of these
lookups on a cancelled context and on one whose deadline passed; neither
sends a query.

Model: opus-5-5
2026-10-02 08:56:52 +02:00
28 changed files with 3415 additions and 308 deletions
+111 -26
View File
@@ -73,14 +73,36 @@ notification endpoint set, changes show only on the dashboard; see
to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
- 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.
and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is
only ever the domain's own delegation. A domain whose parent zone's servers
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
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
the previous check is kept.
- Any change triggers a notification:
- NS added to or removed from that set.
- 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.
After an upgrade, a domain with no delegation of its own, for which an
earlier version saved its parent zone's nameservers, also gets one with
all of them removed, on its first check. That one does not mean the domain
stopped existing: it is not shown as not existing, and its records are
still watched.
- 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
kept and nothing is sent.
kept and nothing is sent. The lookup fails when no nameserver it asks
answers every one of its queries, for A, AAAA and CNAME.
- Also watches the domain's own records as a hostname's are watched (see DNS
Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS
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
address change, in a message that starts `Domain:` where a hostname's starts
`Hostname:`. A domain with no delegation of its own has these records asked at
the servers of the zone it is in, as a hostname has. 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)
@@ -88,9 +110,26 @@ notification endpoint set, changes show only on the dashboard; see
via the Public Suffix List).
- 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).
its last two labels (a name under `co.uk`, or in a delegated subdomain). The
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
answer, the check fails and the hostname's records from the previous check are
kept.
- Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types
but the query for another gets no usable reply (no reply after two tries, an
error reply such as SERVFAIL, a referral, or a reply too large for UDP whose
retry over TCP fails), the failure is logged with the reason, and the type is
listed in the nameserver's `failedTypes` and keeps the records saved for the
nameserver by the previous check. On that check those records are not compared
with the other nameservers', so no record change or inconsistency is reported
for the type; on the next check they are compared with the nameserver's answer
as usual. When the previous check did not know the type's records either,
because the nameserver was new or failing then or the type was already listed
in `unknownTypes`, the type is also listed in `unknownTypes` and left out of
every comparison until it answers. A nameserver none of whose queries got a
usable reply has failed (see NS query failure below).
- Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
@@ -119,8 +158,9 @@ notification endpoint set, changes show only on the dashboard; see
they keep disagreeing, including after a restart. A nameserver that was
not in the previous check (newly added, or back after dropping out), or
failed on it, and answers differently is reported on the check where it
answers. If a pair agrees again and later disagrees, the alert is sent
again.
answers. So is a pair that differs in a record type whose query to either
nameserver failed on the previous check. If a pair agrees again and later
disagrees, the alert is sent again.
- **CNAME address change**: The addresses at the end of a name's CNAME chain
differ from those of the previous check. They are found when its
nameservers answer with a CNAME and no address; a name that answers with
@@ -183,19 +223,21 @@ Supported notification backends:
All configured endpoints receive every notification. Notification content
includes:
- **DNS record changes**: Which hostname, which nameserver, what record type,
old values, new values.
- **DNS record changes**: Which hostname or domain, which nameserver, what
record type, old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed.
- **NS address changes**: Which domain, which nameserver, its old and new
addresses.
- **CNAME address changes**: Which hostname, the old and new addresses at the
end of its CNAME chain.
- **CNAME address changes**: Which hostname or domain, the old and new addresses
at the end of its CNAME chain.
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
REFUSED, network error), which hostname/domain affected.
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname affected.
- **Port changes**: Which IP:port, its new state, all associated hostnames.
which hostname or domain affected.
- **Port changes**: Which IP:port, its new state, and the domains and the
hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A
line that would name nothing is left out.
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
@@ -222,6 +264,14 @@ clears them.
false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent
corruption.
- A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup,
before the first check, without a notification: its domain, hostname and
certificate entries go, it is taken off each port entry's list of names, and a
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
of addresses no configured name has.
- Each port check also removes the certificate entries for an address their name
no longer resolves to, except while none of the name's nameservers answer.
### Web Dashboard
@@ -229,10 +279,15 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
(`/`). It displays:
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
- **Domains** with their discovered nameservers.
- **Hostnames** with per-nameserver DNS records and status.
- **Ports** with open/closed state and associated hostnames.
- **TLS certificates** with CN, issuer, expiry, and status.
- **Domains** with their discovered nameservers, or "does not exist" for a
domain whose parent zone's servers answered NXDOMAIN, and each domain's own
records per nameserver and status, shown as a hostname's are.
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
whose query failed, the reason is shown in place of the records.
- **Ports** with open/closed state and the domains and hostnames that resolve to
each address, in separate columns.
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
the reason is shown in place of CN, issuer and expiry.
- **Recent alerts** (last 100 notifications sent since the process started),
displayed in reverse chronological order.
@@ -259,6 +314,16 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
| `GET /api/v1/status` | Current monitoring state |
| `GET /metrics` | Prometheus metrics, see below |
In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
is `error` also has `error`, the reason, as in the state file (see State File
Format). A domain's own records are in its entry in `domains`, under
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A domain
entry's `nxdomain` is `true` when the domain's parent zone's servers answered
NXDOMAIN, that it does not exist; its `nameservers` and `recordsByNameserver`
are then empty. A port entry lists the domains that resolve to its address in
`domains`, and the hostnames in `hostnames`.
`/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
@@ -426,7 +491,12 @@ anew each time, so no one root server gets every first query. A server that does
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
referral that leads no closer to the name is passed over for the next one. When
a referral names a zone's nameservers without their addresses, the addresses of
all of them are looked up, so that each can be asked.
all of them are looked up, so that each can be asked. When it gives addresses
for only some of them, those are asked first, and the others are looked up and
asked only if none of those gives a usable reply. Both hold in the walk to a
name's nameservers and in the lookup of a nameserver's own address. Such a
lookup can need others in turn; lookups go at most three deep, one inside
another, so delegations that point at each other still end.
This approach ensures:
@@ -449,7 +519,8 @@ resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
The state file (`DATA_DIR/state.json`) contains the complete monitoring
snapshot. Hostname records are stored **per authoritative nameserver**, not as a
merged view, to enable inconsistency detection.
merged view, to enable inconsistency detection. `hostnames` also holds each
domain's own records, under the domain's name.
```json
{
@@ -519,7 +590,7 @@ reachability:
| Status | Meaning |
| ------- | -------------------------------------------------------- |
| `ok` | Query succeeded, records are current |
| `ok` | Query succeeded, records are current except as below |
| `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
@@ -528,20 +599,33 @@ 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.
A nameserver with status `ok` whose query for one record type failed lists that
type in `failedTypes` and holds its records from the previous check, which may
not be current. When the previous check did not know the type's records either,
because the nameserver was new or failing then or the type was already listed in
`unknownTypes`, the type is also listed in `unknownTypes`, and `records` holds
nothing for it. Both lists are left out when empty.
`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 domain entry has `"nxdomain": true` when the domain's parent zone's servers
answered NXDOMAIN, that it does not exist. Its `nameservers` and
`nameserverAddresses` are then empty, and `hostnames` holds no entry for it.
`nxdomain` is left out when false.
`cnameAddresses` lists the sorted addresses at the end of the chain of every
CNAME target a hostname's nameservers gave, found when they answered with a
CNAME and no address; it is empty when they answered with an address. When a
chain cannot be followed, or none of the name's nameservers answered, 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 after that saves it without a
notification.
chain cannot be followed, or none of the name's nameservers answered its queries
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
after that saves it 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.
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
of the `hostnames` list, loads as a list of that one name.
---
@@ -685,7 +769,8 @@ docker run -d \
1. **Startup**: Check that the data directory can be written, and exit with an
error naming it if not. Load state from disk. If no state file exists, start
with empty state (first check will establish baseline without triggering
change notifications).
change notifications). Remove from the state the names no longer in
`DNSWATCHER_TARGETS` (see State Management).
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
startup.
3. **Periodic checks** (DNS always runs first):
+26
View File
@@ -19,6 +19,32 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
- 2026-10-02: a nameserver whose query for one record type failed while the
others answered with no records is `ok`, not `nodata` (closes #253).
- 2026-10-02: a domain that does not exist is shown so, with no nameservers; no
name gets a parent's nameservers when its own did not answer (closes #222).
- 2026-10-02: the refused-query test sends one query to four operators' public
resolvers in turn until one replies, not eight to one operator (closes #251).
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
the dashboard and API, at startup, before the first check (closes #223).
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous
records and alerts nothing; the other types are still saved (closes #231).
- 2026-10-02: a Port Change notification lists the port's domains on a
`Domains:` line and its hostnames on a `Hostnames:` line (closes #248).
- 2026-10-02: the dashboard's Ports table and `/api/v1/status` port entries list
a port's domains apart from its hostnames (closes #245).
- 2026-10-02: nameservers a referral names without addresses are looked up,
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221).
- 2026-10-02: an apex domain is not counted or listed as a hostname; its records
show under Domains, and notifications about them say `Domain:` (closes #224).
- 2026-10-02: the dashboard lists each nameserver's record types in one fixed
order, the README's, then any other type, not a random one (closes #226).
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
a certificate check failed, which only the state file showed (closes #225).
- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
type asked for; a state file with repeats loads each value once (closes #220).
- 2026-10-02: a DNS lookup that shutdown cuts short logs no error; one that
fails otherwise, or runs out of time, still does (closes #229).
- 2026-10-02: Record Change and Inconsistency notifications list only the record
types that differ, each with its values as plain text (closes #219).
- 2026-10-02: the startup notification no longer says every notification
+39 -4
View File
@@ -1,11 +1,14 @@
package handlers
import (
"cmp"
"embed"
"fmt"
"html/template"
"maps"
"math"
"net/http"
"slices"
"strings"
"time"
@@ -40,9 +43,16 @@ func newDashboardTemplate() *template.Template {
)
}
// dashboardData is the data passed to the dashboard template.
// dashboardData is the data passed to the dashboard template. Hostnames
// and DomainRecords split the records in Snapshot.Hostnames, which also
// holds the apex domains' own (see splitHostnames). Ports holds
// Snapshot.Ports with each port's names split into domains and
// hostnames, as /api/v1/status gives them (see buildPorts).
type dashboardData struct {
Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState
Ports map[string]*statusPortInfo
Alerts []notify.AlertEntry
StateAge string
GeneratedAt string
@@ -58,9 +68,13 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
) {
snap := h.state.GetSnapshot()
alerts := h.notifyHistory.Recent()
hostnames, domainRecords := splitHostnames(snap)
data := dashboardData{
Snapshot: snap,
Hostnames: hostnames,
DomainRecords: domainRecords,
Ports: buildPorts(snap),
Alerts: alerts,
StateAge: relTime(snap.LastUpdated),
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
@@ -122,16 +136,37 @@ func joinStrings(items []string, sep string) string {
}
// formatRecords formats a map of record type → values into a
// compact display string.
// compact display string. Record types are listed in the order the
// README lists them, any other type after them in alphabetical order,
// so rows of nameservers with the same records read the same.
func formatRecords(records map[string][]string) string {
if len(records) == 0 {
return "-"
}
order := []string{"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS"}
position := func(rtype string) int {
i := slices.Index(order, rtype)
if i < 0 {
return len(order)
}
return i
}
rtypes := slices.Collect(maps.Keys(records))
slices.SortFunc(rtypes, func(a, b string) int {
return cmp.Or(
cmp.Compare(position(a), position(b)),
strings.Compare(a, b),
)
})
var parts []string
for rtype, values := range records {
for _, v := range values {
for _, rtype := range rtypes {
for _, v := range records[rtype] {
parts = append(parts, rtype+": "+v)
}
}
+193
View File
@@ -1,6 +1,8 @@
package handlers_test
import (
"regexp"
"strings"
"testing"
"time"
@@ -78,3 +80,194 @@ func TestFormatRecords(t *testing.T) {
t.Errorf("unexpected format: %q", got)
}
}
// TestFormatRecordsTypeOrder checks that record types are listed in
// the README's order (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS), with
// any other type after them in alphabetical order.
func TestFormatRecordsTypeOrder(t *testing.T) {
t.Parallel()
got := handlers.FormatRecords(map[string][]string{
"SOA": {"ns1.example.com. hostmaster.example.com. 1 2 3 4 5"},
"NS": {"ns1.example.com.", "ns2.example.com."},
"CAA": {`0 issue "letsencrypt.org"`},
"DNAME": {"example.net."},
"TXT": {"v=spf1 -all"},
"SRV": {"10 5 443 www.example.com."},
"MX": {"10 mail.example.com."},
"CNAME": {"www.example.com."},
"AAAA": {"2001:db8::1"},
"A": {"192.0.2.1"},
})
want := strings.Join([]string{
"A: 192.0.2.1",
"AAAA: 2001:db8::1",
"CNAME: www.example.com.",
"MX: 10 mail.example.com.",
"TXT: v=spf1 -all",
"SRV: 10 5 443 www.example.com.",
`CAA: 0 issue "letsencrypt.org"`,
"NS: ns1.example.com.",
"NS: ns2.example.com.",
"DNAME: example.net.",
"SOA: ns1.example.com. hostmaster.example.com. 1 2 3 4 5",
}, ", ")
if got != want {
t.Errorf("FormatRecords lists types out of order:\n got %q\nwant %q",
got, want)
}
}
// dashboardRow returns the table row of page that contains name.
func dashboardRow(t *testing.T, page string, name string) string {
t.Helper()
for row := range strings.SplitSeq(page, "<tr") {
if strings.Contains(row, name) {
return row
}
}
t.Fatalf("dashboard has no row containing %q", name)
return ""
}
// TestDashboardShowsFailureReasons checks that the dashboard shows the
// reason in the row of a failed nameserver and of a failed certificate,
// and not in the row of a nameserver that answered.
func TestDashboardShowsFailureReasons(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
if !strings.Contains(dashboardRow(t, page, failedNS), nsFailureReason) {
t.Errorf("row of %s does not show %q", failedNS, nsFailureReason)
}
if strings.Contains(dashboardRow(t, page, answeringNS), nsFailureReason) {
t.Errorf("row of %s shows %q", answeringNS, nsFailureReason)
}
if !strings.Contains(dashboardRow(t, page, certKey), certFailedReason) {
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
}
}
// dashboardSection returns the section of page under heading.
func dashboardSection(t *testing.T, page string, heading string) string {
t.Helper()
for section := range strings.SplitSeq(page, "<section") {
words := strings.Join(strings.Fields(section), " ")
if strings.Contains(words, "> "+heading+" </h2>") {
return section
}
}
t.Fatalf("dashboard has no section headed %q", heading)
return ""
}
// TestDashboardShowsDomainRecordsUnderDomains checks that the dashboard
// shows an apex domain's own records in the Domains section, and
// neither lists nor counts the domain as a hostname.
func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
domains := dashboardSection(t, page, "Domains")
if !strings.Contains(dashboardRow(t, domains, domainAddress), testDomain) {
t.Errorf("row of %s does not name %s", domainAddress, testDomain)
}
if strings.Contains(dashboardSection(t, page, "Hostnames"), testDomain) {
t.Errorf("Hostnames section lists the domain %s", testDomain)
}
words := strings.Join(strings.Fields(page), " ")
footer := "monitoring 2 domains + 1 hostnames"
if !strings.Contains(words, footer) {
t.Errorf("dashboard does not say %q", footer)
}
// With the tags taken out, the summary bar starts "Domains 2
// Hostnames 1".
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
summary := "Domains 2 Hostnames 1"
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
t.Errorf("summary bar does not say %q", summary)
}
}
// TestDashboardMarksDomainThatDoesNotExist checks that the Domains
// section says a domain that does not exist does not exist, and does
// not say so of a domain that exists.
func TestDashboardMarksDomainThatDoesNotExist(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
domains := dashboardSection(t, page, "Domains")
if !strings.Contains(dashboardRow(t, domains, missingDomain), "does not exist") {
t.Errorf("row of %s does not say it does not exist", missingDomain)
}
if strings.Contains(dashboardRow(t, domains, testDomain), "does not exist") {
t.Errorf("row of %s says it does not exist", testDomain)
}
}
// rowCells returns the text of each cell of a dashboard table row
// whose cells start with tag, "<th" or "<td".
func rowCells(row string, tag string) []string {
tags := regexp.MustCompile(`<[^>]*>`)
parts := strings.Split(row, tag)[1:]
cells := make([]string, 0, len(parts))
for _, cell := range parts {
text := tags.ReplaceAllString(tag+cell, " ")
cells = append(cells, strings.Join(strings.Fields(text), " "))
}
return cells
}
// TestDashboardPortsTellDomainsFromHostnames checks that the Ports
// table lists an apex domain under Domains and a hostname under
// Hostnames when both resolve to the port's address.
func TestDashboardPortsTellDomainsFromHostnames(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
ports := dashboardSection(t, page, "Ports")
headings := rowCells(dashboardRow(t, ports, "Address</th>"), "<th")
cells := rowCells(dashboardRow(t, ports, sharedPort), "<td")
if len(cells) != len(headings) {
t.Fatalf("row of %s has cells %q under headings %q",
sharedPort, cells, headings)
}
under := make(map[string]string)
for i, heading := range headings {
under[heading] = cells[i]
}
if under["Domains"] != testDomain {
t.Errorf("row of %s lists %q under Domains, want %q",
sharedPort, under["Domains"], testDomain)
}
if under["Hostnames"] != testHostname {
t.Errorf("row of %s lists %q under Hostnames, want %q",
sharedPort, under["Hostnames"], testHostname)
}
}
+82 -18
View File
@@ -9,8 +9,13 @@ import (
)
// statusDomainInfo holds status information for a monitored domain.
// RecordsByNameserver holds the domain's own records, in the form a
// hostname's Nameservers holds the hostname's. NXDomain is true when
// the domain's parent zone's servers answered that it does not exist.
type statusDomainInfo struct {
Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -18,6 +23,7 @@ type statusDomainInfo struct {
type statusHostnameNSInfo struct {
Records map[string][]string `json:"records"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -28,8 +34,11 @@ type statusHostnameInfo struct {
}
// statusPortInfo holds status information for a monitored port.
// Domains and Hostnames list the apex domains and the hostnames that
// resolve to its address.
type statusPortInfo struct {
Open bool `json:"open"`
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -41,6 +50,7 @@ type statusCertificateInfo struct {
NotAfter time.Time `json:"notAfter"`
SubjectAlternativeNames []string `json:"subjectAlternativeNames"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -94,21 +104,44 @@ func buildStatusResponse(
LastUpdated: snap.LastUpdated,
Domains: make(map[string]*statusDomainInfo),
Hostnames: make(map[string]*statusHostnameInfo),
Ports: make(map[string]*statusPortInfo),
Certificates: make(map[string]*statusCertificateInfo),
}
buildDomains(snap, resp)
buildHostnames(snap, resp)
buildPorts(snap, resp)
hostnames, domainRecords := splitHostnames(snap)
buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp)
resp.Ports = buildPorts(snap)
buildCertificates(snap, resp)
buildCounts(resp)
return resp
}
// splitHostnames returns the records saved in snap.Hostnames in two
// maps: the hostnames' and the apex domains' own. The watcher saves a
// domain's own records there under the domain's name, which has an
// entry in snap.Domains too.
func splitHostnames(
snap state.Snapshot,
) (map[string]*state.HostnameState, map[string]*state.HostnameState) {
hostnames := make(map[string]*state.HostnameState)
domainRecords := make(map[string]*state.HostnameState)
for name, hs := range snap.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domainRecords[name] = hs
} else {
hostnames[name] = hs
}
}
return hostnames, domainRecords
}
func buildDomains(
snap state.Snapshot,
domainRecords map[string]*state.HostnameState,
resp *statusResponse,
) {
for name, ds := range snap.Domains {
@@ -116,22 +149,37 @@ func buildDomains(
copy(ns, ds.Nameservers)
sort.Strings(ns)
records := make(map[string]*statusHostnameNSInfo)
if hs, ok := domainRecords[name]; ok {
records = nameserverInfo(hs)
}
resp.Domains[name] = &statusDomainInfo{
Nameservers: ns,
RecordsByNameserver: records,
NXDomain: ds.NXDomain,
LastChecked: ds.LastChecked,
}
}
}
func buildHostnames(
snap state.Snapshot,
hostnames map[string]*state.HostnameState,
resp *statusResponse,
) {
for name, hs := range snap.Hostnames {
info := &statusHostnameInfo{
Nameservers: make(map[string]*statusHostnameNSInfo),
for name, hs := range hostnames {
resp.Hostnames[name] = &statusHostnameInfo{
Nameservers: nameserverInfo(hs),
LastChecked: hs.LastChecked,
}
}
}
// nameserverInfo copies each nameserver's answer saved in hs.
func nameserverInfo(
hs *state.HostnameState,
) map[string]*statusHostnameNSInfo {
info := make(map[string]*statusHostnameNSInfo)
for ns, nsState := range hs.RecordsByNameserver {
recs := make(map[string][]string, len(nsState.Records))
@@ -141,32 +189,47 @@ func buildHostnames(
recs[rtype] = copied
}
info.Nameservers[ns] = &statusHostnameNSInfo{
info[ns] = &statusHostnameNSInfo{
Records: recs,
Status: nsState.Status,
Error: nsState.Error,
LastChecked: nsState.LastChecked,
}
}
resp.Hostnames[name] = info
return info
}
// buildPorts returns the port entries saved in snap. A port entry
// saves apex domains with its hostnames; they are told apart as in
// splitHostnames, by a domain entry in snap.Domains.
func buildPorts(snap state.Snapshot) map[string]*statusPortInfo {
ports := make(map[string]*statusPortInfo, len(snap.Ports))
for key, ps := range snap.Ports {
domains := []string{}
hostnames := []string{}
for _, name := range ps.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
func buildPorts(
snap state.Snapshot,
resp *statusResponse,
) {
for key, ps := range snap.Ports {
hostnames := make([]string, len(ps.Hostnames))
copy(hostnames, ps.Hostnames)
sort.Strings(domains)
sort.Strings(hostnames)
resp.Ports[key] = &statusPortInfo{
ports[key] = &statusPortInfo{
Open: ps.Open,
Domains: domains,
Hostnames: hostnames,
LastChecked: ps.LastChecked,
}
}
return ports
}
func buildCertificates(
@@ -183,6 +246,7 @@ func buildCertificates(
NotAfter: cs.NotAfter,
SubjectAlternativeNames: sans,
Status: cs.Status,
Error: cs.Error,
LastChecked: cs.LastChecked,
}
}
+309
View File
@@ -0,0 +1,309 @@
package handlers_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/handlers"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/notify"
"sneak.berlin/go/dnswatcher/internal/state"
)
// The state the handler tests serve: www.example.com has one nameserver
// that answered and one whose query failed, and its certificate check
// failed. example.net is an apex domain, whose own records are saved
// with the hostnames' records, as the watcher saves them. Both names
// resolve to domainAddress, whose port 443 entry lists them.
// missingDomain is an apex domain whose parent zone's servers answered
// that it does not exist, saved with no nameservers and no records.
const (
missingDomain = "does-not-exist.example"
testHostname = "www.example.com"
answeringNS = "ns1.example.com."
failedNS = "ns2.example.com."
nsFailureReason = "server returned a referral"
certKey = "192.0.2.1:443:www.example.com"
certFailedReason = "x509: certificate has expired or is not yet valid"
testDomain = "example.net"
domainNS = "a.iana-servers.net."
domainAddress = "192.0.2.2"
sharedPort = domainAddress + ":443"
)
// newHandlersWithFailures builds real Handlers whose state holds the
// entries described above.
func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
t.Helper()
glob, err := globals.New(nil)
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: glob})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
Logger: log,
Config: &config.Config{},
})
if err != nil {
t.Fatalf("notify.New: %v", err)
}
st, err := state.New(fxtest.NewLifecycle(t), state.Params{
Logger: log,
Config: &config.Config{DataDir: t.TempDir()},
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
setTestState(st)
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
Notify: notifier,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
}
// setTestState sets the entries described above in st.
func setTestState(st *state.State) {
now := time.Now()
st.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
answeringNS: {
Records: map[string][]string{
"A": {"192.0.2.1", domainAddress},
},
Status: "ok",
LastChecked: now,
},
failedNS: {
Records: map[string][]string{},
Status: "error",
Error: nsFailureReason,
LastChecked: now,
},
},
LastChecked: now,
})
st.SetCertificateState(certKey, &state.CertificateState{
Status: "error",
Error: certFailedReason,
LastChecked: now,
})
st.SetDomainState(testDomain, &state.DomainState{
Nameservers: []string{domainNS},
LastChecked: now,
})
st.SetHostnameState(testDomain, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
domainNS: {
Records: map[string][]string{"A": {domainAddress}},
Status: "ok",
LastChecked: now,
},
},
LastChecked: now,
})
st.SetPortState(sharedPort, &state.PortState{
Open: true,
Hostnames: []string{testDomain, testHostname},
LastChecked: now,
})
st.SetDomainState(missingDomain, &state.DomainState{
Nameservers: []string{},
NXDomain: true,
LastChecked: now,
})
}
// get serves one GET request to handler and returns the response body.
func get(t *testing.T, handler http.HandlerFunc) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(
t.Context(), http.MethodGet, "/", nil,
)
handler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
return rec.Body.String()
}
// TestStatusGivesFailureReasons checks that /api/v1/status gives the
// reason for a failed nameserver entry and a failed certificate entry,
// and no error for a nameserver that answered.
func TestStatusGivesFailureReasons(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Hostnames map[string]struct {
Nameservers map[string]map[string]any `json:"nameservers"`
} `json:"hostnames"`
Certificates map[string]map[string]any `json:"certificates"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
nameservers := resp.Hostnames[testHostname].Nameservers
got := nameservers[failedNS]["error"]
if got != nsFailureReason {
t.Errorf("failed nameserver error = %v, want %q",
got, nsFailureReason)
}
_, has := nameservers[answeringNS]["error"]
if has {
t.Errorf("answering nameserver has an error field: %v",
nameservers[answeringNS])
}
got = resp.Certificates[certKey]["error"]
if got != certFailedReason {
t.Errorf("failed certificate error = %v, want %q",
got, certFailedReason)
}
}
// TestStatusGivesDomainRecordsUnderTheDomain checks that /api/v1/status
// gives an apex domain's own records in its domain entry, and neither
// lists nor counts the domain as a hostname.
func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Counts struct {
Hostnames int `json:"hostnames"`
} `json:"counts"`
Domains map[string]struct {
RecordsByNameserver map[string]struct {
Records map[string][]string `json:"records"`
} `json:"recordsByNameserver"`
} `json:"domains"`
Hostnames map[string]any `json:"hostnames"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
if resp.Counts.Hostnames != 1 {
t.Errorf("counts.hostnames = %d, want 1", resp.Counts.Hostnames)
}
if _, listed := resp.Hostnames[testDomain]; listed {
t.Errorf("hostnames lists the domain %s", testDomain)
}
records := resp.Domains[testDomain].RecordsByNameserver[domainNS].Records
if !slices.Equal(records["A"], []string{domainAddress}) {
t.Errorf("domain %s records at %s = %v, want A %s",
testDomain, domainNS, records, domainAddress)
}
}
// TestStatusMarksDomainThatDoesNotExist checks that /api/v1/status sets
// nxdomain for a domain that does not exist, with no nameservers or
// records, and not for a domain that exists.
func TestStatusMarksDomainThatDoesNotExist(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Domains map[string]struct {
Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]any `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
} `json:"domains"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
missing := resp.Domains[missingDomain]
if !missing.NXDomain || len(missing.Nameservers) != 0 ||
len(missing.RecordsByNameserver) != 0 {
t.Errorf("domain %s = %+v, want nxdomain and nothing else",
missingDomain, missing)
}
if resp.Domains[testDomain].NXDomain {
t.Errorf("domain %s has nxdomain set", testDomain)
}
}
// TestStatusPortsTellDomainsFromHostnames checks that a port entry in
// /api/v1/status lists an apex domain in domains and a hostname in
// hostnames when both resolve to its address.
func TestStatusPortsTellDomainsFromHostnames(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Ports map[string]struct {
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"`
} `json:"ports"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
port := resp.Ports[sharedPort]
if !slices.Equal(port.Domains, []string{testDomain}) {
t.Errorf("port %s domains = %v, want [%s]",
sharedPort, port.Domains, testDomain)
}
if !slices.Equal(port.Hostnames, []string{testHostname}) {
t.Errorf("port %s hostnames = %v, want [%s]",
sharedPort, port.Hostnames, testHostname)
}
}
+78 -38
View File
@@ -39,7 +39,7 @@
Hostnames
</div>
<div class="text-2xl font-bold text-teal-400 mt-1">
{{ len .Snapshot.Hostnames }}
{{ len .Hostnames }}
</div>
</div>
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
@@ -84,7 +84,11 @@
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ if $ds.NXDomain }}
<span class="text-red-400">does not exist</span>
{{ else }}
{{ joinStrings $ds.Nameservers ", " }}
{{ end }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $ds.LastChecked }}
@@ -94,6 +98,24 @@
</tbody>
</table>
</div>
{{ if .DomainRecords }}
<div class="overflow-x-auto mt-4">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Domain</th>
<th class="py-2 px-3">NS</th>
<th class="py-2 px-3">Status</th>
<th class="py-2 px-3">Records</th>
<th class="py-2 px-3">Checked</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{{ template "records" .DomainRecords }}
</tbody>
</table>
</div>
{{ end }}
{{ else }}
<p class="text-slate-600 italic text-xs">
No domains configured.
@@ -108,7 +130,7 @@
>
Hostnames
</h2>
{{ if .Snapshot.Hostnames }}
{{ if .Hostnames }}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
@@ -121,39 +143,7 @@
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{{ range $name, $hs := .Snapshot.Hostnames }}
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ $ns }}
</td>
<td class="py-2 px-3">
{{ if eq $nsr.Status "ok" }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
>ok</span
>
{{ else }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
>{{ $nsr.Status }}</span
>
{{ end }}
</td>
<td
class="py-2 px-3 text-slate-400 break-all max-w-xs"
>
{{ formatRecords $nsr.Records }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $nsr.LastChecked }}
</td>
</tr>
{{ end }}
{{ end }}
{{ template "records" .Hostnames }}
</tbody>
</table>
</div>
@@ -171,19 +161,20 @@
>
Ports
</h2>
{{ if .Snapshot.Ports }}
{{ if .Ports }}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Address</th>
<th class="py-2 px-3">State</th>
<th class="py-2 px-3">Domains</th>
<th class="py-2 px-3">Hostnames</th>
<th class="py-2 px-3">Checked</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{{ range $key, $ps := .Snapshot.Ports }}
{{ range $key, $ps := .Ports }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $key }}
@@ -201,6 +192,9 @@
>
{{ end }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Domains ", " }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Hostnames ", " }}
</td>
@@ -258,6 +252,11 @@
>
{{ end }}
</td>
{{ if $cs.Error }}
<td colspan="3" class="py-2 px-3 text-red-400 break-all">
<div class="max-w-xs">{{ $cs.Error }}</div>
</td>
{{ else }}
<td class="py-2 px-3 text-slate-200">
{{ $cs.CommonName }}
</td>
@@ -285,6 +284,7 @@
{{ end }}
{{ end }}
</td>
{{ end }}
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $cs.LastChecked }}
</td>
@@ -363,8 +363,48 @@
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
>
dnswatcher &middot; monitoring {{ len .Snapshot.Domains }} domains +
{{ len .Snapshot.Hostnames }} hostnames
{{ len .Hostnames }} hostnames
</div>
</div>
</body>
</html>
{{/* ---- One row per nameserver of each name in the map it is given ---- */}}
{{ define "records" }}
{{ range $name, $hs := . }}
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ $ns }}
</td>
<td class="py-2 px-3">
{{ if eq $nsr.Status "ok" }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
>ok</span
>
{{ else }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
>{{ $nsr.Status }}</span
>
{{ end }}
</td>
<td
class="py-2 px-3 text-slate-400 break-all max-w-xs"
>
{{ if $nsr.Error }}
<span class="text-red-400">{{ $nsr.Error }}</span>
{{ else }}
{{ formatRecords $nsr.Records }}
{{ end }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $nsr.LastChecked }}
</td>
</tr>
{{ end }}
{{ end }}
{{ end }}
+17
View File
@@ -10,6 +10,10 @@ var (
"no authoritative nameservers found",
)
// ErrNXDomain is returned when the servers of the zone a domain
// is in answer NXDOMAIN: the domain does not exist.
ErrNXDomain = errors.New("domain does not exist")
// ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown.
@@ -22,6 +26,12 @@ var (
"reply is an error or a referral that leads no closer",
)
// ErrTruncated is the reason given for a reply too large for UDP
// whose retry over TCP failed.
ErrTruncated = errors.New(
"reply truncated and its retry over TCP failed",
)
// ErrIntercepted is returned when every root server refused a
// query. Root servers refuse no query, so the refusals came from
// something on the network answering in their place.
@@ -33,6 +43,13 @@ var (
"CNAME chain depth exceeded",
)
// ErrLookupDepthExceeded is returned when nameserver addresses
// were not looked up because lookups were already maxLookupDepth
// deep, one inside another.
ErrLookupDepthExceeded = errors.New(
"lookups of nameserver addresses go too deep",
)
// ErrContextCanceled wraps context cancellation for the
// resolver's iterative queries.
ErrContextCanceled = errors.New("context canceled")
+78 -2
View File
@@ -2,15 +2,70 @@ package resolver
import (
"context"
"log/slog"
"time"
"github.com/miekg/dns"
)
// NewWithFailingTCP returns a Resolver whose TCP client gives up before
// it can connect, so the retry over TCP of every truncated reply fails.
func NewWithFailingTCP(log *slog.Logger) *Resolver {
r := NewFromLogger(log)
r.tcp = &tcpClient{timeout: time.Nanosecond}
return r
}
// NewWithQueryTimeout returns a Resolver whose queries over UDP give up
// after timeout, so a test that asks an address where nothing answers
// does not wait out the usual timeout.
func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
r := NewFromLogger(log)
r.client = &udpClient{timeout: timeout}
return r
}
// FollowDelegation exports followDelegation for testing.
func (r *Resolver) FollowDelegation(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
return r.followDelegation(ctx, domain, servers)
}
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
// for testing.
func (r *Resolver) FindAuthoritativeNameserversFrom(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
return r.findAuthoritativeNameservers(ctx, domain, servers)
}
// ResolveNSIterative exports resolveNSIterative for testing.
func (r *Resolver) ResolveNSIterative(
ctx context.Context,
domain string,
) ([]string, error) {
return r.resolveNSIterative(ctx, domain)
}
// ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr)
}
// CollectAnswerRecords exports collectAnswerRecords for testing.
func CollectAnswerRecords(msg *dns.Msg, resp *NameserverResponse) {
var state queryState
collectAnswerRecords(msg, resp, &state)
}
// UsableReply exports usableReply for testing.
func UsableReply(resp *dns.Msg, zone string, name string) bool {
return usableReply(resp, zone, name)
@@ -48,12 +103,33 @@ func (r *Resolver) QueryEachNS(
return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
}
// ResolveNSIPs exports resolveNSIPs for testing.
// ResolveNSIPs exports resolveNSIPs for testing, looking each name up
// as a lookup that no other lookup started.
func (r *Resolver) ResolveNSIPs(
ctx context.Context,
nsNames []string,
) []string {
return r.resolveNSIPs(ctx, nsNames)
ips, _ := r.resolveNSIPs(ctx, nsNames, 1)
return ips
}
// MaxLookupDepth exports maxLookupDepth for testing.
const MaxLookupDepth = maxLookupDepth
// QueryZone exports queryZone for testing.
func (r *Resolver) QueryZone(
ctx context.Context,
given []string,
withoutAddresses []string,
zone string,
name string,
qtype uint16,
depth int,
) (*dns.Msg, error) {
return r.queryZone(
ctx, given, withoutAddresses, zone, name, qtype, depth,
)
}
// RootServerList exports rootServerList for testing.
+295 -80
View File
@@ -8,6 +8,7 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -19,6 +20,16 @@ const (
maxRetries = 2
maxDelegation = 20
timeoutMultiplier = 2
// maxLookupDepth is how many lookups of nameserver addresses may be
// under way one inside another. Looking up a nameserver's address
// can meet a referral that names nameservers without their
// addresses, which are then looked up in turn; without a limit,
// delegations that point at each other would never end. Each level
// multiplies the queries sent. pool.ntp.org needs three: the
// address of its nameserver g.ntpns.org can need a.ntpns.org's,
// which needs a bitnames.com nameserver's.
maxLookupDepth = 3
)
// ErrRefused is returned when a DNS server refuses a query.
@@ -89,6 +100,9 @@ func (r *Resolver) tryExchange(
return resp, err
}
// retryTCP returns the reply to msg over TCP when resp, its reply over
// UDP, is truncated. When that fails it returns resp, still truncated,
// which holds only the records that fit.
func (r *Resolver) retryTCP(
ctx context.Context,
msg *dns.Msg,
@@ -190,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips
}
// followDelegation follows referrals from servers, the root servers, to
// domain and returns the NS set of domain's delegation. 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
// error. Any other error means that no such answer came.
func (r *Resolver) followDelegation(
ctx context.Context,
domain string,
@@ -198,13 +218,15 @@ func (r *Resolver) followDelegation(
// servers are the root servers, the servers of zone ".".
zone := "."
var withoutAddresses []string
for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err := r.queryServers(
ctx, servers, zone, domain, dns.TypeNS,
resp, err := r.queryZone(
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
)
if err != nil {
return nil, err
@@ -218,10 +240,15 @@ func (r *Resolver) followDelegation(
// An authoritative reply comes from the servers of the zone
// domain is in; it is not a referral, even when its authority
// section lists that zone's NS records. Without NS records in
// the answer, domain is not the zone's apex and has no
// nameservers of its own.
// the answer, domain has no nameservers of its own: it does
// not exist, when the reply is NXDOMAIN, or else it is not the
// zone's apex.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return nil, ErrNoNameservers
return []string{}, nil
}
authNS := extractNSSet(resp.Ns)
@@ -229,18 +256,7 @@ func (r *Resolver) followDelegation(
return r.resolveNSIterative(ctx, domain)
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
nextServers = r.resolveNSIPs(ctx, authNS)
}
if len(nextServers) == 0 {
return nil, ErrNoNameservers
}
servers = nextServers
servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp)
}
@@ -319,13 +335,20 @@ func (r *Resolver) queryServers(
return nil, fmt.Errorf("all servers failed: %w", lastErr)
}
// isErrorReply reports whether msg is an error reply: one with any code
// but NOERROR and NXDOMAIN, such as SERVFAIL, NOTIMP or FORMERR. An error
// reply says nothing about the name's records.
func isErrorReply(msg *dns.Msg) bool {
return msg.Rcode != dns.RcodeSuccess && msg.Rcode != dns.RcodeNameError
}
// usableReply reports whether resp, a reply from one of the servers of
// zone to a query about name, is usable. An error reply such as SERVFAIL
// is not. Nor is a referral, unless it refers the query to a zone below
// zone that name is in: a server that refers it back to zone, up or
// sideways does not serve zone as it should.
func usableReply(resp *dns.Msg, zone string, name string) bool {
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
if isErrorReply(resp) {
return false
}
@@ -366,29 +389,116 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
return extractNSSet(resp.Answer)
}
// referralNameservers returns the IPv4 addresses that resp, a referral,
// gives for the nameservers it names, and the names of the nameservers
// it gives no address for.
func referralNameservers(resp *dns.Msg) ([]string, []string) {
glue := extractGlue(resp.Extra)
var given, withoutAddresses []string
for _, ns := range extractNSSet(resp.Ns) {
ips := glueIPs([]string{ns}, glue)
if len(ips) == 0 {
withoutAddresses = append(withoutAddresses, ns)
}
given = append(given, ips...)
}
return given, withoutAddresses
}
// queryZone asks the servers of zone about name as queryServers does:
// first those at given, the addresses a referral gave, and only when
// none of them gives a usable reply, the nameservers named
// withoutAddresses, once their addresses are looked up. depth is how
// many lookups of a nameserver's address are under way, 0 in the walk
// to a domain's nameservers; at maxLookupDepth, no address is looked
// up. When the limit is why none was found, here or in a lookup this
// one started, the error is ErrLookupDepthExceeded.
func (r *Resolver) queryZone(
ctx context.Context,
given []string,
withoutAddresses []string,
zone string,
name string,
qtype uint16,
depth int,
) (*dns.Msg, error) {
err := fmt.Errorf(
"no address for any nameserver of %s: %w", zone, ErrNoNameservers,
)
if len(given) > 0 {
var resp *dns.Msg
resp, err = r.queryServers(ctx, given, zone, name, qtype)
if err == nil {
return resp, nil
}
}
if len(withoutAddresses) == 0 {
return nil, err
}
if depth >= maxLookupDepth {
return nil, fmt.Errorf(
"addresses of the nameservers of %s not looked up: %w",
zone, ErrLookupDepthExceeded,
)
}
lookedUp, limitErr := r.resolveNSIPs(ctx, withoutAddresses, depth+1)
if limitErr != nil {
return nil, limitErr
}
if len(lookedUp) == 0 {
return nil, err
}
return r.queryServers(ctx, lookedUp, zone, name, qtype)
}
// resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, for a referral that carries none. The walk can
// then go on to the zone's other nameservers when one gives no usable
// reply.
// whose name resolves, each looked up at depth (see resolveARecord).
// The walk can then go on to the zone's other nameservers when one
// gives no usable reply. When none resolves and the depth limit
// stopped one of the lookups, it returns that lookup's error.
func (r *Resolver) resolveNSIPs(
ctx context.Context,
nsNames []string,
) []string {
var ips []string
depth int,
) ([]string, error) {
var (
ips []string
limitErr error
)
for _, ns := range nsNames {
resolved, err := r.resolveARecord(ctx, ns)
if err == nil {
resolved, err := r.resolveARecord(ctx, ns, depth)
switch {
case err == nil:
ips = append(ips, resolved...)
case errors.Is(err, ErrLookupDepthExceeded):
limitErr = err
}
}
return ips
if len(ips) > 0 {
return ips, nil
}
return nil, limitErr
}
// resolveNSIterative queries for NS records using iterative
// resolution as a fallback when followDelegation finds no
// authoritative answer in the delegation chain.
// authoritative answer in the delegation chain. Its result means what
// followDelegation's does.
func (r *Resolver) resolveNSIterative(
ctx context.Context,
domain string,
@@ -418,6 +528,16 @@ func (r *Resolver) resolveNSIterative(
return nsNames, nil
}
// As in followDelegation: domain has no nameservers of its
// own.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return []string{}, nil
}
// Follow delegation.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
@@ -438,11 +558,14 @@ func (r *Resolver) resolveNSIterative(
return nil, ErrNoNameservers
}
// resolveARecord resolves a hostname to IPv4 addresses using
// iterative resolution through the delegation chain.
// resolveARecord resolves a hostname, a nameserver's name, to IPv4
// addresses using iterative resolution through the delegation chain.
// depth is how many lookups of a nameserver's address are under way,
// this one included: 1 for a lookup that no other lookup started.
func (r *Resolver) resolveARecord(
ctx context.Context,
hostname string,
depth int,
) ([]string, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
@@ -452,13 +575,16 @@ func (r *Resolver) resolveARecord(
servers := rootServerList()
zone := "."
var withoutAddresses []string
for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err := r.queryServers(
ctx, servers, zone, hostname, dns.TypeA,
resp, err := r.queryZone(
ctx, servers, withoutAddresses, zone, hostname, dns.TypeA,
depth,
)
if err != nil {
return nil, fmt.Errorf(
@@ -485,17 +611,7 @@ func (r *Resolver) resolveARecord(
break
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
// Resolve NS IPs iteratively — but guard
// against infinite recursion by using only
// already-resolved servers.
break
}
servers = nextServers
servers, withoutAddresses = referralNameservers(resp)
zone = referralZone(resp)
}
@@ -507,12 +623,23 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the
// 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.
// 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
// 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.
func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context,
domain string,
) ([]string, error) {
return r.findAuthoritativeNameservers(ctx, domain, rootServerList())
}
// findAuthoritativeNameservers is FindAuthoritativeNameservers with each
// walk starting at servers, the root servers.
func (r *Resolver) findAuthoritativeNameservers(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
@@ -528,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(
ctx, candidate, rootServerList(),
)
if err == nil && len(nsNames) > 0 {
nsNames, err := r.followDelegation(ctx, candidate, servers)
if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err
}
if len(nsNames) > 0 {
sort.Strings(nsNames)
return nsNames, nil
}
// The root servers would refuse every parent name too.
if errors.Is(err, ErrIntercepted) {
return nil, err
}
}
return nil, ErrNoNameservers
@@ -584,7 +708,7 @@ func (r *Resolver) queryNameserver(
return nil, ErrContextCanceled
}
nsIPs, err := r.resolveARecord(ctx, nsHostname)
nsIPs, err := r.resolveARecord(ctx, nsHostname, 1)
if err != nil {
return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err)
}
@@ -632,14 +756,20 @@ func (r *Resolver) queryTypes(
type queryState struct {
gotNXDomain bool
gotSERVFAIL bool
gotErrorReply bool
errorReply string // its code, such as SERVFAIL, or number if unnamed
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
}
// queryEachType asks the nameserver at nsIP about hostname once for each
// record type in qtypes, and lists in resp.FailedTypes the types whose
// query got no usable reply, logging each with the reason unless ctx was
// cancelled: shutdown cancels it, and a query it cut short did not fail.
func (r *Resolver) queryEachType(
ctx context.Context,
nsIP string,
@@ -654,7 +784,34 @@ func (r *Resolver) queryEachType(
break
}
r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
err := r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
if err == nil {
state.answered = true
continue
}
rtype := dns.TypeToString[qtype]
resp.FailedTypes = append(resp.FailedTypes, rtype)
if errors.Is(ctx.Err(), context.Canceled) {
continue
}
r.log.Warn(
"record type query failed",
"hostname", hostname,
"nameserver", resp.Nameserver,
"type", rtype,
"error", err,
)
}
// The reply about another type can carry the name's CNAME. When the
// query for CNAME itself failed, that is left out too, so Records
// holds nothing for a failed type.
for _, rtype := range resp.FailedTypes {
delete(resp.Records, rtype)
}
for k := range resp.Records {
@@ -664,6 +821,9 @@ func (r *Resolver) queryEachType(
return state
}
// querySingleType asks the nameserver at nsIP about hostname's records
// of type qtype. It returns nil when the nameserver answered: with
// records, with none, or with NXDOMAIN; otherwise it returns why not.
func (r *Resolver) querySingleType(
ctx context.Context,
nsIP string,
@@ -671,7 +831,7 @@ func (r *Resolver) querySingleType(
qtype uint16,
resp *NameserverResponse,
state *queryState,
) {
) error {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
switch {
@@ -683,37 +843,70 @@ func (r *Resolver) querySingleType(
state.netErr = err
}
return
return err
}
return readReply(msg, resp, state)
}
// readReply adds to resp the records in msg, a nameserver's reply to a
// query about one record type. It returns nil when the nameserver
// answered: with records, with none, or with NXDOMAIN; otherwise it
// returns why not.
func readReply(
msg *dns.Msg,
resp *NameserverResponse,
state *queryState,
) error {
if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true
return
return nil
}
if msg.Rcode == dns.RcodeServerFailure {
state.gotSERVFAIL = true
if isErrorReply(msg) {
state.gotErrorReply = true
return
code, named := dns.RcodeToString[msg.Rcode]
if !named {
code = strconv.Itoa(msg.Rcode)
}
state.errorReply = code
return fmt.Errorf(
"server returned %s: %w", state.errorReply, ErrUnusableReply,
)
}
// 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 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.
// does not hold the zone may send one.
if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
return
return fmt.Errorf("server returned a referral: %w", ErrUnusableReply)
}
// A reply still truncated is one whose TCP retry failed, and holds
// only the records that fit.
if msg.Truncated {
state.netErr = ErrTruncated
return ErrTruncated
}
collectAnswerRecords(msg, resp, state)
return nil
}
// collectAnswerRecords adds the records in msg's answer to resp, each
// value once per record type. For a name with a CNAME, a nameserver
// answers a query of any type with that CNAME, so the same value comes
// in the answer to every type asked for.
func collectAnswerRecords(
msg *dns.Msg,
resp *NameserverResponse,
@@ -726,9 +919,12 @@ func collectAnswerRecords(
}
typeName := dns.TypeToString[rr.Header().Rrtype]
if !slices.Contains(resp.Records[typeName], val) {
resp.Records[typeName] = append(
resp.Records[typeName], val,
)
}
state.hasRecords = true
}
}
@@ -743,26 +939,32 @@ func isTimeout(err error) bool {
return false
}
// classifyResponse sets the nameserver's status. One that answered no
// record type has failed, and Error says why; one that answered some has
// the status of those answers. It has no data only when every type
// answered with no records: a type in FailedTypes may have records, so a
// nameserver with one stays ok.
func classifyResponse(resp *NameserverResponse, state queryState) {
switch {
case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords:
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords:
case state.gotErrorReply && !state.answered:
resp.Status = StatusError
resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords:
resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.answered:
resp.Status = StatusError
resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords:
case state.netErr != nil && !state.answered:
resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.hasRecords:
case state.gotReferral && !state.answered:
resp.Status = StatusError
resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain:
// An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData
}
}
@@ -851,12 +1053,22 @@ func (r *Resolver) queryEachNS(
return results, nil
}
// LookupNS returns the NS record set for a domain.
// LookupNS returns the NS record set of a domain, as the delegation from
// its parent zone's servers lists it, and never a parent name's. When
// they answer that the domain does not exist, the error is ErrNXDomain.
// When they answer that it has no delegation of its own, the set is
// empty and there is no error.
func (r *Resolver) LookupNS(
ctx context.Context,
domain string,
) ([]string, error) {
return r.FindAuthoritativeNameservers(ctx, domain)
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
)
}
// LookupAllRecords performs iterative resolution to find all DNS
@@ -920,9 +1132,11 @@ func (r *Resolver) resolveIPWithCNAME(
}
// collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when
// every nameserver timed out, failed or returned a referral: that is not
// a name with no addresses.
// first CNAME target among them. A nameserver whose query for one of the
// types failed gave only part of the addresses, and is left out. It
// returns ErrNoNameserverAnswered when every nameserver timed out,
// failed, returned a referral or was left out: that is not a name with
// no addresses.
func collectIPs(
results map[string]*NameserverResponse,
) ([]string, string, error) {
@@ -935,7 +1149,8 @@ func collectIPs(
answered := false
for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError {
if resp.Status == StatusTimeout || resp.Status == StatusError ||
len(resp.FailedTypes) > 0 {
continue
}
@@ -0,0 +1,149 @@
package resolver
import (
"strconv"
"syscall"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestClassifyResponse sets a nameserver's status from the results of
// its queries and the record types whose query failed, built here. One
// that answered some record types, even with no records, has not failed
// when its query for another type got no usable reply, whatever the
// reason, and is ok, not nodata: that type may have records. One whose
// every query got none has failed. Only one whose every type answered
// with no records is nodata.
func TestClassifyResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
results queryState
failedTypes []string
wantStatus string
wantError string
}{
{
"every type answered with no records",
queryState{answered: true},
nil,
StatusNoData, "",
},
{
"some types answered with no records, another timed out",
queryState{answered: true, gotTimeout: true},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got SERVFAIL",
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED},
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another's reply was " +
"truncated and its retry over TCP failed",
queryState{answered: true, netErr: ErrTruncated},
[]string{"TXT"},
StatusOK, "",
},
{
"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true},
[]string{"A"},
StatusOK, "",
},
{
"every query timed out",
queryState{gotTimeout: true},
[]string{"A", "AAAA", "CNAME"},
StatusTimeout, "all queries timed out",
},
{
"every query got NOTIMP",
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
[]string{"A", "AAAA", "CNAME"},
StatusError, "server returned NOTIMP",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := &NameserverResponse{Status: StatusOK, FailedTypes: tt.failedTypes}
classifyResponse(resp, tt.results)
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
assert.Equal(t, tt.failedTypes, resp.FailedTypes)
})
}
}
// TestReadReply checks which replies to a query about one record type,
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A
// reply with any other code is not, and the type's query has failed; a
// nameserver whose only reply it is has failed, and Error gives the
// code, or its number when the code has no name.
func TestReadReply(t *testing.T) {
t.Parallel()
tests := []struct {
rcode int
wantStatus string
wantError string
}{
{dns.RcodeSuccess, StatusNoData, ""},
{dns.RcodeNameError, StatusNXDomain, ""},
{dns.RcodeServerFailure, StatusError, "server returned SERVFAIL"},
{dns.RcodeNotImplemented, StatusError, "server returned NOTIMP"},
{dns.RcodeFormatError, StatusError, "server returned FORMERR"},
{12, StatusError, "server returned 12"}, // unassigned, no name
}
for _, tt := range tests {
t.Run(strconv.Itoa(tt.rcode), func(t *testing.T) {
t.Parallel()
msg := new(dns.Msg)
msg.Authoritative = true
msg.Rcode = tt.rcode
resp := &NameserverResponse{Records: map[string][]string{}}
var state queryState
err := readReply(msg, resp, &state)
classifyResponse(resp, state)
if tt.wantStatus == StatusError {
require.ErrorIs(t, err, ErrUnusableReply)
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
})
}
}
+51
View File
@@ -43,6 +43,25 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips)
}
// TestCollectIPs_FailedTypeIsNoAnswer checks that a nameserver whose
// query for one of the types failed is no answer: its addresses are
// only part of them.
func TestCollectIPs_FailedTypeIsNoAnswer(t *testing.T) {
t.Parallel()
ips, _, err := resolver.CollectIPs(
map[string]*resolver.NameserverResponse{
nsExample1: {
Records: map[string][]string{"A": {"192.0.2.1"}},
FailedTypes: []string{"AAAA"},
Status: resolver.StatusOK,
},
},
)
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
assert.Empty(t, ips)
}
const (
// exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it.
@@ -238,6 +257,38 @@ func TestExtractRecordValue_LetterCase(t *testing.T) {
}
}
// TestCollectAnswerRecords_CNAMEOnce collects the answers a nameserver
// gives for a name with a CNAME, one for each record type a check asks
// for. Each answer holds the CNAME, which must be stored once.
func TestCollectAnswerRecords_CNAMEOnce(t *testing.T) {
t.Parallel()
cname := &dns.CNAME{
Hdr: dns.RR_Header{
Name: "git.eeqj.de.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET,
},
Target: "fsn1app1.datavi.be.",
}
resp := &resolver.NameserverResponse{Records: map[string][]string{}}
for _, qtype := range []uint16{
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS,
} {
msg := new(dns.Msg)
msg.SetQuestion("git.eeqj.de.", qtype)
msg.Answer = []dns.RR{cname}
resolver.CollectAnswerRecords(msg, resp)
}
assert.Equal(t,
map[string][]string{"CNAME": {"fsn1app1.datavi.be."}},
resp.Records,
)
}
// TestShuffled shuffles the root servers with many seeds. Every order
// must hold each root server once, so each is tried before a
// resolution fails; each root server must come first for some seed, so
+2 -2
View File
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
return out
}
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
// point, so that both entry points stay independently exercised.
// liveLookupNS looks up the NS record set of domain, a domain that has
// one, retrying until the delegation chain can be walked.
func liveLookupNS(
t *testing.T,
r *resolver.Resolver,
+4
View File
@@ -31,9 +31,13 @@ type Params struct {
}
// NameserverResponse holds one nameserver's response for a query.
// FailedTypes lists the record types whose query got no usable reply,
// and Records holds nothing for them: their records are not known. When
// no record type got one, Status and Error say the nameserver failed.
type NameserverResponse struct {
Nameserver string
Records map[string][]string
FailedTypes []string
Status string
Error string
}
+381 -28
View File
@@ -1,7 +1,9 @@
package resolver_test
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net"
@@ -23,6 +25,13 @@ import (
// Test helpers
// ----------------------------------------------------------------
// nonexistentDomain is a .com domain that does not exist.
const nonexistentDomain = "dnswatcher-test-does-not-exist.com"
// noAnswerAddress is 192.0.2.1, a documentation address: nothing
// answers there.
const noAnswerAddress = "192.0.2.1"
func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper()
@@ -86,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain(
assert.Equal(t, fromZone, fromHost)
}
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
// that the name has no delegation of its own, so it gets their names,
// not those of the cmu.edu servers. Every referral on the way gives the
// nameservers' addresses, so the walk sends few queries.
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
fromParent := liveLookupNS(t, r, "cmu.edu")
assert.Equal(t, fromZone, fromHost)
assert.NotEqual(t, fromParent, fromHost)
}
// TestFindAuthoritativeNameservers_NoAnswer starts each walk for
// www.google.com at 192.0.2.1, a documentation address where nothing
// answers. A walk that got no answer does not say that the name has no
// 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
// would end in ErrNoNameservers, or in the error of a walk for one of
// them.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FindAuthoritativeNameserversFrom(
t.Context(), "www.google.com", []string{noAnswerAddress},
)
require.Error(t, err)
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
@@ -162,6 +212,112 @@ func TestResolveNSIPs_EveryNameserver(t *testing.T) {
assert.ElementsMatch(t, want, got)
}
// TestResolveNSIPs_ZoneDelegatedWithoutAddresses looks up the address
// of a.ntpns.org, a nameserver of pool.ntp.org. The org servers delegate
// ntpns.org to nameservers in other zones and give none of their
// addresses, so those are looked up on the way.
func TestResolveNSIPs_ZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ips := liveResolveNSIPs(t, r, []string{"a.ntpns.org."}, 1)
for _, ip := range ips {
assert.NotNil(t, net.ParseIP(ip), "should be valid IP: %s", ip)
}
}
// TestQueryZone_GivenAddressesFail asks the servers of ntp.org about
// pool.ntp.org, as the walk to a name under ntp.org does after the org
// servers' referral. That referral names four nameservers and gives an
// address for ns1.everett.org alone; here the given address is
// 192.0.2.1, where nothing answers, so the other three must be looked
// up and asked.
func TestQueryZone_GivenAddressesFail(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var resp *dns.Msg
livednstest.Retry(
t,
"QueryZone(192.0.2.1 and three ntp.org nameservers, pool.ntp.org)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryZone(
ctx, []string{"192.0.2.1"},
[]string{"anyns.pch.net.", "dns1.udel.edu.", "dns2.udel.edu."},
"ntp.org.", "pool.ntp.org.", dns.TypeNS, 0,
)
return err
},
)
assert.NotEmpty(t, resolver.NSSetFrom(resp, "pool.ntp.org."))
}
// TestQueryZone_LookupDepth asks the servers of g.ntpns.org, a
// nameserver of pool.ntp.org, for its address, as looking that address
// up does when anyns.pch.net, one of the servers of ntpns.org, gives the
// referral to g.ntpns.org without addresses. Their addresses are looked
// up (here only a.ntpns.org's), and that needs a bitnames.com
// nameserver's address, as the org servers delegate ntpns.org without
// addresses. From depth 1, where looking up g.ntpns.org's address
// starts, that makes three lookups and the address is found. From one
// below maxLookupDepth, the bitnames.com lookup would be past the limit,
// so nothing can be asked, and the error says the limit is why.
func TestQueryZone_LookupDepth(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
withoutAddresses := []string{"a.ntpns.org."}
var resp *dns.Msg
livednstest.Retry(
t,
"QueryZone(a.ntpns.org without its address, g.ntpns.org)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryZone(
ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.",
dns.TypeA, 1,
)
return err
},
)
assert.NotEmpty(t, resp.Answer)
var limitErr error
// Any other error is live DNS not answering, and is retried.
livednstest.Retry(
t,
"QueryZone(a.ntpns.org without its address, g.ntpns.org, "+
"one below the limit)",
func(ctx context.Context) error {
_, limitErr = r.QueryZone(
ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.",
dns.TypeA, resolver.MaxLookupDepth-1,
)
if limitErr == nil ||
errors.Is(limitErr, resolver.ErrLookupDepthExceeded) {
return nil
}
return limitErr
},
)
require.ErrorIs(t, limitErr, resolver.ErrLookupDepthExceeded)
}
// ----------------------------------------------------------------
// QueryNameserver tests
// ----------------------------------------------------------------
@@ -185,6 +341,20 @@ func TestQueryNameserver_BasicA(t *testing.T) {
)
}
// TestQueryNameserver_ZoneDelegatedWithoutAddresses asks a.ntpns.org, a
// nameserver of pool.ntp.org, about pool.ntp.org, as the watcher does.
// The org servers delegate ntpns.org without the addresses of its
// nameservers, so finding a.ntpns.org's address needs a lookup inside
// the one QueryNameserver starts.
func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
resp := liveQueryNameserver(t, r, "a.ntpns.org.", "pool.ntp.org", "A")
assert.Equal(t, resolver.StatusOK, resp.Status)
}
func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel()
@@ -245,6 +415,30 @@ func TestQueryNameserver_TXT(t *testing.T) {
)
}
// TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails asks a google.com
// nameserver about google.com with a resolver whose retries over TCP
// fail. google.com's TXT records do not fit in a reply over UDP, so TXT
// is reported as failed, holding none of the records that fit, and
// logged with the reason, while the nameserver, which answered the other
// types, is ok.
func TestQueryNameserver_TruncatedReplyWhoseTCPRetryFails(t *testing.T) {
t.Parallel()
ns := findOneNSForDomain(t, newTestResolver(t), "google.com")
var logs bytes.Buffer
r := resolver.NewWithFailingTCP(slog.New(slog.NewTextHandler(&logs, nil)))
resp := liveQueryNameserver(t, r, ns, "google.com")
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Contains(t, resp.FailedTypes, "TXT")
assert.NotContains(t, resp.Records, "TXT")
assert.Contains(t, logs.String(),
"hostname=google.com. nameserver="+ns+" type=TXT error=",
)
}
func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel()
@@ -296,48 +490,45 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error)
}
// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public
// recursive resolver, about google.com at both of its addresses. Quad9
// refuses a query that does not ask for recursion and answers one that
// does. The resolver never asks for recursion, so it must be reported
// as refusing, never as answering.
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) {
// TestQueryServers_RecursiveResolverRefused passes a public recursive
// resolver to QueryServers as the server of google.com. These resolvers
// refuse a query that does not ask for recursion and answer one that
// does. The resolver never asks for recursion, so the query must be
// reported as refused, never answered. Each resolver is run by a
// different operator, and they are asked in turn until one replies, so
// one operator not answering does not fail the test.
func TestQueryServers_RecursiveResolverRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
resolvers := []string{
"64.6.64.6", "185.222.222.222", "4.2.2.1", "9.9.9.9",
}
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} {
var resp *resolver.NameserverResponse
var err error
livednstest.Retry(
t,
"QueryNameserverIP("+ip+", google.com)",
"QueryServers(public recursive resolvers, google.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, ip, ip, "google.com",
for _, ip := range resolvers {
_, err = r.QueryServers(
ctx, []string{ip}, "google.com.", "google.com.",
dns.TypeA,
)
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s",
livednstest.ErrNoAnswer, ip, resp.Error,
)
}
// A refusal or an answer is a reply; anything else may
// be no reply at all, so the next resolver is asked.
if err == nil || errors.Is(err, resolver.ErrRefused) {
return nil
}
}
return fmt.Errorf("%w: %w", livednstest.ErrNoAnswer, err)
},
)
assert.Equal(t, resolver.StatusError, resp.Status, ip)
assert.Equal(t, "server returned REFUSED", resp.Error, ip)
}
require.ErrorIs(t, err, resolver.ErrRefused)
}
// googleNameserverIPv4s returns the IPv4 addresses of google.com's
@@ -681,6 +872,145 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
assert.Equal(t, fromFind, fromLookup)
}
// TestLookupNS_ParentZoneDelegatedWithoutAddresses looks up the
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
// ntpns.org, without the addresses of its nameservers, so the walk has
// to look them up to ask them. If it did not, the walk for g.ntpns.org
// would fail.
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
nameservers := liveLookupNS(t, r, "g.ntpns.org")
assert.Contains(t, nameservers, "a.ntpns.org.")
}
// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com
// domain that does not exist. The .com servers answer NXDOMAIN, so the
// error is ErrNXDomain, and the domain does not get their names.
func TestLookupNS_DomainThatDoesNotExist(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var (
nameservers []string
err error
)
livednstest.Retry(
t,
"LookupNS("+nonexistentDomain+")",
func(ctx context.Context) error {
nameservers, err = r.LookupNS(ctx, nonexistentDomain)
if errors.Is(err, resolver.ErrNXDomain) {
return nil
}
return err
},
)
require.ErrorIs(t, err, resolver.ErrNXDomain)
assert.Empty(t, nameservers)
}
// TestLookupNS_NoDelegationOfItsOwn looks up the nameservers of
// www.google.com, a name in the google.com zone with no delegation of
// its own, as a domain such as octocat.github.io is. The google.com
// servers answer with no NS records for it: the set is empty, and it is
// not ErrNXDomain.
func TestLookupNS_NoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var nameservers []string
livednstest.Retry(
t,
"LookupNS(www.google.com)",
func(ctx context.Context) error {
var err error
nameservers, err = r.LookupNS(ctx, "www.google.com")
return err
},
)
assert.Empty(t, nameservers)
}
// TestFollowDelegation_NoAnswer starts the walk LookupNS uses, for
// google.com, at 192.0.2.1, a documentation address where nothing
// answers. A walk that got no answer is an error, not an empty set,
// which the watcher would report as an NS Change with every nameserver
// removed.
func TestFollowDelegation_NoAnswer(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FollowDelegation(
t.Context(), "google.com.", []string{noAnswerAddress},
)
require.Error(t, err)
assert.Empty(t, nameservers)
}
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
// of www.google.com as the fallback walk does. As in
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
func TestResolveNSIterative_NoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var nameservers []string
livednstest.Retry(
t,
"ResolveNSIterative(www.google.com)",
func(ctx context.Context) error {
var err error
nameservers, err = r.ResolveNSIterative(ctx, "www.google.com")
return err
},
)
assert.Empty(t, nameservers)
}
// TestResolveNSIterative_DomainThatDoesNotExist walks to the nameservers
// of a .com domain that does not exist as the fallback walk does. As in
// TestLookupNS_DomainThatDoesNotExist, the error is ErrNXDomain.
func TestResolveNSIterative_DomainThatDoesNotExist(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
var err error
livednstest.Retry(
t,
"ResolveNSIterative("+nonexistentDomain+")",
func(ctx context.Context) error {
_, err = r.ResolveNSIterative(ctx, nonexistentDomain)
if errors.Is(err, resolver.ErrNXDomain) {
return nil
}
return err
},
)
require.ErrorIs(t, err, resolver.ErrNXDomain)
}
// ----------------------------------------------------------------
// ResolveIPAddresses tests
// ----------------------------------------------------------------
@@ -865,6 +1195,29 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error)
}
// TestQueryNameserverIP_CancelledLogsNothing cancels the context while
// a query to 192.0.2.1, where nothing answers, is waiting for a reply,
// as shutdown does. The query was cut short, not failed, so nothing is
// logged.
func TestQueryNameserverIP_CancelledLogsNothing(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
r := resolver.NewFromLogger(slog.New(slog.NewTextHandler(&logs, nil)))
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
time.AfterFunc(100*time.Millisecond, cancel)
_, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1", "example.com",
)
require.NoError(t, err)
assert.Empty(t, logs.String())
}
// TestCollectIPs_NoNameserverAnswered takes the response of a
// nameserver at 192.0.2.1, where nothing answers, as
// TestQueryNameserverIP_Timeout does. Addresses collected from
+91 -1
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"sync"
"time"
@@ -37,16 +38,26 @@ type Params struct {
// DomainState holds the monitoring state for an apex domain.
// NameserverAddresses holds the sorted addresses each nameserver's name
// resolves to, by nameserver name. A state file written before it
// existed loads with it nil.
// existed loads with it nil. NXDomain is true when the domain's parent
// zone's servers answered that it does not exist; it then has no
// nameservers.
type DomainState struct {
Nameservers []string `json:"nameservers"`
NameserverAddresses map[string][]string `json:"nameserverAddresses"`
NXDomain bool `json:"nxdomain,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
// NameserverRecordState holds one NS's response for a hostname.
// FailedTypes lists the record types whose query to the nameserver
// failed on this check: Records holds for them the records saved by the
// previous check, which are kept. UnknownTypes lists those of them whose
// records the previous check did not know either, as when the
// nameserver was new or failing then: Records holds nothing for them.
type NameserverRecordState struct {
Records map[string][]string `json:"records"`
FailedTypes []string `json:"failedTypes,omitempty"`
UnknownTypes []string `json:"unknownTypes,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
@@ -121,6 +132,8 @@ type CertificateState struct {
}
// Snapshot is the complete monitoring state persisted to disk.
// Hostnames also holds each apex domain's own records, under the
// domain's name, which has an entry in Domains too.
type Snapshot struct {
Version int `json:"version"`
LastUpdated time.Time `json:"lastUpdated"`
@@ -201,6 +214,19 @@ func (s *State) Load() error {
return fmt.Errorf("parsing state file: %w", err)
}
// A state file saved before each record value was stored once can
// hold a hostname's CNAME once for every record type asked for.
// Each value is kept once, so the first check does not see a
// record change.
for _, hs := range snapshot.Hostnames {
for _, ns := range hs.RecordsByNameserver {
for recordType, values := range ns.Records {
slices.Sort(values)
ns.Records[recordType] = slices.Compact(values)
}
}
}
s.snapshot = &snapshot
s.log.Info("loaded state from disk", "path", path)
@@ -275,6 +301,27 @@ func (s *State) GetDomainState(
return ds, ok
}
// DeleteDomainState removes a domain state entry.
func (s *State) DeleteDomainState(domain string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Domains, domain)
}
// GetAllDomainNames returns the names of all domain state entries.
func (s *State) GetAllDomainNames() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.snapshot.Domains))
for name := range s.snapshot.Domains {
names = append(names, name)
}
return names
}
// SetHostnameState updates the state for a hostname.
func (s *State) SetHostnameState(
hostname string,
@@ -298,6 +345,28 @@ func (s *State) GetHostnameState(
return hs, ok
}
// DeleteHostnameState removes a hostname state entry.
func (s *State) DeleteHostnameState(hostname string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Hostnames, hostname)
}
// GetAllHostnames returns the names of all hostname state entries,
// which include each apex domain's own records.
func (s *State) GetAllHostnames() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.snapshot.Hostnames))
for name := range s.snapshot.Hostnames {
names = append(names, name)
}
return names
}
// SetPortState updates the state for a port.
func (s *State) SetPortState(key string, ps *PortState) {
s.mu.Lock()
@@ -360,6 +429,27 @@ func (s *State) GetCertificateState(
return cs, ok
}
// DeleteCertificateState removes a certificate state entry.
func (s *State) DeleteCertificateState(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Certificates, key)
}
// GetAllCertificateKeys returns all certificate state keys.
func (s *State) GetAllCertificateKeys() []string {
s.mu.RLock()
defer s.mu.RUnlock()
keys := make([]string, 0, len(s.snapshot.Certificates))
for k := range s.snapshot.Certificates {
keys = append(keys, k)
}
return keys
}
// checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an
// unwritable directory stops the process, instead of the process running
+108
View File
@@ -236,6 +236,61 @@ func TestSaveLoadRoundTrip_CNAMEAddresses(t *testing.T) {
}
}
// TestSaveLoadRoundTrip_FailedTypes checks that a nameserver's
// failedTypes and unknownTypes survive a save and load. Without
// unknownTypes, a type whose records were not known would load as one
// with no records.
func TestSaveLoadRoundTrip_FailedTypes(t *testing.T) {
t.Parallel()
dir := t.TempDir()
s := state.NewForTestWithDataDir(dir)
failed := []string{"TXT", "CAA"}
unknown := []string{"CAA"}
s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
Records: map[string][]string{"TXT": {"v=spf1 -all"}},
FailedTypes: failed,
UnknownTypes: unknown,
Status: "ok",
},
},
})
err := s.Save()
if err != nil {
t.Fatalf("Save() error: %v", err)
}
loaded := state.NewForTestWithDataDir(dir)
err = loaded.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
hs, ok := loaded.GetHostnameState(testHostname)
if !ok {
t.Fatal("missing hostname " + testHostname)
}
ns1 := hs.RecordsByNameserver[testNS1]
if ns1 == nil {
t.Fatal("missing nameserver " + testNS1)
}
if !reflect.DeepEqual(ns1.FailedTypes, failed) {
t.Errorf("failedTypes: got %#v", ns1.FailedTypes)
}
if !reflect.DeepEqual(ns1.UnknownTypes, unknown) {
t.Errorf("unknownTypes: got %#v", ns1.UnknownTypes)
}
}
// TestLoadStateFromBeforeCNAMEAddresses loads a state file written
// before the addresses at the end of a hostname's CNAME chain were
// saved. They load as not known (nil), not as none.
@@ -277,6 +332,59 @@ func TestLoadStateFromBeforeCNAMEAddresses(t *testing.T) {
}
}
// TestLoadStateWithRepeatedValues loads a state file saved when a
// hostname's CNAME was stored once for every record type asked for.
// Each value must load once, and every different value must load.
func TestLoadStateWithRepeatedValues(t *testing.T) {
t.Parallel()
dir := t.TempDir()
data := []byte(`{
"version": 1,
"hostnames": {
"www.example.com": {
"recordsByNameserver": {
"ns1.example.com.": {
"records": {
"A": ["192.0.2.2", "192.0.2.1", "192.0.2.2", "192.0.2.1"],
"CNAME": ["a.example.net.", "a.example.net.", "a.example.net."]
},
"status": "ok"
}
}
}
}
}`)
err := os.WriteFile(filepath.Join(dir, "state.json"), data, 0o600)
if err != nil {
t.Fatalf("writing state file: %v", err)
}
s := state.NewForTestWithDataDir(dir)
err = s.Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
hs, ok := s.GetHostnameState(testHostname)
if !ok {
t.Fatal("missing hostname " + testHostname)
}
want := map[string][]string{
"A": {"192.0.2.1", "192.0.2.2"},
"CNAME": {"a.example.net."},
}
got := hs.RecordsByNameserver[testNS1].Records
if !reflect.DeepEqual(got, want) {
t.Errorf("records: got %v, want %v", got, want)
}
}
// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
t.Parallel()
+72
View File
@@ -1,10 +1,13 @@
package watcher_test
import (
"bytes"
"context"
"log/slog"
"reflect"
"strings"
"testing"
"time"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
@@ -79,3 +82,72 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
t.Errorf("sent %v, want no notifications", notifications)
}
}
// newLoggingWatcher returns a watcher for a domain and a hostname, with
// the real resolver, that writes what it logs at warning level or above
// into the returned buffer.
func newLoggingWatcher(t *testing.T) (*watcher.Watcher, *bytes.Buffer) {
t.Helper()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testSmallDomain}
cfg.Hostnames = []string{host}
w, _ := newTestWatcher(t, cfg)
logs := &bytes.Buffer{}
w.SetLogger(slog.New(slog.NewJSONHandler(
logs, &slog.HandlerOptions{Level: slog.LevelWarn},
)))
return w, logs
}
// TestLookupCutShortIsNotLogged checks a domain and a hostname, looks
// up a nameserver's addresses and follows a CNAME, with the context
// cancelled, as shutdown leaves it. The real resolver fails each lookup
// without sending a query. Shutdown cutting a lookup short is not a
// failure, so nothing may be logged at warning level or above.
func TestLookupCutShortIsNotLogged(t *testing.T) {
t.Parallel()
w, logs := newLoggingWatcher(t)
ctx, cancel := context.WithCancel(t.Context())
cancel()
w.RunOnce(ctx)
w.ResolveNameserverAddresses(ctx, []string{nsA}, nil)
w.ResolveCNAMEAddresses(ctx, host, cnameState(), nil)
if logs.Len() > 0 {
t.Errorf("logged at warning level or above:\n%s", logs)
}
}
// TestLookupOutOfTimeIsLoggedAsError does what
// TestLookupCutShortIsNotLogged does, with the context's deadline passed
// instead. A lookup that ran out of time did fail, so the domain's NS
// lookup, the hostname's lookup, the nameserver's address lookup and the
// CNAME's are each logged as an error.
func TestLookupOutOfTimeIsLoggedAsError(t *testing.T) {
t.Parallel()
w, logs := newLoggingWatcher(t)
ctx, cancel := context.WithDeadline(t.Context(), time.Now())
t.Cleanup(cancel)
w.RunOnce(ctx)
w.ResolveNameserverAddresses(ctx, []string{nsA}, nil)
w.ResolveCNAMEAddresses(ctx, host, cnameState(), nil)
const want = 4
lines := strings.Count(logs.String(), "\n")
errorLines := strings.Count(logs.String(), `"level":"ERROR"`)
if lines != want || errorLines != want {
t.Errorf("logged:\n%s\nwant %d lines, each at error level", logs, want)
}
}
+36
View File
@@ -202,6 +202,42 @@ func TestCNAMEWhoseNameserversAllFailedKeepsPrevious(t *testing.T) {
}
}
// TestCNAMEWhoseAddressQueryFailedKeepsPrevious checks a name whose
// nameserver answered, but whose query for A, AAAA or CNAME failed with
// nothing kept for it. That is not an answer with no address: the
// addresses the previous check saved from following its CNAME are kept,
// and nothing is looked up, the watcher having no resolver.
func TestCNAMEWhoseAddressQueryFailedKeepsPrevious(t *testing.T) {
t.Parallel()
for _, rtype := range []string{"A", "AAAA", "CNAME"} {
t.Run(rtype, func(t *testing.T) {
t.Parallel()
w := watcher.NewForTest(nil, nil, nil, nil, nil, nil)
current := saved(map[string]*state.NameserverRecordState{
nsA: {
Records: map[string][]string{},
FailedTypes: []string{rtype},
UnknownTypes: []string{rtype},
Status: "ok",
},
})
prev := cnameState(oldIP)
w.ResolveCNAMEAddresses(t.Context(), host, current, prev)
if !slices.Equal(current.CNAMEAddresses, prev.CNAMEAddresses) {
t.Errorf(
"saved %v, want %v",
current.CNAMEAddresses, prev.CNAMEAddresses,
)
}
})
}
}
// cnameTo builds the records of a nameserver that answered with a CNAME
// to target and no address.
func cnameTo(target string) map[string][]string {
+25 -2
View File
@@ -10,7 +10,8 @@ import (
"sneak.berlin/go/dnswatcher/internal/state"
)
// NewForTest creates a Watcher without fx for unit testing.
// NewForTest creates a Watcher without fx for unit testing. A nil cfg
// is an empty configuration.
func NewForTest(
cfg *config.Config,
st *state.State,
@@ -19,6 +20,10 @@ func NewForTest(
tc TLSChecker,
n Notifier,
) *Watcher {
if cfg == nil {
cfg = &config.Config{}
}
return &Watcher{
log: slog.Default(),
config: cfg,
@@ -31,6 +36,12 @@ func NewForTest(
}
}
// SetLogger replaces the watcher's logger, so a test can read what it
// logs.
func (w *Watcher) SetLogger(log *slog.Logger) {
w.log = log
}
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs(
prev, current *state.HostnameState,
@@ -90,6 +101,17 @@ func (w *Watcher) DetectNSAddressChanges(
w.detectNSAddressChanges(ctx, domain, prev, current)
}
// MaybeSendTestNotification exports maybeSendTestNotification for
// testing.
func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
w.maybeSendTestNotification(ctx)
}
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
func (w *Watcher) CleanupRemovedTargets() {
w.cleanupRemovedTargets()
}
// CheckAllPorts exports checkAllPorts for testing.
func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx)
@@ -103,7 +125,8 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
// BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState(
results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time,
) *state.HostnameState {
return buildHostnameState(results, now)
return buildHostnameState(results, prev, now)
}
+364
View File
@@ -0,0 +1,364 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"time"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
const (
// txt is the record type whose query fails in these tests.
txt = "TXT"
spf1 = "v=spf1 -all"
spf2 = "v=spf1 include:example.net -all"
)
// response is a nameserver's response with these records, whose queries
// for failedTypes failed.
func response(
records map[string][]string,
failedTypes ...string,
) *resolver.NameserverResponse {
return &resolver.NameserverResponse{
Records: records,
FailedTypes: failedTypes,
Status: resolver.StatusOK,
}
}
// savedChecks saves the state of each check in turn from the
// nameservers' responses, each from the state the check before saved.
func savedChecks(
checks ...map[string]*resolver.NameserverResponse,
) []*state.HostnameState {
states := make([]*state.HostnameState, 0, len(checks))
var prev *state.HostnameState
for _, results := range checks {
prev = watcher.BuildHostnameState(results, prev, time.Now())
states = append(states, prev)
}
return states
}
// TestFailedTypeKeepsPreviousRecords saves a check in which nsA's query
// for TXT failed, after previous checks of several kinds. TXT is always
// saved in FailedTypes, and in UnknownTypes when there was nothing to
// keep.
func TestFailedTypeKeepsPreviousRecords(t *testing.T) {
t.Parallel()
aOnly := map[string][]string{"A": {ip1}}
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
txtKept := &state.NameserverRecordState{
Records: withTXT, FailedTypes: []string{txt}, Status: "ok",
}
txtNotKnown := &state.NameserverRecordState{
Records: aOnly,
FailedTypes: []string{txt},
UnknownTypes: []string{txt},
Status: "ok",
}
tests := []struct {
name string
prev *state.HostnameState
wantRecords map[string][]string
wantUnknown []string
}{
{
"previous TXT records are kept",
saved(map[string]*state.NameserverRecordState{nsA: answered(withTXT)}),
withTXT, nil,
},
{
"previous check had no TXT records",
saved(map[string]*state.NameserverRecordState{nsA: answered(aOnly)}),
aOnly, nil,
},
{
"TXT failed on the previous check, which kept its records",
saved(map[string]*state.NameserverRecordState{nsA: txtKept}),
withTXT, nil,
},
{"first check", nil, aOnly, []string{txt}},
{
"nameserver new on this check",
saved(map[string]*state.NameserverRecordState{nsB: answered(withTXT)}),
aOnly, []string{txt},
},
{
"nameserver failed on the previous check",
saved(map[string]*state.NameserverRecordState{nsA: failed()}),
aOnly, []string{txt},
},
{
"TXT failed on the previous check with nothing to keep",
saved(map[string]*state.NameserverRecordState{nsA: txtNotKnown}),
aOnly, []string{txt},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{
nsA: response(map[string][]string{"A": {ip1}}, txt),
},
tt.prev, time.Now(),
)
got := hs.RecordsByNameserver[nsA]
if got.Status != "ok" ||
!maps.EqualFunc(got.Records, tt.wantRecords, slices.Equal) ||
!slices.Equal(got.FailedTypes, []string{txt}) ||
!slices.Equal(got.UnknownTypes, tt.wantUnknown) {
t.Errorf(
"saved status %q, records %v, failed types %v, "+
"unknown types %v; want ok, %v, [%s], %v",
got.Status, got.Records, got.FailedTypes,
got.UnknownTypes, tt.wantRecords, txt, tt.wantUnknown,
)
}
})
}
}
// TestFailedTypeAlerts saves the checks of each case in turn from the
// nameservers' responses, the first being the state loaded at startup,
// and counts the alerts sent. nsB's TXT query fails on one check, and
// nothing changes.
func TestFailedTypeAlerts(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}, txt: {spf1}}
aOnly := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
bTXTFails := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(aOnly, txt),
}
onlyA := map[string]*resolver.NameserverResponse{
nsA: response(records),
}
bFails := map[string]*resolver.NameserverResponse{
nsA: response(records),
nsB: {
Records: map[string][]string{},
Status: resolver.StatusTimeout,
Error: "all queries timed out",
},
}
tests := []struct {
name string
checks []map[string]*resolver.NameserverResponse
want alertCounts
}{
{
"type failing at one nameserver alerts nothing, nor its next answer",
[]map[string]*resolver.NameserverResponse{
bothAnswer, bTXTFails, bothAnswer,
},
alertCounts{},
},
{
"type failing on the first check alerts nothing on the next",
[]map[string]*resolver.NameserverResponse{bTXTFails, bothAnswer},
alertCounts{},
},
{
"type failing at a nameserver new on that check alerts nothing",
[]map[string]*resolver.NameserverResponse{
onlyA, bTXTFails, bothAnswer,
},
alertCounts{},
},
{
"type failing at a recovering nameserver alerts the recovery",
[]map[string]*resolver.NameserverResponse{
bFails, bTXTFails, bothAnswer,
},
alertCounts{recoveries: 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
states := savedChecks(tt.checks...)
got := countAlerts(t, states[0], states[1:])
if got != tt.want {
t.Errorf("sent %+v, want %+v", got, tt.want)
}
})
}
}
// TestFailedTypeComparedOnceItAnswers saves the checks of each case in
// turn as TestFailedTypeAlerts does. nsB's TXT query fails on one check,
// and the TXT record changes: the change is sent as a Record Change for
// each nameserver on the check where it answers it, and an Inconsistency
// only when nsB still answers the old record.
func TestFailedTypeComparedOnceItAnswers(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}, txt: {spf1}}
changed := map[string][]string{"A": {ip1}, txt: {spf2}}
aOnly := map[string][]string{"A": {ip1}}
bothAnswer := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(records),
}
bTXTFails := map[string]*resolver.NameserverResponse{
nsA: response(records), nsB: response(aOnly, txt),
}
bothChange := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(changed),
}
aChangesBTXTFails := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(aOnly, txt),
}
bStillOld := map[string]*resolver.NameserverResponse{
nsA: response(changed), nsB: response(records),
}
tests := []struct {
name string
checks []map[string]*resolver.NameserverResponse
want alertCounts
}{
{
"change made while the type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, bTXTFails, bothChange,
},
alertCounts{recordChanges: 2},
},
{
"change seen at one nameserver while the other's type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, aChangesBTXTFails, bothChange,
},
alertCounts{recordChanges: 2},
},
{
"old record answered after the type failed",
[]map[string]*resolver.NameserverResponse{
bothAnswer, aChangesBTXTFails, bStillOld,
},
alertCounts{recordChanges: 1, inconsistencies: 1},
},
{
"change after the type failed on the first check and answered",
[]map[string]*resolver.NameserverResponse{
bTXTFails, bothAnswer, bothChange,
},
alertCounts{recordChanges: 2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
states := savedChecks(tt.checks...)
got := countAlerts(t, states[0], states[1:])
if got != tt.want {
t.Errorf("sent %+v, want %+v", got, tt.want)
}
})
}
}
// TestFailedTypeLeftOutOfMessages checks that a Record Change and an
// Inconsistency name only the record types they compared. nsB's TXT
// records are not known on the first check, and on the second either
// answered or still not known; nsB's A record changes, so both alerts
// are sent and name the A record alone.
func TestFailedTypeLeftOutOfMessages(t *testing.T) {
t.Parallel()
withTXT := map[string][]string{"A": {ip1}, txt: {spf1}}
txtNotKnown := func(address string) *state.NameserverRecordState {
return &state.NameserverRecordState{
Records: map[string][]string{"A": {address}},
FailedTypes: []string{txt},
UnknownTypes: []string{txt},
Status: "ok",
}
}
before := saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT), nsB: txtNotKnown(ip1),
})
tests := []struct {
name string
after *state.HostnameState
}{
{
"TXT answers",
saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT),
nsB: answered(map[string][]string{"A": {ip2}, txt: {spf1}}),
}),
},
{
"TXT still not known",
saved(map[string]*state.NameserverRecordState{
nsA: answered(withTXT), nsB: txtNotKnown(ip2),
}),
},
}
want := map[string]string{
"Record Change: " + host: "Hostname: " + host +
"\nNameserver: " + nsB + "\nType: A\nOld: " + ip1 + "\nNew: " + ip2,
"Inconsistency: " + host: "Hostname: " + host +
"\nType: A\n" + nsA + ": " + ip1 + "\n" + nsB + ": " + ip2,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// The hostname change detection uses only the notifier.
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(t.Context(), host, before, tt.after)
notifications := notifier.getNotifications()
if len(notifications) != len(want) {
t.Fatalf(
"sent %d notifications, want %d: %v",
len(notifications), len(want), notifications,
)
}
for _, n := range notifications {
if n.Message != want[n.Title] {
t.Errorf(
"%s message:\n%s\nwant:\n%s",
n.Title, n.Message, want[n.Title],
)
}
}
})
}
}
+55
View File
@@ -183,3 +183,58 @@ func TestInconsistencyAlert(t *testing.T) {
})
}
}
// TestFirstCheckAfterRepeatedValuesLoaded saves a state file holding a
// hostname's CNAME once for every record type asked for, as checks did
// before each value was stored once, and two addresses each repeated,
// and loads it. A check that then finds each value once at each
// nameserver must notify nothing.
func TestFirstCheckAfterRepeatedValuesLoaded(t *testing.T) {
t.Parallel()
const (
cnameType = "CNAME"
cname = "c.example.net."
)
cfg := defaultTestConfig(t)
repeated := map[string][]string{
"A": {ip2, ip1, ip2, ip1},
cnameType: {cname, cname, cname, cname, cname, cname, cname, cname},
}
once := map[string][]string{"A": {ip1, ip2}, cnameType: {cname}}
saved := newTestDeps(t, cfg).state
saved.SetHostnameState(host, hostnameState(map[string]map[string][]string{
nsA: repeated, nsB: repeated,
}))
err := saved.Save()
if err != nil {
t.Fatalf("saving the state file: %v", err)
}
deps := newTestDeps(t, cfg)
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state file: %v", err)
}
prev, ok := deps.state.GetHostnameState(host)
if !ok {
t.Fatal("the state file has no state for " + host)
}
current := hostnameState(map[string]map[string][]string{
nsA: once, nsB: once,
})
// The hostname change detection uses only the notifier.
w := watcher.NewForTest(nil, nil, nil, nil, nil, deps.notifier)
w.DetectHostnameChanges(t.Context(), host, prev, current)
if got := deps.notifier.getNotifications(); len(got) != 0 {
t.Errorf("sent %v, want no notification", got)
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ import (
// DNSResolver performs iterative DNS resolution.
type DNSResolver interface {
// LookupNS discovers authoritative nameservers for a domain.
// LookupNS returns a domain's NS record set, as its parent zone's
// servers delegate it: empty when they answer that it has none, and
// resolver.ErrNXDomain when they answer that it does not exist.
LookupNS(
ctx context.Context,
domain string,
+149
View File
@@ -1,8 +1,12 @@
package watcher_test
import (
"maps"
"strings"
"testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
@@ -64,3 +68,148 @@ b.ns.example.net.: 192.0.2.2`,
}
}
}
// Every kind of notification about a configured apex domain's own
// records names it as a domain.
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
t.Parallel()
notifier := &mockNotifier{}
w := watcher.NewForTest(
&config.Config{Domains: []string{domain}},
nil, nil, nil, nil, notifier,
)
// nsA's address changes, which also makes it differ from nsC; nsB
// fails; nsC answers again; nsD is gone.
nsD := "d.ns.example.net."
w.DetectHostnameChanges(t.Context(), domain,
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
nsD: answered(map[string][]string{"A": {ip1}}),
}),
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip2}}),
nsB: failed(),
nsC: answered(map[string][]string{"A": {ip1}}),
}),
)
// The address at the end of its CNAME chain changes.
w.DetectHostnameChanges(
t.Context(), domain, cnameState(ip1), cnameState(ip2),
)
// NS Failure is sent for nsB failing and for nsD being gone.
want := map[string]int{
"Record Change": 1,
"Inconsistency": 1,
"NS Failure": 2,
"NS Recovery": 1,
"CNAME Address Change": 1,
}
sent := make(map[string]int)
for _, n := range notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":")
sent[kind]++
if !strings.HasPrefix(n.Message, "Domain: "+domain+"\n") {
t.Errorf("%s message does not name the domain:\n%s",
n.Title, n.Message)
}
}
if !maps.Equal(sent, want) {
t.Errorf("sent %v, want %v", sent, want)
}
}
// The startup notification counts the configured domains and hostnames,
// although the state's hostnames also hold the apex domain's own
// records. Nothing is looked up: the watcher has no resolver.
func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.SendTestNotification = true
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
// The state a check of both names saves.
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{nsA},
})
for _, name := range []string{domain, host} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
}
w.MaybeSendTestNotification(t.Context())
notifications := deps.notifier.getNotifications()
counts := "\nMonitoring 1 domain(s) and 1 hostname(s).\n"
if len(notifications) != 1 ||
!strings.Contains(notifications[0].Message, counts) {
t.Errorf("sent %v, want one message with %q", notifications, counts)
}
}
// A Port Change notification lists the configured apex domain and the
// hostname that resolve to the port's address on separate lines. The
// port checks read the saved hostname state and look nothing up, so the
// watcher has no resolver.
func TestPortChangeListsDomainsApartFromHostnames(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
// Both names resolve to ip1, whose port 443 the previous check
// found open. It is closed now.
for _, name := range []string{domain, host} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
}
key := ip1 + ":443"
deps.state.SetPortState(key, &state.PortState{
Open: true, Hostnames: []string{domain, host},
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
title := "Port Change: " + key
want := `Domains: example.net
Hostnames: www.example.net
Address: 192.0.2.1:443
Port now closed`
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)
}
}
+3 -3
View File
@@ -201,7 +201,7 @@ func TestNameserverThatNeverAnswers(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(),
map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
)
got := hs.RecordsByNameserver[nsA]
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
)
got := hs.RecordsByNameserver[ns]
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
)
got := hs.RecordsByNameserver[ns]
+224
View File
@@ -0,0 +1,224 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestRemovedTargetsLeaveTheState loads a state saved while a domain
// and a hostname now removed from the configuration were still in it,
// and runs the removal that Run does before the first check. The
// removed names' domain, hostname and certificate entries are gone,
// the configured names' are kept, and nothing is notified. Nothing is
// looked up: the watcher has no resolver.
func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Parallel()
const (
removedDomain = "example.com"
removedHost = "www.example.com"
)
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
// The state a check of all four names saves, each name at ip1.
for _, name := range []string{domain, removedDomain} {
deps.state.SetDomainState(name, &state.DomainState{
Nameservers: []string{nsA},
})
}
for _, name := range []string{domain, host, removedDomain, removedHost} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetCertificateState(
ip1+":443:"+name, &state.CertificateState{Status: "ok"},
)
}
err := deps.state.Save()
if err != nil {
t.Fatalf("saving the state: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state: %v", err)
}
w.CleanupRemovedTargets()
snap := deps.state.GetSnapshot()
got := slices.Sorted(maps.Keys(snap.Domains))
if want := []string{domain}; !slices.Equal(got, want) {
t.Errorf("domain entries %v, want %v", got, want)
}
got = slices.Sorted(maps.Keys(snap.Hostnames))
if want := []string{domain, host}; !slices.Equal(got, want) {
t.Errorf("hostname entries %v, want %v", got, want)
}
got = slices.Sorted(maps.Keys(snap.Certificates))
if want := []string{
ip1 + ":443:" + domain, ip1 + ":443:" + host,
}; !slices.Equal(got, want) {
t.Errorf("certificate entries %v, want %v", got, want)
}
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
}
// TestRemovedTargetsLeaveThePortEntries loads a state whose port
// entries name a domain and a hostname now removed from the
// configuration, and runs the removal that Run does before the first
// check. The removed names are off each port entry's list of names, the
// entry only they had is gone, the entry that also names configured
// names is kept for the port checks, and nothing is notified.
func TestRemovedTargetsLeaveThePortEntries(t *testing.T) {
t.Parallel()
const (
removedDomain = "example.com"
removedHost = "www.example.com"
)
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
// The port 443 entries a check of all four names saves: each name
// at ip1, except the removed hostname, at ip2.
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{removedDomain, domain, host},
})
deps.state.SetPortState(ip2+":443", &state.PortState{
Open: true, Hostnames: []string{removedHost},
})
err := deps.state.Save()
if err != nil {
t.Fatalf("saving the state: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state: %v", err)
}
w.CleanupRemovedTargets()
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
snap := deps.state.GetSnapshot()
got := slices.Sorted(maps.Keys(snap.Ports))
if want := []string{ip1 + ":443"}; !slices.Equal(got, want) {
t.Fatalf("port entries %v, want %v", got, want)
}
got = snap.Ports[ip1+":443"].Hostnames
if want := []string{domain, host}; !slices.Equal(got, want) {
t.Errorf("names of port entry %s:443 %v, want %v", ip1, got, want)
}
}
// TestCertificateStateForAnAddressGone runs the port checks on hostname
// state built here for a configured hostname, with certificate entries
// saved for it at ip1, ip2 and an IPv6 address. When its nameservers
// answered with ip1 and the IPv6 address, the entry for ip2 is removed.
// When none of them answered, its addresses are not known, and every
// entry is kept. Nothing is notified, and nothing is looked up.
func TestCertificateStateForAnAddressGone(t *testing.T) {
t.Parallel()
const ip6 = "2001:db8::1"
tests := []struct {
name string
hostname *state.HostnameState
want []string
}{
{
"answered without ip2",
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{
"A": {ip1}, "AAAA": {ip6},
}),
}),
[]string{ip1 + ":443:" + host, ip6 + ":443:" + host},
},
{
"no nameserver answered",
saved(map[string]*state.NameserverRecordState{
nsA: failed(), nsB: failed(),
}),
[]string{
ip1 + ":443:" + host,
ip2 + ":443:" + host,
ip6 + ":443:" + host,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(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)
deps.state.SetHostnameState(host, tt.hostname)
for _, ip := range []string{ip1, ip2, ip6} {
deps.state.SetCertificateState(
ip+":443:"+host, &state.CertificateState{Status: "ok"},
)
}
w.CheckAllPorts(t.Context())
got := slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
if !slices.Equal(got, tt.want) {
t.Errorf("certificate entries %v, want %v", got, tt.want)
}
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
})
}
}
+299 -45
View File
@@ -2,8 +2,10 @@ package watcher
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"sort"
"strings"
@@ -129,6 +131,7 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(),
)
w.cleanupRemovedTargets()
w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
@@ -199,6 +202,59 @@ func (w *Watcher) detectFirstRun() {
}
}
// cleanupRemovedTargets removes from the loaded state the domain,
// hostname and certificate entries of names no longer in the
// configuration, which changes only at a restart, and takes those names
// off each port entry's list of names, removing a port entry left with
// none. Nothing is notified. A configured domain's own records are
// saved as a hostname entry under its name, which is kept.
func (w *Watcher) cleanupRemovedTargets() {
for _, name := range w.state.GetAllDomainNames() {
if !w.isDomain(name) {
w.state.DeleteDomainState(name)
}
}
for _, name := range w.state.GetAllHostnames() {
if !w.isConfigured(name) {
w.state.DeleteHostnameState(name)
}
}
for _, key := range w.state.GetAllCertificateKeys() {
if _, hostname := parseCertKey(key); !w.isConfigured(hostname) {
w.state.DeleteCertificateState(key)
}
}
for _, key := range w.state.GetAllPortKeys() {
ps, ok := w.state.GetPortState(key)
if !ok {
continue
}
var names []string
for _, name := range ps.Hostnames {
if w.isConfigured(name) {
names = append(names, name)
}
}
if len(names) == 0 {
w.state.DeletePortState(key)
continue
}
w.state.SetPortState(key, &state.PortState{
Open: ps.Open,
Hostnames: names,
LastChecked: ps.LastChecked,
})
}
}
// runDNSChecks performs DNS resolution for all configured domains
// and hostnames, updating state with freshly resolved records.
// This must complete before port or TLS checks run so those
@@ -213,13 +269,36 @@ func (w *Watcher) runDNSChecks(ctx context.Context) {
}
}
// logFailedLookup logs a failed DNS lookup at error level, unless ctx
// was cancelled: shutdown cancels it, and a lookup it cut short did not
// fail. A lookup that ran out of time did fail, so it is logged.
func (w *Watcher) logFailedLookup(
ctx context.Context,
msg string,
args ...any,
) {
if errors.Is(ctx.Err(), context.Canceled) {
return
}
w.log.Error(msg, args...)
}
func (w *Watcher) checkDomain(
ctx context.Context,
domain string,
) {
nameservers, err := w.resolver.LookupNS(ctx, domain)
// A domain that does not exist has no nameservers.
nxdomain := errors.Is(err, resolver.ErrNXDomain)
if nxdomain {
nameservers, err = []string{}, nil
}
if err != nil {
w.log.Error(
w.logFailedLookup(
ctx,
"failed to lookup NS",
"domain", domain,
"error", err,
@@ -251,11 +330,21 @@ func (w *Watcher) checkDomain(
w.state.SetDomainState(domain, &state.DomainState{
Nameservers: nameservers,
NameserverAddresses: addresses,
NXDomain: nxdomain,
LastChecked: now,
})
// The apex domain's records are also checked as a hostname's, so
// that the port and TLS checks find its addresses.
// 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)
}
@@ -320,7 +409,8 @@ func (w *Watcher) resolveNameserverAddresses(
continue
}
w.log.Error(
w.logFailedLookup(
ctx,
"no addresses found for nameserver",
"nameserver", ns,
"error", err,
@@ -372,7 +462,8 @@ func (w *Watcher) checkHostname(
) {
results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil {
w.log.Error(
w.logFailedLookup(
ctx,
"failed to lookup records",
"hostname", hostname,
"error", err,
@@ -381,8 +472,10 @@ func (w *Watcher) checkHostname(
return
}
prev, _ := w.state.GetHostnameState(hostname)
w.updateHostnameState(
ctx, hostname, buildHostnameState(results, time.Now().UTC()),
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
)
}
@@ -412,8 +505,9 @@ func (w *Watcher) updateHostnameState(
// the addresses found for all of them are saved, so nameservers that
// disagree on the target do not change the result from check to check.
// The addresses saved in prev, which may be nil, are kept when none of
// the name's nameservers answered, and when a target cannot be
// followed, as when no nameserver of a zone in its chain answers.
// the name's nameservers answered its queries for A, AAAA and CNAME,
// and when a target cannot be followed, as when no nameserver of a zone
// in its chain answers.
func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context,
hostname string,
@@ -431,7 +525,10 @@ func (w *Watcher) resolveCNAMEAddresses(
targets := make(map[string]bool)
for _, nsState := range current.RecordsByNameserver {
if nsState.Status != statusOK {
if nsState.Status != statusOK ||
slices.Contains(nsState.FailedTypes, "A") ||
slices.Contains(nsState.FailedTypes, "AAAA") ||
slices.Contains(nsState.FailedTypes, "CNAME") {
continue
}
@@ -455,7 +552,8 @@ func (w *Watcher) resolveCNAMEAddresses(
for target := range targets {
ips, err := w.resolver.ResolveIPAddresses(ctx, target)
if err != nil {
w.log.Error(
w.logFailedLookup(
ctx,
"failed to follow CNAME",
"hostname", hostname,
"target", target,
@@ -476,11 +574,13 @@ func (w *Watcher) resolveCNAMEAddresses(
}
// buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok; one
// that timed out or failed is saved as error with the reason, and its
// empty record set is not an answer.
// that answered, even with NXDOMAIN or no records, is saved as ok, with
// the record types whose query failed; one that timed out or failed is
// saved as error with the reason, and its empty record set is not an
// answer. prev is the hostname's state from the previous check, or nil.
func buildHostnameState(
results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time,
) *state.HostnameState {
hs := &state.HostnameState{
@@ -492,7 +592,7 @@ func buildHostnameState(
for ns, resp := range results {
nsState := &state.NameserverRecordState{
Records: resp.Records,
Records: maps.Clone(resp.Records),
Status: statusOK,
LastChecked: now,
}
@@ -501,6 +601,15 @@ func buildHostnameState(
resp.Status == resolver.StatusError {
nsState.Status = statusError
nsState.Error = resp.Error
} else {
nsState.FailedTypes = resp.FailedTypes
var prevNS *state.NameserverRecordState
if prev != nil {
prevNS = prev.RecordsByNameserver[ns]
}
keepFailedTypes(nsState, prevNS)
}
hs.RecordsByNameserver[ns] = nsState
@@ -509,6 +618,27 @@ func buildHostnameState(
return hs
}
// keepFailedTypes copies into nsState, for each record type in its
// FailedTypes, the records prevNS, the nameserver's state from the
// previous check, holds for that type, which may be none. When prevNS
// does not know them either, because the nameserver was new or failing
// then or the type was in its UnknownTypes, the type goes in
// nsState.UnknownTypes instead.
func keepFailedTypes(nsState, prevNS *state.NameserverRecordState) {
for _, rtype := range nsState.FailedTypes {
if prevNS == nil || prevNS.Status != statusOK ||
slices.Contains(prevNS.UnknownTypes, rtype) {
nsState.UnknownTypes = append(nsState.UnknownTypes, rtype)
continue
}
if records, ok := prevNS.Records[rtype]; ok {
nsState.Records[rtype] = records
}
}
}
func (w *Watcher) detectHostnameChanges(
ctx context.Context,
hostname string,
@@ -521,6 +651,50 @@ func (w *Watcher) detectHostnameChanges(
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
}
// isDomain reports whether name is a configured apex domain, whose own
// records are checked and saved as a hostname's are.
func (w *Watcher) isDomain(name string) bool {
return slices.Contains(w.config.Domains, name)
}
// nameLine is the line a notification about name's records starts with:
// "Domain: " and the name for a configured apex domain, and
// "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string {
if w.isDomain(name) {
return "Domain: " + name
}
return "Hostname: " + name
}
// portNameLines lists the names that resolve to a port's address, the
// configured apex domains on one line and the hostnames on the next,
// leaving out a line that would name nothing.
func (w *Watcher) portNameLines(names []string) string {
var domains, hostnames []string
for _, name := range names {
if w.isDomain(name) {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
var lines []string
if len(domains) > 0 {
lines = append(lines, "Domains: "+strings.Join(domains, ", "))
}
if len(hostnames) > 0 {
lines = append(lines, "Hostnames: "+strings.Join(hostnames, ", "))
}
return strings.Join(lines, "\n")
}
// detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved,
// including a change from or to none. When the previous addresses are
@@ -537,8 +711,8 @@ func (w *Watcher) detectCNAMEAddressChanges(
}
msg := fmt.Sprintf(
"Hostname: %s\nOld: %s\nNew: %s",
hostname,
"%s\nOld: %s\nNew: %s",
w.nameLine(hostname),
strings.Join(old, ", "),
strings.Join(cur, ", "),
)
@@ -553,7 +727,10 @@ func (w *Watcher) detectCNAMEAddressChanges(
// detectRecordChanges compares each nameserver's records with those of
// the previous check. Only answers are compared: a nameserver that
// failed on either check has no records to compare.
// failed on either check has no records to compare. The records kept
// for a record type whose query failed are compared too, but not those
// of a type in UnknownTypes on either check, which the message leaves
// out as well.
func (w *Watcher) detectRecordChanges(
ctx context.Context,
hostname string,
@@ -565,16 +742,20 @@ func (w *Watcher) detectRecordChanges(
continue
}
if recordsEqual(prevNS.Records, cur.Records) {
unknown := slices.Concat(prevNS.UnknownTypes, cur.UnknownTypes)
oldRecords := withoutTypes(prevNS.Records, unknown)
newRecords := withoutTypes(cur.Records, unknown)
if recordsEqual(oldRecords, newRecords) {
continue
}
msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s\n%s",
hostname, ns,
"%s\nNameserver: %s\n%s",
w.nameLine(hostname), ns,
recordDifferences(
"Old", prevNS.Records,
"New", cur.Records,
"Old", oldRecords,
"New", newRecords,
),
)
@@ -598,8 +779,8 @@ func (w *Watcher) detectNSDisappearances(
}
msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s disappeared",
hostname, ns,
"%s\nNameserver: %s disappeared",
w.nameLine(hostname), ns,
)
w.notify.SendNotification(
@@ -628,8 +809,8 @@ func (w *Watcher) detectNSFailures(
switch {
case prevNS.Status == statusOK && cur.Status == statusError:
msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s\nError: %s",
hostname, ns, cur.Error,
"%s\nNameserver: %s\nError: %s",
w.nameLine(hostname), ns, cur.Error,
)
w.notify.SendNotification(
@@ -640,8 +821,8 @@ func (w *Watcher) detectNSFailures(
)
case prevNS.Status == statusError && cur.Status == statusOK:
msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s recovered",
hostname, ns,
"%s\nNameserver: %s recovered",
w.nameLine(hostname), ns,
)
w.notify.SendNotification(
@@ -661,13 +842,19 @@ func (w *Watcher) detectInconsistencies(
) {
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
state1 := current.RecordsByNameserver[ns1]
state2 := current.RecordsByNameserver[ns2]
// The record types left out of the comparison are left out of
// the message too.
failed := slices.Concat(state1.FailedTypes, state2.FailedTypes)
msg := fmt.Sprintf(
"Hostname: %s\n%s",
hostname,
"%s\n%s",
w.nameLine(hostname),
recordDifferences(
ns1, current.RecordsByNameserver[ns1].Records,
ns2, current.RecordsByNameserver[ns2].Records,
ns1, withoutTypes(state1.Records, failed),
ns2, withoutTypes(state2.Records, failed),
),
)
@@ -685,7 +872,9 @@ func (w *Watcher) detectInconsistencies(
// except pairs where both nameservers answered in prev and already
// differed there. A nameserver missing from prev, or that failed there,
// is paired with every nameserver it differs from. A nameserver that
// failed in current has no records to compare and is in no pair.
// failed in current has no records to compare and is in no pair. In
// both checks, a record type whose query failed at either nameserver is
// not compared.
func newlyDisagreeingPairs(
prev, current *state.HostnameState,
) [][2]string {
@@ -702,9 +891,9 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] {
if recordsEqual(
current.RecordsByNameserver[ns1].Records,
current.RecordsByNameserver[ns2].Records,
if nameserversAgree(
current.RecordsByNameserver[ns1],
current.RecordsByNameserver[ns2],
) {
continue
}
@@ -714,7 +903,7 @@ func newlyDisagreeingPairs(
if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK &&
!recordsEqual(prev1.Records, prev2.Records) {
!nameserversAgree(prev1, prev2) {
continue
}
@@ -742,8 +931,10 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
}
// Phase 3: Remove port state entries that no longer have
// any hostname referencing them.
// any hostname referencing them, and certificate entries for
// an address their name no longer has.
w.cleanupStalePorts(associations)
w.cleanupStaleCertificates()
}
// buildPortAssociations constructs a map from IP:port keys to
@@ -827,11 +1018,45 @@ func (w *Watcher) cleanupStalePorts(
}
}
// cleanupStaleCertificates removes the certificate entries for an
// address their name no longer resolves to. An entry saved for a name
// none of whose nameservers answered is kept: that name's addresses are
// not known, not gone.
func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key)
if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) {
continue
}
w.state.DeleteCertificateState(key)
}
}
// parseCertKey splits an "ip:port:hostname" certificate key into its
// address and hostname.
func parseCertKey(key string) (string, string) {
lastColon := strings.LastIndex(key, ":")
if lastColon < 0 {
return "", key
}
ip, _ := parsePortKey(key[:lastColon])
return ip, key[lastColon+1:]
}
// isConfigured reports whether name is a configured domain or hostname.
func (w *Watcher) isConfigured(name string) bool {
return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
}
// noNameserverAnswered reports whether name is a configured domain or
// hostname and none of its nameservers answered on its last check.
func (w *Watcher) noNameserverAnswered(name string) bool {
if !slices.Contains(w.config.Hostnames, name) &&
!slices.Contains(w.config.Domains, name) {
if !w.isConfigured(name) {
return false
}
@@ -920,8 +1145,8 @@ func (w *Watcher) checkSinglePort(
}
msg := fmt.Sprintf(
"Hosts: %s\nAddress: %s\nPort now %s",
strings.Join(hostnames, ", "), key, stateStr,
"%s\nAddress: %s\nPort now %s",
w.portNameLines(hostnames), key, stateStr,
)
w.notify.SendNotification(
@@ -1160,7 +1385,9 @@ func (w *Watcher) saveState() {
// after the first full scan completes, if SEND_TEST_NOTIFICATION
// is enabled. The message is informational, not an error or anomaly
// alert. It is written before it reaches any endpoint, so it claims
// nothing about whether the endpoints work.
// nothing about whether the endpoints work. Domains and hostnames are
// counted from the configuration: the state's hostnames also hold each
// apex domain's own records.
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
if !w.config.SendTestNotification {
return
@@ -1174,8 +1401,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
"This is a test notification, sent to every configured "+
"notification endpoint.",
len(snap.Domains),
len(snap.Hostnames),
len(w.config.Domains),
len(w.config.Hostnames),
len(snap.Ports),
len(snap.Certificates),
)
@@ -1201,6 +1428,33 @@ func toSet(items []string) map[string]bool {
return set
}
// nameserversAgree reports whether two nameservers' states from the same
// check hold the same records, leaving out the record types either lists
// in FailedTypes: the records held for those are kept from an earlier
// check, or not known.
func nameserversAgree(a, b *state.NameserverRecordState) bool {
failed := slices.Concat(a.FailedTypes, b.FailedTypes)
return recordsEqual(
withoutTypes(a.Records, failed), withoutTypes(b.Records, failed),
)
}
// withoutTypes returns a copy of records without the record types in
// types.
func withoutTypes(
records map[string][]string,
types []string,
) map[string][]string {
records = maps.Clone(records)
for _, rtype := range types {
delete(records, rtype)
}
return records
}
func recordsEqual(
a, b map[string][]string,
) bool {
+113
View File
@@ -482,6 +482,119 @@ func TestNSChangeDetection(t *testing.T) {
}
}
// TestDomainThatDoesNotExist checks a .com domain that does not exist,
// with nameservers and records saved by an earlier check. The .com
// servers answer that it does not exist, so it is saved with nxdomain
// set and no nameservers, an NS Change removes them all, and its saved
// records are removed rather than asked for at the .com servers.
func TestDomainThatDoesNotExist(t *testing.T) {
t.Parallel()
const domain = "dnswatcher-test-does-not-exist.com"
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2},
})
deps.state.SetHostnameState(domain, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: {
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
},
},
})
started := time.Now()
w.RunOnce(ctx)
// When no server answered, the domain's state is not saved.
ds, _ := deps.state.GetDomainState(domain)
if ds.LastChecked.Before(started) {
return fmt.Errorf("%s: %w", domain, livednstest.ErrNoAnswer)
}
return nil
})
ds, _ := deps.state.GetDomainState(domain)
if !ds.NXDomain || len(ds.Nameservers) != 0 {
t.Errorf("saved nxdomain %v and nameservers %v, want true and none",
ds.NXDomain, ds.Nameservers)
}
if hs, ok := deps.state.GetHostnameState(domain); ok {
t.Errorf("records saved for %s: %v", domain, hs.RecordsByNameserver)
}
assertNotified(t, deps, "NS Change: "+domain, "warning")
// That is the only notification, and it removes both nameservers,
// in either order.
for _, n := range deps.notifier.getNotifications() {
removed := strings.TrimPrefix(
n.Message, "Domain: "+domain+"\nAdded: \nRemoved: ",
)
if removed != oldNS1+", "+oldNS2 && removed != oldNS2+", "+oldNS1 {
t.Errorf("unexpected notification: %v", n)
}
}
}
// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation
// of its own: codeberg.page is on the public suffix list, so
// docs.codeberg.page is a domain, but the .page servers delegate only
// codeberg.page, whose servers answer for it. It is saved with no
// nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
const domain = "docs.codeberg.page"
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
err := checkOnce(ctx, w, deps)
// A domain saved as not existing has no records to wait for;
// the checks below fail on it.
if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain {
return nil
}
return err
})
ds, _ := deps.state.GetDomainState(domain)
if ds.NXDomain || len(ds.Nameservers) != 0 {
t.Errorf("saved nxdomain %v and nameservers %v, want false and none",
ds.NXDomain, ds.Nameservers)
}
if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain)
}
}
func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel()