1 Commits
Author SHA1 Message Date
sneak e3c632c793 notify: a failed Mattermost delivery's error names Mattermost (closes #227)
check / check (push) Successful in 1m8s
Mattermost is sent by the Slack sender, which wrapped every HTTP error
status in ErrSlackFailed, so a Mattermost endpoint answering 503 was
logged as "slack notification failed". The sender now takes the error
to wrap: the Slack endpoint passes ErrSlackFailed and the Mattermost
endpoint passes ErrMattermostFailed, which was defined but unused.

A new delivery test sets both endpoints to a stand-in server answering
503 and checks the error logged for each names its own endpoint.

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