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

Model: opus-5-5
2026-10-02 08:22:45 +00:00
20 changed files with 149 additions and 1282 deletions
+12 -54
View File
@@ -73,21 +73,9 @@ 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
@@ -99,10 +87,7 @@ notification endpoint set, changes show only on the dashboard; see
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.
`Hostname:`.
### DNS Hostname Monitoring (Subdomains)
@@ -110,11 +95,7 @@ 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
@@ -235,9 +216,7 @@ includes:
- **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.
- **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 +243,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,13 +250,11 @@ 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.
- **Domains** with their discovered nameservers, 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.
- **Ports** with open/closed state and associated hostnames.
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
the reason is shown in place of CN, issuer and expiry.
- **Recent alerts** (last 100 notifications sent since the process started),
@@ -318,11 +287,7 @@ 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`.
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain.
`/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,
@@ -610,11 +575,6 @@ nothing for it. Both lists are left out when empty.
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
@@ -623,9 +583,8 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification.
A port entry'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 +728,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):
-12
View File
@@ -19,20 +19,8 @@ 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
+1 -5
View File
@@ -45,14 +45,11 @@ 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).
// holds the apex domains' own (see splitHostnames).
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
@@ -74,7 +71,6 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
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"),
+3 -69
View File
@@ -191,83 +191,17 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
words := strings.Join(strings.Fields(page), " ")
footer := "monitoring 2 domains + 1 hostnames"
footer := "monitoring 1 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
// With the tags taken out, the summary bar starts "Domains 1
// Hostnames 1".
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
summary := "Domains 2 Hostnames 1"
summary := "Domains 1 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)
}
}
+10 -30
View File
@@ -10,12 +10,10 @@ 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.
// hostname's Nameservers holds the hostname's.
type statusDomainInfo struct {
Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
NXDomain bool `json:"nxdomain"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -34,11 +32,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"`
}
@@ -104,6 +99,7 @@ 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),
}
@@ -111,7 +107,7 @@ func buildStatusResponse(
buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp)
resp.Ports = buildPorts(snap)
buildPorts(snap, resp)
buildCertificates(snap, resp)
buildCounts(resp)
@@ -157,7 +153,6 @@ func buildDomains(
resp.Domains[name] = &statusDomainInfo{
Nameservers: ns,
RecordsByNameserver: records,
NXDomain: ds.NXDomain,
LastChecked: ds.LastChecked,
}
}
@@ -200,36 +195,21 @@ func nameserverInfo(
return info
}
// buildPorts returns the port entries saved in snap. A port entry
// saves apex domains with its hostnames; they are told apart as in
// splitHostnames, by a domain entry in snap.Domains.
func buildPorts(snap state.Snapshot) map[string]*statusPortInfo {
ports := make(map[string]*statusPortInfo, len(snap.Ports))
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(
+11 -101
View File
@@ -21,12 +21,8 @@ import (
// 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.
// with the hostnames' records, as the watcher saves them.
const (
missingDomain = "does-not-exist.example"
testHostname = "www.example.com"
answeringNS = "ns1.example.com."
failedNS = "ns2.example.com."
@@ -36,7 +32,6 @@ const (
testDomain = "example.net"
domainNS = "a.iana-servers.net."
domainAddress = "192.0.2.2"
sharedPort = domainAddress + ":443"
)
// newHandlersWithFailures builds real Handlers whose state holds the
@@ -70,31 +65,12 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
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},
},
Records: map[string][]string{"A": {"192.0.2.1"}},
Status: "ok",
LastChecked: now,
},
@@ -130,17 +106,17 @@ func setTestState(st *state.State) {
LastChecked: now,
})
st.SetPortState(sharedPort, &state.PortState{
Open: true,
Hostnames: []string{testDomain, testHostname},
LastChecked: now,
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
Notify: notifier,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
st.SetDomainState(missingDomain, &state.DomainState{
Nameservers: []string{},
NXDomain: true,
LastChecked: now,
})
return hnd
}
// get serves one GET request to handler and returns the response body.
@@ -241,69 +217,3 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
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)
}
}
+2 -10
View File
@@ -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 }}
@@ -161,20 +157,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 +187,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>
-4
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.
-37
View File
@@ -17,43 +17,6 @@ func NewWithFailingTCP(log *slog.Logger) *Resolver {
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)
+33 -83
View File
@@ -8,7 +8,6 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -204,12 +203,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,
@@ -240,15 +233,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)
@@ -497,8 +485,7 @@ func (r *Resolver) resolveNSIPs(
// 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 +515,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 {
@@ -623,23 +600,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 +621,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
@@ -755,15 +724,14 @@ func (r *Resolver) queryTypes(
}
type queryState struct {
gotNXDomain bool
gotErrorReply bool
errorReply string // its code, such as SERVFAIL, or number if unnamed
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
gotNXDomain bool
errorReply string // code of an error reply, such as SERVFAIL
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
}
// queryEachType asks the nameserver at nsIP about hostname once for each
@@ -865,14 +833,7 @@ func readReply(
}
if isErrorReply(msg) {
state.gotErrorReply = true
code, named := dns.RcodeToString[msg.Rcode]
if !named {
code = strconv.Itoa(msg.Rcode)
}
state.errorReply = code
state.errorReply = dns.RcodeToString[msg.Rcode]
return fmt.Errorf(
"server returned %s: %w", state.errorReply, ErrUnusableReply,
@@ -882,7 +843,9 @@ func readReply(
// 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
@@ -941,9 +904,7 @@ func isTimeout(err error) bool {
// 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.
// the status of those answers.
func classifyResponse(resp *NameserverResponse, state queryState) {
switch {
case state.gotNXDomain && !state.hasRecords:
@@ -951,7 +912,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotErrorReply && !state.answered:
case state.errorReply != "" && !state.answered:
resp.Status = StatusError
resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.answered:
@@ -963,8 +924,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered:
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 +1013,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
+30 -63
View File
@@ -1,87 +1,66 @@
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.
// its queries, 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; one whose every query got none
// has.
func TestClassifyResponse(t *testing.T) {
t.Parallel()
tests := []struct {
name string
results queryState
failedTypes []string
wantStatus string
wantError string
name string
results queryState
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, "",
StatusNoData, "",
},
{
"some types answered with no records, another got SERVFAIL",
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
[]string{"A"},
StatusOK, "",
queryState{answered: true, errorReply: "SERVFAIL"},
StatusNoData, "",
},
{
"some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true},
[]string{"A"},
StatusOK, "",
StatusNoData, "",
},
{
"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED},
[]string{"A"},
StatusOK, "",
StatusNoData, "",
},
{
"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, "",
StatusNoData, "",
},
{
"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true},
[]string{"A"},
StatusOK, "",
StatusNoData, "",
},
{
"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"},
queryState{errorReply: "NOTIMP"},
StatusError, "server returned NOTIMP",
},
}
@@ -90,39 +69,34 @@ func TestClassifyResponse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := &NameserverResponse{Status: StatusOK, FailedTypes: tt.failedTypes}
resp := &NameserverResponse{Status: StatusOK}
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.
// reply with any other code is not, and the type's query has failed.
func TestReadReply(t *testing.T) {
t.Parallel()
tests := []struct {
rcode int
wantStatus string
wantError string
rcode int
answered bool
}{
{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
{dns.RcodeSuccess, true},
{dns.RcodeNameError, true},
{dns.RcodeServerFailure, false},
{dns.RcodeNotImplemented, false},
{dns.RcodeFormatError, false},
}
for _, tt := range tests {
t.Run(strconv.Itoa(tt.rcode), func(t *testing.T) {
t.Run(dns.RcodeToString[tt.rcode], func(t *testing.T) {
t.Parallel()
msg := new(dns.Msg)
@@ -130,20 +104,13 @@ func TestReadReply(t *testing.T) {
msg.Rcode = tt.rcode
resp := &NameserverResponse{Records: map[string][]string{}}
err := readReply(msg, resp, &queryState{})
var state queryState
err := readReply(msg, resp, &state)
classifyResponse(resp, state)
if tt.wantStatus == StatusError {
require.ErrorIs(t, err, ErrUnusableReply)
if tt.answered {
assert.NoError(t, err)
} else {
require.NoError(t, err)
assert.ErrorIs(t, err, ErrUnusableReply)
}
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
})
}
}
+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,
+35 -204
View File
@@ -25,13 +25,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 +88,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,
) {
@@ -490,45 +442,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)",
func(ctx context.Context) error {
for _, ip := range resolvers {
_, err = r.QueryServers(
ctx, []string{ip}, "google.com.", "google.com.",
dns.TypeA,
livednstest.Retry(
t,
"QueryNameserverIP("+ip+", google.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, ip, ip, "google.com",
)
// 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
if err != nil {
return err
}
}
return fmt.Errorf("%w: %w", livednstest.ErrNoAnswer, 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,
)
}
require.ErrorIs(t, err, resolver.ErrRefused)
return nil
},
)
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
@@ -876,7 +831,8 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
// 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.
// would fail and LookupNS would return the nameservers of ntpns.org,
// which a.ntpns.org is not one of.
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
@@ -886,131 +842,6 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
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
// ----------------------------------------------------------------
+1 -68
View File
@@ -38,13 +38,10 @@ 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"`
}
@@ -301,27 +298,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 +321,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 +383,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
-5
View File
@@ -107,11 +107,6 @@ 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)
+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,
-48
View File
@@ -165,51 +165,3 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
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)
}
}
-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)
}
})
}
}
+8 -147
View File
@@ -131,7 +131,6 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(),
)
w.cleanupRemovedTargets()
w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
@@ -202,59 +201,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
@@ -289,13 +235,6 @@ func (w *Watcher) checkDomain(
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,
@@ -330,18 +269,9 @@ 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).
@@ -651,50 +581,17 @@ 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.
// "Domain: " and the name for a configured apex domain, whose own
// records are checked as a hostname's are, and "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string {
if w.isDomain(name) {
if slices.Contains(w.config.Domains, 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
@@ -931,10 +828,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 +913,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 +1006,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(
-113
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()