7 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
26 changed files with 1904 additions and 224 deletions
+82 -24
View File
@@ -73,20 +73,36 @@ notification endpoint set, changes show only on the dashboard; see
to discover all authoritative nameservers (NS records) for each domain. to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently. - Queries **every** discovered authoritative nameserver independently.
- Stores the domain's NS record set, as its parent zone's servers delegate it, - 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: - 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 - NS address change: a nameserver that stays in the set resolves to
different addresses than on the previous check. A nameserver added or different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are 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 - 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 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 records, stored per nameserver. Their changes are notified as a hostname's
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
address change, in a message that starts `Domain:` where a hostname's starts address change, in a message that starts `Domain:` where a hostname's starts
`Hostname:`. `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) ### DNS Hostname Monitoring (Subdomains)
@@ -94,9 +110,26 @@ notification endpoint set, changes show only on the dashboard; see
via the Public Suffix List). via the Public Suffix List).
- Every **1 hour** by default, performs a full iterative trace to discover the - Every **1 hour** by default, performs a full iterative trace to discover the
authoritative nameservers of the zone the hostname is in, which is not always 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 - Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS. types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types
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 - Stores results **per nameserver**. The state for a hostname is not a merged
view — it is a map from nameserver to record set. view — it is a map from nameserver to record set.
- DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in - DNS names inside record values (CNAME, MX, SRV and NS targets) are stored in
@@ -125,8 +158,9 @@ notification endpoint set, changes show only on the dashboard; see
they keep disagreeing, including after a restart. A nameserver that was they keep disagreeing, including after a restart. A nameserver that was
not in the previous check (newly added, or back after dropping out), or 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 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 answers. So is a pair that differs in a record type whose query to either
again. 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 - **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 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 nameservers answer with a CNAME and no address; a name that answers with
@@ -201,7 +235,9 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned, - **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname or domain affected. which hostname or domain affected.
- **Port changes**: Which IP:port, its new state, all associated hostnames. - **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 - **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP. hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname - **TLS certificate changes**: Old and new CN and issuer, associated hostname
@@ -228,10 +264,12 @@ clears them.
false-positive change notifications. false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent - State is written atomically (write to temp file, then rename) to prevent
corruption. corruption.
- A name removed from `DNSWATCHER_TARGETS` is removed from the state at the - A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup,
first check after startup, without a notification: its domain, hostname and before the first check, without a notification: its domain, hostname and
certificate entries go, as do the port entries of addresses no configured name certificate entries go, it is taken off each port entry's list of names, and a
has. The dashboard and `/api/v1/status` then no longer list or count it. 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 - 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. no longer resolves to, except while none of the name's nameservers answer.
@@ -241,11 +279,13 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
(`/`). It displays: (`/`). It displays:
- **Summary counts** for monitored domains, hostnames, ports, and certificates. - **Summary counts** for monitored domains, hostnames, ports, and certificates.
- **Domains** with their discovered nameservers, and each domain's own records - **Domains** with their discovered nameservers, or "does not exist" for a
per nameserver and status, shown as a hostname's are. 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 - **Hostnames** with per-nameserver DNS records and status. For a nameserver
whose query failed, the reason is shown in place of the records. whose query failed, the reason is shown in place of the records.
- **Ports** with open/closed state and associated hostnames. - **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, - **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
the reason is shown in place of CN, issuer and expiry. the reason is shown in place of CN, issuer and expiry.
- **Recent alerts** (last 100 notifications sent since the process started), - **Recent alerts** (last 100 notifications sent since the process started),
@@ -278,7 +318,11 @@ 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 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 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 `recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. 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 `/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, Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
@@ -546,7 +590,7 @@ reachability:
| Status | Meaning | | 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) | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and empty A nameserver that answers NXDOMAIN or with no records has status `ok` and empty
@@ -555,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 certificate entry whose TLS connection or handshake failed likewise has status
`error`, the reason in `error`, and the certificate fields left empty or zero. `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 `nameserverAddresses` lists, by nameserver, the sorted addresses its name
resolves to. A state file without it loads, and the next check fills it in resolves to. A state file without it loads, and the next check fills it in
without a notification. 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 `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 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 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 chain cannot be followed, or none of the name's nameservers answered its queries
previous check's list is kept, or `null` when no earlier check saved one. A for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
state file without it loads, and the first check after that saves it without a earlier check saved one. A state file without it loads, and the first check
notification. after that saves it without a notification.
A port entry in the older format, with one `hostname` instead of the `hostnames` A port entry's `hostnames` lists every name that resolves to its address,
list, loads as a list of that one name. 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.
--- ---
@@ -712,7 +769,8 @@ docker run -d \
1. **Startup**: Check that the data directory can be written, and exit with an 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 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 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 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
startup. startup.
3. **Periodic checks** (DNS always runs first): 3. **Periodic checks** (DNS always runs first):
+13 -1
View File
@@ -19,8 +19,20 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # 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 - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
the dashboard and API, at the first check after startup (closes #223). 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, - 2026-10-02: nameservers a referral names without addresses are looked up,
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). 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 - 2026-10-02: an apex domain is not counted or listed as a hostname; its records
+5 -1
View File
@@ -45,11 +45,14 @@ func newDashboardTemplate() *template.Template {
// dashboardData is the data passed to the dashboard template. Hostnames // dashboardData is the data passed to the dashboard template. Hostnames
// and DomainRecords split the records in Snapshot.Hostnames, which also // and DomainRecords split the records in Snapshot.Hostnames, which also
// holds the apex domains' own (see splitHostnames). // 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 { type dashboardData struct {
Snapshot state.Snapshot Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState DomainRecords map[string]*state.HostnameState
Ports map[string]*statusPortInfo
Alerts []notify.AlertEntry Alerts []notify.AlertEntry
StateAge string StateAge string
GeneratedAt string GeneratedAt string
@@ -71,6 +74,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
Snapshot: snap, Snapshot: snap,
Hostnames: hostnames, Hostnames: hostnames,
DomainRecords: domainRecords, DomainRecords: domainRecords,
Ports: buildPorts(snap),
Alerts: alerts, Alerts: alerts,
StateAge: relTime(snap.LastUpdated), StateAge: relTime(snap.LastUpdated),
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
+69 -3
View File
@@ -191,17 +191,83 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
words := strings.Join(strings.Fields(page), " ") words := strings.Join(strings.Fields(page), " ")
footer := "monitoring 1 domains + 1 hostnames" footer := "monitoring 2 domains + 1 hostnames"
if !strings.Contains(words, footer) { if !strings.Contains(words, footer) {
t.Errorf("dashboard does not say %q", footer) t.Errorf("dashboard does not say %q", footer)
} }
// With the tags taken out, the summary bar starts "Domains 1 // With the tags taken out, the summary bar starts "Domains 2
// Hostnames 1". // Hostnames 1".
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ") text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
summary := "Domains 1 Hostnames 1" summary := "Domains 2 Hostnames 1"
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) { if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
t.Errorf("summary bar does not say %q", 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)
}
}
+30 -10
View File
@@ -10,10 +10,12 @@ import (
// statusDomainInfo holds status information for a monitored domain. // statusDomainInfo holds status information for a monitored domain.
// RecordsByNameserver holds the domain's own records, in the form a // RecordsByNameserver holds the domain's own records, in the form a
// hostname's Nameservers holds the hostname's. // 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 { type statusDomainInfo struct {
Nameservers []string `json:"nameservers"` Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"` RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -32,8 +34,11 @@ type statusHostnameInfo struct {
} }
// statusPortInfo holds status information for a monitored port. // 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 { type statusPortInfo struct {
Open bool `json:"open"` Open bool `json:"open"`
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"` Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -99,7 +104,6 @@ func buildStatusResponse(
LastUpdated: snap.LastUpdated, LastUpdated: snap.LastUpdated,
Domains: make(map[string]*statusDomainInfo), Domains: make(map[string]*statusDomainInfo),
Hostnames: make(map[string]*statusHostnameInfo), Hostnames: make(map[string]*statusHostnameInfo),
Ports: make(map[string]*statusPortInfo),
Certificates: make(map[string]*statusCertificateInfo), Certificates: make(map[string]*statusCertificateInfo),
} }
@@ -107,7 +111,7 @@ func buildStatusResponse(
buildDomains(snap, domainRecords, resp) buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp) buildHostnames(hostnames, resp)
buildPorts(snap, resp) resp.Ports = buildPorts(snap)
buildCertificates(snap, resp) buildCertificates(snap, resp)
buildCounts(resp) buildCounts(resp)
@@ -153,6 +157,7 @@ func buildDomains(
resp.Domains[name] = &statusDomainInfo{ resp.Domains[name] = &statusDomainInfo{
Nameservers: ns, Nameservers: ns,
RecordsByNameserver: records, RecordsByNameserver: records,
NXDomain: ds.NXDomain,
LastChecked: ds.LastChecked, LastChecked: ds.LastChecked,
} }
} }
@@ -195,21 +200,36 @@ func nameserverInfo(
return info return info
} }
func buildPorts( // buildPorts returns the port entries saved in snap. A port entry
snap state.Snapshot, // saves apex domains with its hostnames; they are told apart as in
resp *statusResponse, // 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 { for key, ps := range snap.Ports {
hostnames := make([]string, len(ps.Hostnames)) domains := []string{}
copy(hostnames, ps.Hostnames) hostnames := []string{}
for _, name := range ps.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
sort.Strings(domains)
sort.Strings(hostnames) sort.Strings(hostnames)
resp.Ports[key] = &statusPortInfo{ ports[key] = &statusPortInfo{
Open: ps.Open, Open: ps.Open,
Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
LastChecked: ps.LastChecked, LastChecked: ps.LastChecked,
} }
} }
return ports
} }
func buildCertificates( func buildCertificates(
+101 -11
View File
@@ -21,8 +21,12 @@ import (
// The state the handler tests serve: www.example.com has one nameserver // The state the handler tests serve: www.example.com has one nameserver
// that answered and one whose query failed, and its certificate check // that answered and one whose query failed, and its certificate check
// failed. example.net is an apex domain, whose own records are saved // failed. example.net is an apex domain, whose own records are saved
// with the hostnames' records, as the watcher saves them. // 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 ( const (
missingDomain = "does-not-exist.example"
testHostname = "www.example.com" testHostname = "www.example.com"
answeringNS = "ns1.example.com." answeringNS = "ns1.example.com."
failedNS = "ns2.example.com." failedNS = "ns2.example.com."
@@ -32,6 +36,7 @@ const (
testDomain = "example.net" testDomain = "example.net"
domainNS = "a.iana-servers.net." domainNS = "a.iana-servers.net."
domainAddress = "192.0.2.2" domainAddress = "192.0.2.2"
sharedPort = domainAddress + ":443"
) )
// newHandlersWithFailures builds real Handlers whose state holds the // newHandlersWithFailures builds real Handlers whose state holds the
@@ -65,12 +70,31 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
t.Fatalf("state.New: %v", err) 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() now := time.Now()
st.SetHostnameState(testHostname, &state.HostnameState{ st.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
answeringNS: { answeringNS: {
Records: map[string][]string{"A": {"192.0.2.1"}}, Records: map[string][]string{
"A": {"192.0.2.1", domainAddress},
},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
@@ -106,17 +130,17 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
LastChecked: now, LastChecked: now,
}) })
hnd, err := handlers.New(nil, handlers.Params{ st.SetPortState(sharedPort, &state.PortState{
Logger: log, Open: true,
Globals: glob, Hostnames: []string{testDomain, testHostname},
State: st, LastChecked: now,
Notify: notifier,
}) })
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd st.SetDomainState(missingDomain, &state.DomainState{
Nameservers: []string{},
NXDomain: true,
LastChecked: now,
})
} }
// get serves one GET request to handler and returns the response body. // get serves one GET request to handler and returns the response body.
@@ -217,3 +241,69 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
testDomain, domainNS, records, domainAddress) 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)
}
}
+10 -2
View File
@@ -84,7 +84,11 @@
{{ $name }} {{ $name }}
</td> </td>
<td class="py-2 px-3 text-slate-400 break-all"> <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 ", " }} {{ joinStrings $ds.Nameservers ", " }}
{{ end }}
</td> </td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap"> <td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $ds.LastChecked }} {{ relTime $ds.LastChecked }}
@@ -157,19 +161,20 @@
> >
Ports Ports
</h2> </h2>
{{ if .Snapshot.Ports }} {{ if .Ports }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left text-xs"> <table class="w-full text-left text-xs">
<thead> <thead>
<tr class="text-slate-500 uppercase tracking-wider"> <tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Address</th> <th class="py-2 px-3">Address</th>
<th class="py-2 px-3">State</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">Hostnames</th>
<th class="py-2 px-3">Checked</th> <th class="py-2 px-3">Checked</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ range $key, $ps := .Snapshot.Ports }} {{ range $key, $ps := .Ports }}
<tr class="hover:bg-surface-800/50"> <tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium"> <td class="py-2 px-3 text-slate-200 font-medium">
{{ $key }} {{ $key }}
@@ -187,6 +192,9 @@
> >
{{ end }} {{ end }}
</td> </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"> <td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Hostnames ", " }} {{ joinStrings $ps.Hostnames ", " }}
</td> </td>
+10
View File
@@ -10,6 +10,10 @@ var (
"no authoritative nameservers found", "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 // ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral, // asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown. // so whether the name has addresses is unknown.
@@ -22,6 +26,12 @@ var (
"reply is an error or a referral that leads no closer", "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 // ErrIntercepted is returned when every root server refused a
// query. Root servers refuse no query, so the refusals came from // query. Root servers refuse no query, so the refusals came from
// something on the network answering in their place. // something on the network answering in their place.
+48
View File
@@ -2,10 +2,58 @@ package resolver
import ( import (
"context" "context"
"log/slog"
"time"
"github.com/miekg/dns" "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. // ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string { func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) return extractRecordValue(rr)
+173 -48
View File
@@ -8,6 +8,7 @@ import (
"net" "net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -99,6 +100,9 @@ func (r *Resolver) tryExchange(
return resp, err 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( func (r *Resolver) retryTCP(
ctx context.Context, ctx context.Context,
msg *dns.Msg, msg *dns.Msg,
@@ -200,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips 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( func (r *Resolver) followDelegation(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -230,10 +240,15 @@ func (r *Resolver) followDelegation(
// An authoritative reply comes from the servers of the zone // An authoritative reply comes from the servers of the zone
// domain is in; it is not a referral, even when its authority // domain is in; it is not a referral, even when its authority
// section lists that zone's NS records. Without NS records in // section lists that zone's NS records. Without NS records in
// the answer, domain is not the zone's apex and has no // the answer, domain has no nameservers of its own: it does
// nameservers of its own. // 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 { if resp.Authoritative {
return nil, ErrNoNameservers return []string{}, nil
} }
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
@@ -320,13 +335,20 @@ func (r *Resolver) queryServers(
return nil, fmt.Errorf("all servers failed: %w", lastErr) 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 // 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 // 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 // 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 // zone that name is in: a server that refers it back to zone, up or
// sideways does not serve zone as it should. // sideways does not serve zone as it should.
func usableReply(resp *dns.Msg, zone string, name string) bool { func usableReply(resp *dns.Msg, zone string, name string) bool {
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError { if isErrorReply(resp) {
return false return false
} }
@@ -475,7 +497,8 @@ func (r *Resolver) resolveNSIPs(
// resolveNSIterative queries for NS records using iterative // resolveNSIterative queries for NS records using iterative
// resolution as a fallback when followDelegation finds no // 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( func (r *Resolver) resolveNSIterative(
ctx context.Context, ctx context.Context,
domain string, domain string,
@@ -505,6 +528,16 @@ func (r *Resolver) resolveNSIterative(
return nsNames, nil 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. // Follow delegation.
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 { if len(authNS) == 0 {
@@ -590,12 +623,23 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from // FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the // root servers to discover all authoritative nameservers for the
// given domain, as the delegation from its parent zone's servers lists // given domain, as the delegation from its parent zone's servers lists
// them. For a name that is not a zone apex it tries each // them. When the servers asked answer that the name has no delegation
// parent name in turn, so it returns the nameservers of the zone the // of its own, or does not exist, it tries each parent name in turn, so
// name is in. // 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( func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context, ctx context.Context,
domain string, 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) { ) ([]string, error) {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
@@ -611,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "." candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation( nsNames, err := r.followDelegation(ctx, candidate, servers)
ctx, candidate, rootServerList(), if err != nil && !errors.Is(err, ErrNXDomain) {
) return nil, err
if err == nil && len(nsNames) > 0 { }
if len(nsNames) > 0 {
sort.Strings(nsNames) sort.Strings(nsNames)
return nsNames, nil return nsNames, nil
} }
// The root servers would refuse every parent name too.
if errors.Is(err, ErrIntercepted) {
return nil, err
}
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
@@ -714,15 +755,21 @@ func (r *Resolver) queryTypes(
} }
type queryState struct { type queryState struct {
gotNXDomain bool gotNXDomain bool
gotSERVFAIL bool gotErrorReply bool
gotRefused bool errorReply string // its code, such as SERVFAIL, or number if unnamed
gotTimeout bool gotRefused bool
gotReferral bool gotTimeout bool
netErr error gotReferral bool
hasRecords 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( func (r *Resolver) queryEachType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -737,7 +784,34 @@ func (r *Resolver) queryEachType(
break 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 { for k := range resp.Records {
@@ -747,6 +821,9 @@ func (r *Resolver) queryEachType(
return state 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( func (r *Resolver) querySingleType(
ctx context.Context, ctx context.Context,
nsIP string, nsIP string,
@@ -754,7 +831,7 @@ func (r *Resolver) querySingleType(
qtype uint16, qtype uint16,
resp *NameserverResponse, resp *NameserverResponse,
state *queryState, state *queryState,
) { ) error {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil { if err != nil {
switch { switch {
@@ -766,35 +843,64 @@ func (r *Resolver) querySingleType(
state.netErr = err 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 { if msg.Rcode == dns.RcodeNameError {
state.gotNXDomain = true state.gotNXDomain = true
return return nil
} }
if msg.Rcode == dns.RcodeServerFailure { if isErrorReply(msg) {
state.gotSERVFAIL = true 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 // A reply with no answer that lists other nameservers, from a server
// that does not hold the name's zone, is a referral and says nothing // that does not hold the name's zone, is a referral and says nothing
// about the name's records. A server named in the delegation that // about the name's records. A server named in the delegation that
// does not hold the zone may send one, as do a parent zone's servers // does not hold the zone may send one.
// when FindAuthoritativeNameservers found no delegation for the
// name's zone and moved on to a parent name.
if !msg.Authoritative && len(msg.Answer) == 0 && if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 { len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true state.gotReferral = true
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) collectAnswerRecords(msg, resp, state)
return nil
} }
// collectAnswerRecords adds the records in msg's answer to resp, each // collectAnswerRecords adds the records in msg's answer to resp, each
@@ -833,26 +939,32 @@ func isTimeout(err error) bool {
return false 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) { func classifyResponse(resp *NameserverResponse, state queryState) {
switch { switch {
case state.gotNXDomain && !state.hasRecords: case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords: case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout resp.Status = StatusTimeout
resp.Error = "all queries timed out" resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords: case state.gotErrorReply && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned SERVFAIL" resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.hasRecords: case state.gotRefused && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned REFUSED" resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords: case state.netErr != nil && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error() resp.Error = "network error: " + state.netErr.Error()
case state.gotReferral && !state.hasRecords: case state.gotReferral && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned a referral" 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 resp.Status = StatusNoData
} }
} }
@@ -941,12 +1053,22 @@ func (r *Resolver) queryEachNS(
return results, nil 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( func (r *Resolver) LookupNS(
ctx context.Context, ctx context.Context,
domain string, domain string,
) ([]string, error) { ) ([]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 // LookupAllRecords performs iterative resolution to find all DNS
@@ -1010,9 +1132,11 @@ func (r *Resolver) resolveIPWithCNAME(
} }
// collectIPs returns the addresses in the nameservers' answers and the // collectIPs returns the addresses in the nameservers' answers and the
// first CNAME target among them. It returns ErrNoNameserverAnswered when // first CNAME target among them. A nameserver whose query for one of the
// every nameserver timed out, failed or returned a referral: that is not // types failed gave only part of the addresses, and is left out. It
// a name with no addresses. // 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( func collectIPs(
results map[string]*NameserverResponse, results map[string]*NameserverResponse,
) ([]string, string, error) { ) ([]string, string, error) {
@@ -1025,7 +1149,8 @@ func collectIPs(
answered := false answered := false
for _, resp := range results { for _, resp := range results {
if resp.Status == StatusTimeout || resp.Status == StatusError { if resp.Status == StatusTimeout || resp.Status == StatusError ||
len(resp.FailedTypes) > 0 {
continue 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)
})
}
}
+19
View File
@@ -43,6 +43,25 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
assert.Empty(t, ips) 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 ( const (
// exampleCom is the zone most cases of TestUsableReply and // exampleCom is the zone most cases of TestUsableReply and
// TestNSSetFrom are about, and wwwExampleCom a name in it. // TestNSSetFrom are about, and wwwExampleCom a name in it.
+2 -2
View File
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
return out return out
} }
// liveLookupNS is liveFindAuthoritative through the LookupNS entry // liveLookupNS looks up the NS record set of domain, a domain that has
// point, so that both entry points stay independently exercised. // one, retrying until the delegation chain can be walked.
func liveLookupNS( func liveLookupNS(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
+8 -4
View File
@@ -31,11 +31,15 @@ type Params struct {
} }
// NameserverResponse holds one nameserver's response for a query. // 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 { type NameserverResponse struct {
Nameserver string Nameserver string
Records map[string][]string Records map[string][]string
Status string FailedTypes []string
Error string Status string
Error string
} }
// Resolver performs iterative DNS resolution from root servers. // Resolver performs iterative DNS resolution from root servers.
+257 -40
View File
@@ -1,6 +1,7 @@
package resolver_test package resolver_test
import ( import (
"bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -24,6 +25,13 @@ import (
// Test helpers // 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 { func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper() t.Helper()
@@ -87,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain(
assert.Equal(t, fromZone, fromHost) 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( func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T, t *testing.T,
) { ) {
@@ -366,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) { func TestQueryNameserver_NXDomain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -417,48 +490,45 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error) assert.Equal(t, "server returned REFUSED", resp.Error)
} }
// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public // TestQueryServers_RecursiveResolverRefused passes a public recursive
// recursive resolver, about google.com at both of its addresses. Quad9 // resolver to QueryServers as the server of google.com. These resolvers
// refuses a query that does not ask for recursion and answers one that // refuse a query that does not ask for recursion and answer one that
// does. The resolver never asks for recursion, so it must be reported // does. The resolver never asks for recursion, so the query must be
// as refusing, never as answering. // reported as refused, never answered. Each resolver is run by a
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) { // 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() t.Parallel()
r := newTestResolver(t) r := newTestResolver(t)
resolvers := []string{
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} { "64.6.64.6", "185.222.222.222", "4.2.2.1", "9.9.9.9",
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserverIP("+ip+", google.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, ip, ip, "google.com",
)
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,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status, ip)
assert.Equal(t, "server returned REFUSED", resp.Error, ip)
} }
var err error
livednstest.Retry(
t,
"QueryServers(public recursive resolvers, google.com)",
func(ctx context.Context) error {
for _, ip := range resolvers {
_, err = r.QueryServers(
ctx, []string{ip}, "google.com.", "google.com.",
dns.TypeA,
)
// 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)
},
)
require.ErrorIs(t, err, resolver.ErrRefused)
} }
// googleNameserverIPv4s returns the IPv4 addresses of google.com's // googleNameserverIPv4s returns the IPv4 addresses of google.com's
@@ -806,8 +876,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
// nameservers of g.ntpns.org. The org servers delegate its parent zone, // nameservers of g.ntpns.org. The org servers delegate its parent zone,
// ntpns.org, without the addresses of its nameservers, so the walk has // 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 // to look them up to ask them. If it did not, the walk for g.ntpns.org
// would fail and LookupNS would return the nameservers of ntpns.org, // would fail.
// which a.ntpns.org is not one of.
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) { func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel() t.Parallel()
@@ -817,6 +886,131 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
assert.Contains(t, nameservers, "a.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 // ResolveIPAddresses tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -1001,6 +1195,29 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error) 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 // TestCollectIPs_NoNameserverAnswered takes the response of a
// nameserver at 192.0.2.1, where nothing answers, as // nameserver at 192.0.2.1, where nothing answers, as
// TestQueryNameserverIP_Timeout does. Addresses collected from // TestQueryNameserverIP_Timeout does. Addresses collected from
+15 -5
View File
@@ -38,19 +38,29 @@ type Params struct {
// DomainState holds the monitoring state for an apex domain. // DomainState holds the monitoring state for an apex domain.
// NameserverAddresses holds the sorted addresses each nameserver's name // NameserverAddresses holds the sorted addresses each nameserver's name
// resolves to, by nameserver name. A state file written before it // 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 { type DomainState struct {
Nameservers []string `json:"nameservers"` Nameservers []string `json:"nameservers"`
NameserverAddresses map[string][]string `json:"nameserverAddresses"` NameserverAddresses map[string][]string `json:"nameserverAddresses"`
NXDomain bool `json:"nxdomain,omitempty"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
// NameserverRecordState holds one NS's response for a hostname. // 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 { type NameserverRecordState struct {
Records map[string][]string `json:"records"` Records map[string][]string `json:"records"`
Status string `json:"status"` FailedTypes []string `json:"failedTypes,omitempty"`
Error string `json:"error,omitempty"` UnknownTypes []string `json:"unknownTypes,omitempty"`
LastChecked time.Time `json:"lastChecked"` Status string `json:"status"`
Error string `json:"error,omitempty"`
LastChecked time.Time `json:"lastChecked"`
} }
// HostnameState holds per-nameserver monitoring state for a hostname. // HostnameState holds per-nameserver monitoring state for a hostname.
+55
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 // TestLoadStateFromBeforeCNAMEAddresses loads a state file written
// before the addresses at the end of a hostname's CNAME chain were // before the addresses at the end of a hostname's CNAME chain were
// saved. They load as not known (nil), not as none. // saved. They load as not known (nil), not as none.
+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 // cnameTo builds the records of a nameserver that answered with a CNAME
// to target and no address. // to target and no address.
func cnameTo(target string) map[string][]string { func cnameTo(target string) map[string][]string {
+7 -1
View File
@@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
w.maybeSendTestNotification(ctx) w.maybeSendTestNotification(ctx)
} }
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
func (w *Watcher) CleanupRemovedTargets() {
w.cleanupRemovedTargets()
}
// CheckAllPorts exports checkAllPorts for testing. // CheckAllPorts exports checkAllPorts for testing.
func (w *Watcher) CheckAllPorts(ctx context.Context) { func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx) w.checkAllPorts(ctx)
@@ -120,7 +125,8 @@ func (w *Watcher) RunTLSChecks(ctx context.Context) {
// BuildHostnameState exports buildHostnameState for testing. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *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],
)
}
}
})
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ import (
// DNSResolver performs iterative DNS resolution. // DNSResolver performs iterative DNS resolution.
type DNSResolver interface { 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( LookupNS(
ctx context.Context, ctx context.Context,
domain string, domain string,
+48
View File
@@ -165,3 +165,51 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
t.Errorf("sent %v, want one message with %q", notifications, 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( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(), map[string]*resolver.NameserverResponse{nsA: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[nsA] got := hs.RecordsByNameserver[nsA]
@@ -256,7 +256,7 @@ func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
@@ -320,7 +320,7 @@ func TestNameserverThatRefuses(t *testing.T) {
} }
hs := watcher.BuildHostnameState( hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(), map[string]*resolver.NameserverResponse{ns: resp}, nil, time.Now(),
) )
got := hs.RecordsByNameserver[ns] got := hs.RecordsByNameserver[ns]
+67 -10
View File
@@ -11,11 +11,10 @@ import (
// TestRemovedTargetsLeaveTheState loads a state saved while a domain // TestRemovedTargetsLeaveTheState loads a state saved while a domain
// and a hostname now removed from the configuration were still in it, // and a hostname now removed from the configuration were still in it,
// and runs the port checks, which the first check after startup runs // and runs the removal that Run does before the first check. The
// after its DNS checks. The removed names' domain, hostname and // removed names' domain, hostname and certificate entries are gone,
// certificate entries are gone, the configured names' are kept, and // the configured names' are kept, and nothing is notified. Nothing is
// nothing is notified. Nothing is looked up: the watcher has no // looked up: the watcher has no resolver.
// resolver.
func TestRemovedTargetsLeaveTheState(t *testing.T) { func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Parallel() t.Parallel()
@@ -34,10 +33,6 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
deps.portChecker, deps.tlsChecker, deps.notifier, deps.portChecker, deps.tlsChecker, deps.notifier,
) )
// A state file is loaded, so changes are notified from the first
// check on.
w.SetFirstRun(false)
// The state a check of all four names saves, each name at ip1. // The state a check of all four names saves, each name at ip1.
for _, name := range []string{domain, removedDomain} { for _, name := range []string{domain, removedDomain} {
deps.state.SetDomainState(name, &state.DomainState{ deps.state.SetDomainState(name, &state.DomainState{
@@ -66,7 +61,7 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Fatalf("loading the state: %v", err) t.Fatalf("loading the state: %v", err)
} }
w.CheckAllPorts(t.Context()) w.CleanupRemovedTargets()
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -92,6 +87,68 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
} }
} }
// 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 // TestCertificateStateForAnAddressGone runs the port checks on hostname
// state built here for a configured hostname, with certificate entries // state built here for a configured hostname, with certificate entries
// saved for it at ip1, ip2 and an IPv6 address. When its nameservers // saved for it at ip1, ip2 and an IPv6 address. When its nameservers
+217 -58
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -130,6 +131,7 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(), "tlsInterval", w.config.TLSInterval.String(),
) )
w.cleanupRemovedTargets()
w.RunOnce(ctx) w.RunOnce(ctx)
w.maybeSendTestNotification(ctx) w.maybeSendTestNotification(ctx)
@@ -200,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 // runDNSChecks performs DNS resolution for all configured domains
// and hostnames, updating state with freshly resolved records. // and hostnames, updating state with freshly resolved records.
// This must complete before port or TLS checks run so those // This must complete before port or TLS checks run so those
@@ -234,6 +289,13 @@ func (w *Watcher) checkDomain(
domain string, domain string,
) { ) {
nameservers, err := w.resolver.LookupNS(ctx, domain) 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 { if err != nil {
w.logFailedLookup( w.logFailedLookup(
ctx, ctx,
@@ -268,9 +330,18 @@ func (w *Watcher) checkDomain(
w.state.SetDomainState(domain, &state.DomainState{ w.state.SetDomainState(domain, &state.DomainState{
Nameservers: nameservers, Nameservers: nameservers,
NameserverAddresses: addresses, NameserverAddresses: addresses,
NXDomain: nxdomain,
LastChecked: now, LastChecked: now,
}) })
// A domain that does not exist has no records of its own: none are
// asked for, and those saved by an earlier check are removed.
if nxdomain {
w.state.DeleteHostnameState(domain)
return
}
// The apex domain's records are also checked and saved as a // The apex domain's records are also checked and saved as a
// hostname's, so that the port and TLS checks find its addresses. // hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine). // Notifications about them name it as a domain (see nameLine).
@@ -401,8 +472,10 @@ func (w *Watcher) checkHostname(
return return
} }
prev, _ := w.state.GetHostnameState(hostname)
w.updateHostnameState( w.updateHostnameState(
ctx, hostname, buildHostnameState(results, time.Now().UTC()), ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
) )
} }
@@ -432,8 +505,9 @@ func (w *Watcher) updateHostnameState(
// the addresses found for all of them are saved, so nameservers that // 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. // 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 addresses saved in prev, which may be nil, are kept when none of
// the name's nameservers answered, and when a target cannot be // the name's nameservers answered its queries for A, AAAA and CNAME,
// followed, as when no nameserver of a zone in its chain answers. // and when a target cannot be followed, as when no nameserver of a zone
// in its chain answers.
func (w *Watcher) resolveCNAMEAddresses( func (w *Watcher) resolveCNAMEAddresses(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -451,7 +525,10 @@ func (w *Watcher) resolveCNAMEAddresses(
targets := make(map[string]bool) targets := make(map[string]bool)
for _, nsState := range current.RecordsByNameserver { 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 continue
} }
@@ -497,11 +574,13 @@ func (w *Watcher) resolveCNAMEAddresses(
} }
// buildHostnameState saves each nameserver's response. A nameserver // buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok; one // that answered, even with NXDOMAIN or no records, is saved as ok, with
// that timed out or failed is saved as error with the reason, and its // the record types whose query failed; one that timed out or failed is
// empty record set is not an answer. // 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( func buildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
prev *state.HostnameState,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
hs := &state.HostnameState{ hs := &state.HostnameState{
@@ -513,7 +592,7 @@ func buildHostnameState(
for ns, resp := range results { for ns, resp := range results {
nsState := &state.NameserverRecordState{ nsState := &state.NameserverRecordState{
Records: resp.Records, Records: maps.Clone(resp.Records),
Status: statusOK, Status: statusOK,
LastChecked: now, LastChecked: now,
} }
@@ -522,6 +601,15 @@ func buildHostnameState(
resp.Status == resolver.StatusError { resp.Status == resolver.StatusError {
nsState.Status = statusError nsState.Status = statusError
nsState.Error = resp.Error 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 hs.RecordsByNameserver[ns] = nsState
@@ -530,6 +618,27 @@ func buildHostnameState(
return hs 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( func (w *Watcher) detectHostnameChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -542,17 +651,50 @@ func (w *Watcher) detectHostnameChanges(
w.detectCNAMEAddressChanges(ctx, hostname, prev, current) 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: // nameLine is the line a notification about name's records starts with:
// "Domain: " and the name for a configured apex domain, whose own // "Domain: " and the name for a configured apex domain, and
// records are checked as a hostname's are, and "Hostname: " otherwise. // "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string { func (w *Watcher) nameLine(name string) string {
if slices.Contains(w.config.Domains, name) { if w.isDomain(name) {
return "Domain: " + name return "Domain: " + name
} }
return "Hostname: " + 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 // detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved, // hostname's CNAME chain differ from those the previous check saved,
// including a change from or to none. When the previous addresses are // including a change from or to none. When the previous addresses are
@@ -585,7 +727,10 @@ func (w *Watcher) detectCNAMEAddressChanges(
// detectRecordChanges compares each nameserver's records with those of // detectRecordChanges compares each nameserver's records with those of
// the previous check. Only answers are compared: a nameserver that // 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( func (w *Watcher) detectRecordChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
@@ -597,7 +742,11 @@ func (w *Watcher) detectRecordChanges(
continue 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 continue
} }
@@ -605,8 +754,8 @@ func (w *Watcher) detectRecordChanges(
"%s\nNameserver: %s\n%s", "%s\nNameserver: %s\n%s",
w.nameLine(hostname), ns, w.nameLine(hostname), ns,
recordDifferences( recordDifferences(
"Old", prevNS.Records, "Old", oldRecords,
"New", cur.Records, "New", newRecords,
), ),
) )
@@ -693,13 +842,19 @@ func (w *Watcher) detectInconsistencies(
) { ) {
for _, pair := range newlyDisagreeingPairs(prev, current) { for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1] 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( msg := fmt.Sprintf(
"%s\n%s", "%s\n%s",
w.nameLine(hostname), w.nameLine(hostname),
recordDifferences( recordDifferences(
ns1, current.RecordsByNameserver[ns1].Records, ns1, withoutTypes(state1.Records, failed),
ns2, current.RecordsByNameserver[ns2].Records, ns2, withoutTypes(state2.Records, failed),
), ),
) )
@@ -717,7 +872,9 @@ func (w *Watcher) detectInconsistencies(
// except pairs where both nameservers answered in prev and already // except pairs where both nameservers answered in prev and already
// differed there. A nameserver missing from prev, or that failed there, // differed there. A nameserver missing from prev, or that failed there,
// is paired with every nameserver it differs from. A nameserver that // 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( func newlyDisagreeingPairs(
prev, current *state.HostnameState, prev, current *state.HostnameState,
) [][2]string { ) [][2]string {
@@ -734,9 +891,9 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers { for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] { for _, ns2 := range nameservers[i+1:] {
if recordsEqual( if nameserversAgree(
current.RecordsByNameserver[ns1].Records, current.RecordsByNameserver[ns1],
current.RecordsByNameserver[ns2].Records, current.RecordsByNameserver[ns2],
) { ) {
continue continue
} }
@@ -746,7 +903,7 @@ func newlyDisagreeingPairs(
if ok1 && ok2 && if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK && prev1.Status == statusOK && prev2.Status == statusOK &&
!recordsEqual(prev1.Records, prev2.Records) { !nameserversAgree(prev1, prev2) {
continue continue
} }
@@ -774,11 +931,9 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
} }
// Phase 3: Remove port state entries that no longer have // Phase 3: Remove port state entries that no longer have
// any hostname referencing them, the domain, hostname and // any hostname referencing them, and certificate entries for
// certificate entries of names no longer configured, and // an address their name no longer has.
// certificate entries for an address their name no longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupRemovedTargets()
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -863,38 +1018,16 @@ func (w *Watcher) cleanupStalePorts(
} }
} }
// cleanupRemovedTargets removes the domain and hostname state entries // cleanupStaleCertificates removes the certificate entries for an
// of names no longer in the configuration. A configured domain's own // address their name no longer resolves to. An entry saved for a name
// records are saved as a hostname entry under its name, which is kept. // none of whose nameservers answered is kept: that name's addresses are
func (w *Watcher) cleanupRemovedTargets() { // not known, not gone.
for _, name := range w.state.GetAllDomainNames() {
if !slices.Contains(w.config.Domains, name) {
w.state.DeleteDomainState(name)
}
}
for _, name := range w.state.GetAllHostnames() {
if !w.isConfigured(name) {
w.state.DeleteHostnameState(name)
}
}
}
// cleanupStaleCertificates removes the certificate entries of names no
// longer configured, and those for an address their name no longer
// resolves to. An entry saved for a configured name none of whose
// nameservers answered is kept: that name's addresses are not known,
// not gone.
func (w *Watcher) cleanupStaleCertificates() { func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() { for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key) ip, hostname := parseCertKey(key)
if w.isConfigured(hostname) && if slices.Contains(w.collectIPs(hostname), ip) ||
slices.Contains(w.collectIPs(hostname), ip) { w.noNameserverAnswered(hostname) {
continue
}
if w.noNameserverAnswered(hostname) {
continue continue
} }
@@ -917,8 +1050,7 @@ func parseCertKey(key string) (string, string) {
// isConfigured reports whether name is a configured domain or hostname. // isConfigured reports whether name is a configured domain or hostname.
func (w *Watcher) isConfigured(name string) bool { func (w *Watcher) isConfigured(name string) bool {
return slices.Contains(w.config.Domains, name) || return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
slices.Contains(w.config.Hostnames, name)
} }
// noNameserverAnswered reports whether name is a configured domain or // noNameserverAnswered reports whether name is a configured domain or
@@ -1013,8 +1145,8 @@ func (w *Watcher) checkSinglePort(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hosts: %s\nAddress: %s\nPort now %s", "%s\nAddress: %s\nPort now %s",
strings.Join(hostnames, ", "), key, stateStr, w.portNameLines(hostnames), key, stateStr,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -1296,6 +1428,33 @@ func toSet(items []string) map[string]bool {
return set 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( func recordsEqual(
a, b map[string][]string, a, b map[string][]string,
) bool { ) 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) { func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()