Compare commits

...
Author SHA1 Message Date
sneak c302f1399d watcher, config: no Record Change or Inconsistency for listed names (closes #255)
check / check (push) Waiting to run
DNSWATCHER_SKIP_RECORD_NOTIFICATIONS takes a comma-separated list of
names from DNSWATCHER_TARGETS, read as the targets are (letter case,
trailing dot, repeats). For a listed name no Record Change and no
Inconsistency notification is sent; its records are still checked and
saved, and its other notifications are sent. A listed name that is not
a target stops startup with an error naming it.

The two detections return early for a listed name. Apex domains are
covered too: their own records go through the same detection.

Live DNS cannot be made to disagree on purpose, so the Inconsistency
test feeds records to the change detection directly, as the existing
inconsistency tests do; the live test covers Record Change.

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

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

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

Model: opus-5-5
2026-10-02 12:00:48 +02:00
19 changed files with 869 additions and 157 deletions
+76 -34
View File
@@ -73,9 +73,21 @@ notification endpoint set, changes show only on the dashboard; see
to discover all authoritative nameservers (NS records) for each domain.
- Queries **every** discovered authoritative nameserver independently.
- Stores the domain's NS record set, as its parent zone's servers delegate it,
and the IPv4 and IPv6 addresses each nameserver's name resolves to.
and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is
only ever the domain's own delegation. A domain whose parent zone's servers
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
delegation of its own, such as `octocat.github.io`, has no nameservers either.
When the parent zone's servers do not answer, the check fails and the set from
the previous check is kept.
- Any change triggers a notification:
- NS added to or removed from that set.
- NS added to or removed from that set. A domain that had nameservers on the
previous check and no longer exists gets one with all of them removed.
After an upgrade, a domain with no delegation of its own, for which an
earlier version saved its parent zone's nameservers, also gets one with
all of them removed, on its first check. That one does not mean the domain
stopped existing: it is not shown as not existing, and its records are
still watched.
- NS address change: a nameserver that stays in the set resolves to
different addresses than on the previous check. A nameserver added or
removed gets only the NS change notification. When the lookup of a
@@ -87,7 +99,11 @@ 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:`.
`Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
record change or inconsistency notification. A domain with no delegation of
its own has these records asked at the servers of the zone it is in, as a
hostname has. A domain that does not exist has none: they are not asked for,
and those saved by an earlier check are removed without a notification.
### DNS Hostname Monitoring (Subdomains)
@@ -95,7 +111,11 @@ notification endpoint set, changes show only on the dashboard; see
via the Public Suffix List).
- Every **1 hour** by default, performs a full iterative trace to discover the
authoritative nameservers of the zone the hostname is in, which is not always
its last two labels (a name under `co.uk`, or in a delegated subdomain).
its last two labels (a name under `co.uk`, or in a delegated subdomain). The
trace moves from a name to its parent only when the servers asked answer that
the name has no delegation of its own, or does not exist. When they do not
answer, the check fails and the hostname's records from the previous check are
kept.
- Queries **each** authoritative nameserver independently for **all** record
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
- Each record type is a query of its own. When a nameserver answers some types
@@ -117,10 +137,14 @@ notification endpoint set, changes show only on the dashboard; see
lower case, because names are case-insensitive and nameservers may answer in
any letter case. TXT and CAA values keep their letter case; they are not
lower-cased.
- Any observable change in any nameserver's response triggers a notification.
This includes:
- Any observable change in any nameserver's response triggers a notification,
except a record change or an inconsistency for a domain or hostname listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`. This includes:
- **Record change**: A nameserver returns different records than it did on
the previous check (additions, removals, value changes).
the previous check (additions, removals, value changes). For a domain or
hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, neither a
record change nor an inconsistency is notified; its records are still
checked and saved, and its other notifications are sent.
- **NS query failure**: A nameserver that previously responded becomes
unreachable (timeout, SERVFAIL, REFUSED, network error). This is distinct
from "responded with no records": a nameserver that answers NXDOMAIN or
@@ -141,7 +165,8 @@ notification endpoint set, changes show only on the dashboard; see
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.
disagrees, the alert is sent again. For a domain or hostname listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS`, no inconsistency is notified.
- **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
@@ -167,9 +192,13 @@ notification endpoint set, changes show only on the dashboard; see
- Any change in port availability triggers a notification:
- Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change): its port state is recorded without a
port notification; the DNS change notification shows the new address.
port notification; the DNS change notification shows the new address. A
domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets
no notification for an address added to its own A or AAAA records.
- IP disappeared (from DNS change) — noted in the DNS change notification;
port state for that IP is removed. When none of a name's nameservers
port state for that IP is removed. A domain or hostname listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no notification for an address
removed from its own A or AAAA records. When none of a name's nameservers
answered, its addresses are not known, so the port state saved for them is
kept.
@@ -191,7 +220,9 @@ notification endpoint set, changes show only on the dashboard; see
**Every observable state change produces a notification.** dnswatcher is
designed as a real-time change feed — degradations, failures, recoveries, and
routine changes are all reported equally.
routine changes are all reported equally. A domain or hostname listed in
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
notification.
Supported notification backends:
@@ -260,8 +291,9 @@ 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, and each domain's own records
per nameserver and status, shown as a hostname's are.
- **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
@@ -298,9 +330,11 @@ In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
is `error` also has `error`, the reason, as in the state file (see State File
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 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. 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,
@@ -387,23 +421,24 @@ following precedence (highest to lowest):
### Environment Variables
| Variable | Description | Default |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------- |
| `PORT` | HTTP listen port | `8080` |
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
| Variable | Description | Default |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `PORT` | HTTP listen port | `8080` |
| `DNSWATCHER_DEBUG` | Enable debug logging | `false` |
| `DNSWATCHER_DATA_DIR` | Directory for state file | `/var/lib/dnswatcher` |
| `DNSWATCHER_TARGETS` | Comma-separated DNS names (auto-classified via PSL) | `""` |
| `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` | Comma-separated names from `DNSWATCHER_TARGETS` for which no record change or inconsistency is notified; any other name stops startup | `""` |
| `DNSWATCHER_SLACK_WEBHOOK` | Slack incoming webhook URL | `""` |
| `DNSWATCHER_MATTERMOST_WEBHOOK` | Mattermost incoming webhook URL | `""` |
| `DNSWATCHER_NTFY_TOPIC` | ntfy topic URL | `""` |
| `DNSWATCHER_DNS_INTERVAL` | DNS check interval, a positive duration such as `30m`; empty means the default, anything else stops startup | `1h` |
| `DNSWATCHER_TLS_INTERVAL` | TLS check interval, a positive duration such as `6h`; empty means the default, anything else stops startup | `12h` |
| `DNSWATCHER_TLS_EXPIRY_WARNING` | Days before expiry to warn | `7` |
| `DNSWATCHER_SENTRY_DSN` | Sentry DSN for error reporting | `""` |
| `DNSWATCHER_MAINTENANCE_MODE` | Only sets `maintenanceMode` in the health check response; changes nothing else | `false` |
| `DNSWATCHER_METRICS_USERNAME` | Basic auth username for /metrics, which is served only when this is set | `""` |
| `DNSWATCHER_METRICS_PASSWORD` | Basic auth password for /metrics | `""` |
| `DNSWATCHER_SEND_TEST_NOTIFICATION` | Send a test notification after first scan completes | `false` |
**`DNSWATCHER_TARGETS` is required.** dnswatcher will refuse to start if no
monitoring targets are configured. A monitoring daemon with nothing to monitor
@@ -588,6 +623,11 @@ 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
@@ -759,7 +799,9 @@ docker run -d \
nameservers answered, the addresses an earlier check found at the end of
the chain are used.
4. **On change detection**: Send notifications to all configured endpoints,
update in-memory state, persist to disk.
update in-memory state, persist to disk. A record change or inconsistency for
a domain or hostname listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` sends
no notification, but the state is still updated and saved.
5. **Shutdown**: The watcher stops checking and saves the final state to disk,
and shutdown waits for that save before it goes on. Then it waits for
in-flight notification deliveries to complete. Both waits share the fx
+9
View File
@@ -19,6 +19,15 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps
- 2026-10-05: a name listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
Record Change or Inconsistency notification; a name listed there that is not
in `DNSWATCHER_TARGETS` stops startup (closes #255).
- 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
+81 -36
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"time"
@@ -35,6 +36,10 @@ var ErrInvalidInterval = errors.New(
"interval must be a positive duration such as 30m or 1h",
)
// ErrNotInTargets is returned when DNSWATCHER_SKIP_RECORD_NOTIFICATIONS
// lists a name that is not in DNSWATCHER_TARGETS.
var ErrNotInTargets = errors.New("name is not in DNSWATCHER_TARGETS")
// Params contains dependencies for Config.
type Params struct {
fx.In
@@ -45,24 +50,25 @@ type Params struct {
// Config holds application configuration.
type Config struct {
Port int
Debug bool
DataDir string
Domains []string
Hostnames []string
SlackWebhook string
MattermostWebhook string
NtfyTopic string
DNSInterval time.Duration
TLSInterval time.Duration
TLSExpiryWarning int
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SendTestNotification bool
params *Params
log *slog.Logger
Port int
Debug bool
DataDir string
Domains []string
Hostnames []string
SkipRecordNotifications []string
SlackWebhook string
MattermostWebhook string
NtfyTopic string
DNSInterval time.Duration
TLSInterval time.Duration
TLSExpiryWarning int
SentryDSN string
MaintenanceMode bool
MetricsUsername string
MetricsPassword string
SendTestNotification bool
params *Params
log *slog.Logger
}
// New creates a new Config instance from environment and config files.
@@ -103,6 +109,7 @@ func setupViper(name string) {
viper.SetDefault("DEBUG", false)
viper.SetDefault("DATA_DIR", "/var/lib/"+name)
viper.SetDefault("TARGETS", "")
viper.SetDefault("SKIP_RECORD_NOTIFICATIONS", "")
viper.SetDefault("SLACK_WEBHOOK", "")
viper.SetDefault("MATTERMOST_WEBHOOK", "")
viper.SetDefault("NTFY_TOPIC", "")
@@ -147,25 +154,33 @@ func buildConfig(
return nil, err
}
skipRecordNotifications, err := parseSkipRecordNotifications(
domains, hostnames,
)
if err != nil {
return nil, err
}
cfg := &Config{
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
Domains: domains,
Hostnames: hostnames,
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params,
log: log,
Port: viper.GetInt("PORT"),
Debug: viper.GetBool("DEBUG"),
DataDir: viper.GetString("DATA_DIR"),
Domains: domains,
Hostnames: hostnames,
SkipRecordNotifications: skipRecordNotifications,
SlackWebhook: viper.GetString("SLACK_WEBHOOK"),
MattermostWebhook: viper.GetString("MATTERMOST_WEBHOOK"),
NtfyTopic: viper.GetString("NTFY_TOPIC"),
DNSInterval: dnsInterval,
TLSInterval: tlsInterval,
TLSExpiryWarning: viper.GetInt("TLS_EXPIRY_WARNING"),
SentryDSN: viper.GetString("SENTRY_DSN"),
MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
SendTestNotification: viper.GetBool("SEND_TEST_NOTIFICATION"),
params: params,
log: log,
}
return cfg, nil
@@ -204,6 +219,36 @@ func parseAndValidateTargets() ([]string, []string, error) {
return domains, hostnames, nil
}
// parseSkipRecordNotifications reads DNSWATCHER_SKIP_RECORD_NOTIFICATIONS,
// a comma-separated list of names from the targets. Each name is written
// as ClassifyTargets writes a target, in lower case without a trailing
// dot, and a name listed more than once is kept once. A name that is
// not one of domains or hostnames is an error naming it.
func parseSkipRecordNotifications(
domains, hostnames []string,
) ([]string, error) {
value := viper.GetString("SKIP_RECORD_NOTIFICATIONS")
var names []string
for _, listed := range parseCSV(value) {
name := strings.ToLower(strings.TrimSuffix(listed, "."))
if !slices.Contains(domains, name) && !slices.Contains(hostnames, name) {
return nil, fmt.Errorf(
"invalid DNSWATCHER_SKIP_RECORD_NOTIFICATIONS %q: %w",
listed, ErrNotInTargets,
)
}
if !slices.Contains(names, name) {
names = append(names, name)
}
}
return names, nil
}
func parseCSV(input string) []string {
if input == "" {
return nil
+28
View File
@@ -57,6 +57,7 @@ func TestNew_DefaultValues(t *testing.T) {
assert.Empty(t, cfg.MetricsUsername)
assert.Empty(t, cfg.MetricsPassword)
assert.False(t, cfg.SendTestNotification)
assert.Empty(t, cfg.SkipRecordNotifications)
}
func TestNew_EnvironmentOverrides(t *testing.T) {
@@ -235,6 +236,33 @@ func TestNew_TargetsWithTrailingComma(t *testing.T) {
"trailing comma should be ignored")
}
func TestNew_SkipRecordNotifications(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net,www.example.net,example.org")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
" WWW.Example.net. , example.net,www.example.net")
cfg, err := config.New(nil, newTestParams(t))
require.NoError(t, err)
assert.Equal(t,
[]string{"www.example.net", "example.net"},
cfg.SkipRecordNotifications,
"names are written as targets are, each once",
)
}
func TestNew_SkipRecordNotificationsNotInTargetsStopsStartup(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.net")
t.Setenv("DNSWATCHER_SKIP_RECORD_NOTIFICATIONS",
"example.net,www.example.net")
_, err := config.New(nil, newTestParams(t))
require.ErrorIs(t, err, config.ErrNotInTargets)
require.ErrorContains(t, err, "DNSWATCHER_SKIP_RECORD_NOTIFICATIONS")
require.ErrorContains(t, err, `"www.example.net"`)
}
func TestNew_CustomDNSIntervalDuration(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
+21 -3
View File
@@ -191,21 +191,39 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
words := strings.Join(strings.Fields(page), " ")
footer := "monitoring 1 domains + 1 hostnames"
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 1
// With the tags taken out, the summary bar starts "Domains 2
// Hostnames 1".
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
summary := "Domains 1 Hostnames 1"
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 {
+4 -1
View File
@@ -10,10 +10,12 @@ 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.
// 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"`
}
@@ -155,6 +157,7 @@ func buildDomains(
resp.Domains[name] = &statusDomainInfo{
Nameservers: ns,
RecordsByNameserver: records,
NXDomain: ds.NXDomain,
LastChecked: ds.LastChecked,
}
}
+42
View File
@@ -23,7 +23,10 @@ import (
// 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."
@@ -132,6 +135,12 @@ func setTestState(st *state.State) {
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.
@@ -233,6 +242,39 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
}
}
// 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.
@@ -84,7 +84,11 @@
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ if $ds.NXDomain }}
<span class="text-red-400">does not exist</span>
{{ else }}
{{ joinStrings $ds.Nameservers ", " }}
{{ end }}
</td>
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
{{ relTime $ds.LastChecked }}
+4
View File
@@ -10,6 +10,10 @@ var (
"no authoritative nameservers found",
)
// ErrNXDomain is returned when the servers of the zone a domain
// is in answer NXDOMAIN: the domain does not exist.
ErrNXDomain = errors.New("domain does not exist")
// ErrNoNameserverAnswered is returned when every nameserver
// asked about a name timed out, failed or returned a referral,
// so whether the name has addresses is unknown.
+37
View File
@@ -17,6 +17,43 @@ 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)
+64 -23
View File
@@ -204,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
return ips
}
// followDelegation follows referrals from servers, the root servers, to
// domain and returns the NS set of domain's delegation. When the servers
// of the zone domain is in answer that domain does not exist, the error
// is ErrNXDomain. When they answer that it has no delegation of its own,
// because it is not the zone's apex, the set is empty and there is no
// error. Any other error means that no such answer came.
func (r *Resolver) followDelegation(
ctx context.Context,
domain string,
@@ -234,10 +240,15 @@ func (r *Resolver) followDelegation(
// An authoritative reply comes from the servers of the zone
// domain is in; it is not a referral, even when its authority
// section lists that zone's NS records. Without NS records in
// the answer, domain is not the zone's apex and has no
// nameservers of its own.
// the answer, domain has no nameservers of its own: it does
// not exist, when the reply is NXDOMAIN, or else it is not the
// zone's apex.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return nil, ErrNoNameservers
return []string{}, nil
}
authNS := extractNSSet(resp.Ns)
@@ -486,7 +497,8 @@ 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.
// authoritative answer in the delegation chain. Its result means what
// followDelegation's does.
func (r *Resolver) resolveNSIterative(
ctx context.Context,
domain string,
@@ -516,6 +528,16 @@ func (r *Resolver) resolveNSIterative(
return nsNames, nil
}
// As in followDelegation: domain has no nameservers of its
// own.
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
return nil, ErrNXDomain
}
if resp.Authoritative {
return []string{}, nil
}
// Follow delegation.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
@@ -601,12 +623,23 @@ func (r *Resolver) resolveARecord(
// FindAuthoritativeNameservers traces the delegation chain from
// root servers to discover all authoritative nameservers for the
// given domain, as the delegation from its parent zone's servers lists
// them. For a name that is not a zone apex it tries each
// parent name in turn, so it returns the nameservers of the zone the
// name is in.
// them. When the servers asked answer that the name has no delegation
// of its own, or does not exist, it tries each parent name in turn, so
// it returns the nameservers of the zone the name is in. When they do
// not answer, it returns the error and tries no parent name.
func (r *Resolver) FindAuthoritativeNameservers(
ctx context.Context,
domain string,
) ([]string, error) {
return r.findAuthoritativeNameservers(ctx, domain, rootServerList())
}
// findAuthoritativeNameservers is FindAuthoritativeNameservers with each
// walk starting at servers, the root servers.
func (r *Resolver) findAuthoritativeNameservers(
ctx context.Context,
domain string,
servers []string,
) ([]string, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
@@ -622,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers(
candidate := strings.Join(labels[i:], ".") + "."
nsNames, err := r.followDelegation(
ctx, candidate, rootServerList(),
)
if err == nil && len(nsNames) > 0 {
nsNames, err := r.followDelegation(ctx, candidate, servers)
if err != nil && !errors.Is(err, ErrNXDomain) {
return nil, err
}
if len(nsNames) > 0 {
sort.Strings(nsNames)
return nsNames, nil
}
// The root servers would refuse every parent name too.
if errors.Is(err, ErrIntercepted) {
return nil, err
}
}
return nil, ErrNoNameservers
@@ -852,9 +882,7 @@ 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, as do a parent zone's servers
// when FindAuthoritativeNameservers found no delegation for the
// name's zone and moved on to a parent name.
// does not hold the zone may send one.
if !msg.Authoritative && len(msg.Answer) == 0 &&
len(extractNSSet(msg.Ns)) > 0 {
state.gotReferral = true
@@ -913,7 +941,9 @@ 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.
// 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:
@@ -933,7 +963,8 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotReferral && !state.answered:
resp.Status = StatusError
resp.Error = "server returned a referral"
case !state.hasRecords && !state.gotNXDomain:
// An NXDOMAIN reply with no records was taken by the first case.
case !state.hasRecords && len(resp.FailedTypes) == 0:
resp.Status = StatusNoData
}
}
@@ -1022,12 +1053,22 @@ func (r *Resolver) queryEachNS(
return results, nil
}
// LookupNS returns the NS record set for a domain.
// LookupNS returns the NS record set of a domain, as the delegation from
// its parent zone's servers lists it, and never a parent name's. When
// they answer that the domain does not exist, the error is ErrNXDomain.
// When they answer that it has no delegation of its own, the set is
// empty and there is no error.
func (r *Resolver) LookupNS(
ctx context.Context,
domain string,
) ([]string, error) {
return r.FindAuthoritativeNameservers(ctx, domain)
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
return r.followDelegation(
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
)
}
// LookupAllRecords performs iterative resolution to find all DNS
+33 -15
View File
@@ -11,60 +11,77 @@ import (
)
// TestClassifyResponse sets a nameserver's status from the results of
// 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.
// 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
wantStatus string
wantError string
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},
StatusNoData, "",
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got SERVFAIL",
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
StatusNoData, "",
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another was refused",
queryState{answered: true, gotRefused: true},
StatusNoData, "",
[]string{"A"},
StatusOK, "",
},
{
"some types answered with no records, another got a network error",
queryState{answered: true, netErr: syscall.ECONNREFUSED},
StatusNoData, "",
[]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},
StatusNoData, "",
[]string{"TXT"},
StatusOK, "",
},
{
"some types answered with no records, another got a referral",
queryState{answered: true, gotReferral: true},
StatusNoData, "",
[]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",
},
}
@@ -73,11 +90,12 @@ func TestClassifyResponse(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
resp := &NameserverResponse{Status: StatusOK}
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)
})
}
}
+2 -2
View File
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
return out
}
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
// point, so that both entry points stay independently exercised.
// liveLookupNS looks up the NS record set of domain, a domain that has
// one, retrying until the delegation chain can be walked.
func liveLookupNS(
t *testing.T,
r *resolver.Resolver,
+209 -40
View File
@@ -25,6 +25,13 @@ import (
// Test helpers
// ----------------------------------------------------------------
// nonexistentDomain is a .com domain that does not exist.
const nonexistentDomain = "dnswatcher-test-does-not-exist.com"
// noAnswerAddress is 192.0.2.1, a documentation address: nothing
// answers there.
const noAnswerAddress = "192.0.2.1"
func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper()
@@ -88,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain(
assert.Equal(t, fromZone, fromHost)
}
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
// that the name has no delegation of its own, so it gets their names,
// not those of the cmu.edu servers. Every referral on the way gives the
// nameservers' addresses, so the walk sends few queries.
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
fromParent := liveLookupNS(t, r, "cmu.edu")
assert.Equal(t, fromZone, fromHost)
assert.NotEqual(t, fromParent, fromHost)
}
// TestFindAuthoritativeNameservers_NoAnswer starts each walk for
// www.google.com at 192.0.2.1, a documentation address where nothing
// answers. A walk that got no answer does not say that the name has no
// delegation of its own, so the lookup returns that walk's error, about
// www.google.com, and tries no parent name: trying google.com and com
// would end in ErrNoNameservers, or in the error of a walk for one of
// them.
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
t.Parallel()
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
nameservers, err := r.FindAuthoritativeNameserversFrom(
t.Context(), "www.google.com", []string{noAnswerAddress},
)
require.Error(t, err)
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
assert.Empty(t, nameservers)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
@@ -442,48 +490,45 @@ func TestQueryNameserver_Refused(t *testing.T) {
assert.Equal(t, "server returned REFUSED", resp.Error)
}
// TestQueryNameserverIP_RecursiveResolverRefused asks Quad9, a public
// recursive resolver, about google.com at both of its addresses. Quad9
// refuses a query that does not ask for recursion and answers one that
// does. The resolver never asks for recursion, so it must be reported
// as refusing, never as answering.
func TestQueryNameserverIP_RecursiveResolverRefused(t *testing.T) {
// TestQueryServers_RecursiveResolverRefused passes a public recursive
// resolver to QueryServers as the server of google.com. These resolvers
// refuse a query that does not ask for recursion and answer one that
// does. The resolver never asks for recursion, so the query must be
// reported as refused, never answered. Each resolver is run by a
// different operator, and they are asked in turn until one replies, so
// one operator not answering does not fail the test.
func TestQueryServers_RecursiveResolverRefused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
for _, ip := range []string{"9.9.9.9", "149.112.112.112"} {
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserverIP("+ip+", google.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserverIP(
ctx, ip, ip, "google.com",
)
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s",
livednstest.ErrNoAnswer, ip, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status, ip)
assert.Equal(t, "server returned REFUSED", resp.Error, ip)
resolvers := []string{
"64.6.64.6", "185.222.222.222", "4.2.2.1", "9.9.9.9",
}
var err error
livednstest.Retry(
t,
"QueryServers(public recursive resolvers, google.com)",
func(ctx context.Context) error {
for _, ip := range resolvers {
_, err = r.QueryServers(
ctx, []string{ip}, "google.com.", "google.com.",
dns.TypeA,
)
// A refusal or an answer is a reply; anything else may
// be no reply at all, so the next resolver is asked.
if err == nil || errors.Is(err, resolver.ErrRefused) {
return nil
}
}
return fmt.Errorf("%w: %w", livednstest.ErrNoAnswer, err)
},
)
require.ErrorIs(t, err, resolver.ErrRefused)
}
// googleNameserverIPv4s returns the IPv4 addresses of google.com's
@@ -831,8 +876,7 @@ 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 and LookupNS would return the nameservers of ntpns.org,
// which a.ntpns.org is not one of.
// would fail.
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
t.Parallel()
@@ -842,6 +886,131 @@ 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
// ----------------------------------------------------------------
+4 -1
View File
@@ -38,10 +38,13 @@ type Params struct {
// DomainState holds the monitoring state for an apex domain.
// NameserverAddresses holds the sorted addresses each nameserver's name
// resolves to, by nameserver name. A state file written before it
// existed loads with it nil.
// existed loads with it nil. NXDomain is true when the domain's parent
// zone's servers answered that it does not exist; it then has no
// nameservers.
type DomainState struct {
Nameservers []string `json:"nameservers"`
NameserverAddresses map[string][]string `json:"nameserverAddresses"`
NXDomain bool `json:"nxdomain,omitempty"`
LastChecked time.Time `json:"lastChecked"`
}
+3 -1
View File
@@ -11,7 +11,9 @@ import (
// DNSResolver performs iterative DNS resolution.
type DNSResolver interface {
// LookupNS discovers authoritative nameservers for a domain.
// LookupNS returns a domain's NS record set, as its parent zone's
// servers delegate it: empty when they answer that it has none, and
// resolver.ErrNXDomain when they answer that it does not exist.
LookupNS(
ctx context.Context,
domain string,
@@ -0,0 +1,107 @@
package watcher_test
import (
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestSkipRecordNotificationsAlerts runs the hostname change detection
// for two names on the same check: nsA's address changes, so that nsA
// now differs from nsB, and nsC stops answering. The name in
// SkipRecordNotifications gets only the NS Failure; the other name also
// gets the Record Change and the Inconsistency.
func TestSkipRecordNotificationsAlerts(t *testing.T) {
t.Parallel()
const skipped = "skipped.example.net"
prev := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: answered(map[string][]string{"A": {ip1}}),
})
current := saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip2}}),
nsB: answered(map[string][]string{"A": {ip1}}),
nsC: failed(),
})
cfg := &config.Config{
Hostnames: []string{host, skipped},
SkipRecordNotifications: []string{skipped},
}
// The hostname change detection uses only the configuration and the
// notifier.
notifier := &mockNotifier{}
w := watcher.NewForTest(cfg, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(t.Context(), host, prev, current)
w.DetectHostnameChanges(t.Context(), skipped, prev, current)
sent := notifier.getNotifications()
titles := make([]string, 0, len(sent))
for _, n := range sent {
titles = append(titles, n.Title)
}
slices.Sort(titles)
want := []string{
"Inconsistency: " + host,
"NS Failure: " + skipped,
"NS Failure: " + host,
"Record Change: " + host,
}
if !slices.Equal(titles, want) {
t.Errorf("sent %v, want %v", titles, want)
}
}
// TestSkipRecordNotificationsCheck checks testHost, which is in
// SkipRecordNotifications, from a saved state in which every nameserver
// live DNS lists answered with an address live DNS never returns. No
// Record Change and no Inconsistency is sent, and the check still saves
// what the nameservers answer.
func TestSkipRecordNotificationsCheck(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.SkipRecordNotifications = []string{testHost}
nameservers := lookupNameservers(t, testHost)
_, deps := runChecks(t, cfg, func(deps *testDeps) {
byNameserver := make(map[string]*state.NameserverRecordState)
for _, ns := range nameservers {
byNameserver[ns] = answered(map[string][]string{"A": {oldIP}})
}
deps.state.SetHostnameState(testHost, saved(byNameserver))
})
for _, title := range []string{
"Record Change: " + testHost,
"Inconsistency: " + testHost,
} {
if n := countNotifications(deps, title); n != 0 {
t.Errorf("sent %d %q, want 0", n, title)
}
}
// A nameserver whose query for A failed keeps oldIP, so the check
// is that some address live DNS gave was saved.
hs, _ := deps.state.GetHostnameState(testHost)
fromLiveDNS := func(ip string) bool { return ip != oldIP }
if !slices.ContainsFunc(addresses(hs), fromLiveDNS) {
t.Errorf("saved addresses %v, want those live DNS gave", addresses(hs))
}
}
+28 -1
View File
@@ -289,6 +289,13 @@ 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,
@@ -323,9 +330,18 @@ 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).
@@ -714,12 +730,16 @@ func (w *Watcher) detectCNAMEAddressChanges(
// 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.
// out as well. Nothing is sent for a name in SkipRecordNotifications.
func (w *Watcher) detectRecordChanges(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
return
}
for ns, cur := range current.RecordsByNameserver {
prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusOK || cur.Status != statusOK {
@@ -819,11 +839,18 @@ func (w *Watcher) detectNSFailures(
}
}
// detectInconsistencies notifies each pair of nameservers that newly
// disagree (see newlyDisagreeingPairs). Nothing is sent for a name in
// SkipRecordNotifications.
func (w *Watcher) detectInconsistencies(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
if slices.Contains(w.config.SkipRecordNotifications, hostname) {
return
}
for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1]
state1 := current.RecordsByNameserver[ns1]
+113
View File
@@ -482,6 +482,119 @@ func TestNSChangeDetection(t *testing.T) {
}
}
// TestDomainThatDoesNotExist checks a .com domain that does not exist,
// with nameservers and records saved by an earlier check. The .com
// servers answer that it does not exist, so it is saved with nxdomain
// set and no nameservers, an NS Change removes them all, and its saved
// records are removed rather than asked for at the .com servers.
func TestDomainThatDoesNotExist(t *testing.T) {
t.Parallel()
const domain = "dnswatcher-test-does-not-exist.com"
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
deps.state.SetDomainState(domain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2},
})
deps.state.SetHostnameState(domain, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: {
Records: map[string][]string{"A": {oldIP}},
Status: "ok",
},
},
})
started := time.Now()
w.RunOnce(ctx)
// When no server answered, the domain's state is not saved.
ds, _ := deps.state.GetDomainState(domain)
if ds.LastChecked.Before(started) {
return fmt.Errorf("%s: %w", domain, livednstest.ErrNoAnswer)
}
return nil
})
ds, _ := deps.state.GetDomainState(domain)
if !ds.NXDomain || len(ds.Nameservers) != 0 {
t.Errorf("saved nxdomain %v and nameservers %v, want true and none",
ds.NXDomain, ds.Nameservers)
}
if hs, ok := deps.state.GetHostnameState(domain); ok {
t.Errorf("records saved for %s: %v", domain, hs.RecordsByNameserver)
}
assertNotified(t, deps, "NS Change: "+domain, "warning")
// That is the only notification, and it removes both nameservers,
// in either order.
for _, n := range deps.notifier.getNotifications() {
removed := strings.TrimPrefix(
n.Message, "Domain: "+domain+"\nAdded: \nRemoved: ",
)
if removed != oldNS1+", "+oldNS2 && removed != oldNS2+", "+oldNS1 {
t.Errorf("unexpected notification: %v", n)
}
}
}
// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation
// of its own: codeberg.page is on the public suffix list, so
// docs.codeberg.page is a domain, but the .page servers delegate only
// codeberg.page, whose servers answer for it. It is saved with no
// nameservers and without nxdomain, and its records, asked at the
// codeberg.page servers, are saved. Those are testSmallDomain's two
// nameservers; github.io, the zone of the README's example, has eight.
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
t.Parallel()
const domain = "docs.codeberg.page"
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
err := checkOnce(ctx, w, deps)
// A domain saved as not existing has no records to wait for;
// the checks below fail on it.
if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain {
return nil
}
return err
})
ds, _ := deps.state.GetDomainState(domain)
if ds.NXDomain || len(ds.Nameservers) != 0 {
t.Errorf("saved nxdomain %v and nameservers %v, want false and none",
ds.NXDomain, ds.Nameservers)
}
if _, ok := deps.state.GetHostnameState(domain); !ok {
t.Errorf("no records saved for %s", domain)
}
}
func TestNSAddressChangeDetection(t *testing.T) {
t.Parallel()