resolver, watcher: a domain's nameservers are only its own delegation (closes #222) #250
@@ -73,9 +73,21 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
to discover all authoritative nameservers (NS records) for each domain.
|
to discover all authoritative nameservers (NS records) for each domain.
|
||||||
- Queries **every** discovered authoritative nameserver independently.
|
- Queries **every** discovered authoritative nameserver independently.
|
||||||
- Stores the domain's NS record set, as its parent zone's servers delegate it,
|
- Stores the domain's NS record set, as its parent zone's servers delegate it,
|
||||||
and the IPv4 and IPv6 addresses each nameserver's name resolves to.
|
and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is
|
||||||
|
only ever the domain's own delegation. A domain whose parent zone's servers
|
||||||
|
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
|
||||||
|
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
|
||||||
|
delegation of its own, such as `octocat.github.io`, has no nameservers either.
|
||||||
|
When the parent zone's servers do not answer, the check fails and the set from
|
||||||
|
the previous check is kept.
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- NS added to or removed from that set.
|
- NS added to or removed from that set. A domain that had nameservers on the
|
||||||
|
previous check and no longer exists gets one with all of them removed.
|
||||||
|
After an upgrade, a domain with no delegation of its own, for which an
|
||||||
|
earlier version saved its parent zone's nameservers, also gets one with
|
||||||
|
all of them removed, on its first check. That one does not mean the domain
|
||||||
|
stopped existing: it is not shown as not existing, and its records are
|
||||||
|
still watched.
|
||||||
- NS address change: a nameserver that stays in the set resolves to
|
- NS address change: a nameserver that stays in the set resolves to
|
||||||
different addresses than on the previous check. A nameserver added or
|
different addresses than on the previous check. A nameserver added or
|
||||||
removed gets only the NS change notification. When the lookup of a
|
removed gets only the NS change notification. When the lookup of a
|
||||||
@@ -87,7 +99,10 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
records, stored per nameserver. Their changes are notified as a hostname's
|
records, stored per nameserver. Their changes are notified as a hostname's
|
||||||
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
||||||
address change, in a message that starts `Domain:` where a hostname's starts
|
address change, in a message that starts `Domain:` where a hostname's starts
|
||||||
`Hostname:`.
|
`Hostname:`. A domain with no delegation of its own has these records asked at
|
||||||
|
the servers of the zone it is in, as a hostname has. A domain that does not
|
||||||
|
exist has none: they are not asked for, and those saved by an earlier check
|
||||||
|
are removed without a notification.
|
||||||
|
|
||||||
### DNS Hostname Monitoring (Subdomains)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
@@ -95,7 +110,11 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
via the Public Suffix List).
|
via the Public Suffix List).
|
||||||
- Every **1 hour** by default, performs a full iterative trace to discover the
|
- Every **1 hour** by default, performs a full iterative trace to discover the
|
||||||
authoritative nameservers of the zone the hostname is in, which is not always
|
authoritative nameservers of the zone the hostname is in, which is not always
|
||||||
its last two labels (a name under `co.uk`, or in a delegated subdomain).
|
its last two labels (a name under `co.uk`, or in a delegated subdomain). The
|
||||||
|
trace moves from a name to its parent only when the servers asked answer that
|
||||||
|
the name has no delegation of its own, or does not exist. When they do not
|
||||||
|
answer, the check fails and the hostname's records from the previous check are
|
||||||
|
kept.
|
||||||
- Queries **each** authoritative nameserver independently for **all** record
|
- Queries **each** authoritative nameserver independently for **all** record
|
||||||
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||||
- Each record type is a query of its own. When a nameserver answers some types
|
- Each record type is a query of its own. When a nameserver answers some types
|
||||||
@@ -260,8 +279,9 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
|
|||||||
(`/`). It displays:
|
(`/`). It displays:
|
||||||
|
|
||||||
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
- **Summary counts** for monitored domains, hostnames, ports, and certificates.
|
||||||
- **Domains** with their discovered nameservers, and each domain's own records
|
- **Domains** with their discovered nameservers, or "does not exist" for a
|
||||||
per nameserver and status, shown as a hostname's are.
|
domain whose parent zone's servers answered NXDOMAIN, and each domain's own
|
||||||
|
records per nameserver and status, shown as a hostname's are.
|
||||||
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
||||||
whose query failed, the reason is shown in place of the records.
|
whose query failed, the reason is shown in place of the records.
|
||||||
- **Ports** with open/closed state and the domains and hostnames that resolve to
|
- **Ports** with open/closed state and the domains and hostnames that resolve to
|
||||||
@@ -298,9 +318,11 @@ In `/api/v1/status`, each nameserver entry and certificate entry whose `status`
|
|||||||
is `error` also has `error`, the reason, as in the state file (see State File
|
is `error` also has `error`, the reason, as in the state file (see State File
|
||||||
Format). A domain's own records are in its entry in `domains`, under
|
Format). A domain's own records are in its entry in `domains`, under
|
||||||
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
|
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
|
||||||
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port
|
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A domain
|
||||||
entry lists the domains that resolve to its address in `domains`, and the
|
entry's `nxdomain` is `true` when the domain's parent zone's servers answered
|
||||||
hostnames in `hostnames`.
|
NXDOMAIN, that it does not exist; its `nameservers` and `recordsByNameserver`
|
||||||
|
are then empty. A port entry lists the domains that resolve to its address in
|
||||||
|
`domains`, and the hostnames in `hostnames`.
|
||||||
|
|
||||||
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
|
`/metrics` is served only when `DNSWATCHER_METRICS_USERNAME` is set, behind
|
||||||
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
|
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
|
||||||
@@ -588,6 +610,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
|
resolves to. A state file without it loads, and the next check fills it in
|
||||||
without a notification.
|
without a notification.
|
||||||
|
|
||||||
|
A domain entry has `"nxdomain": true` when the domain's parent zone's servers
|
||||||
|
answered NXDOMAIN, that it does not exist. Its `nameservers` and
|
||||||
|
`nameserverAddresses` are then empty, and `hostnames` holds no entry for it.
|
||||||
|
`nxdomain` is left out when false.
|
||||||
|
|
||||||
`cnameAddresses` lists the sorted addresses at the end of the chain of every
|
`cnameAddresses` lists the sorted addresses at the end of the chain of every
|
||||||
CNAME target a hostname's nameservers gave, found when they answered with a
|
CNAME target a hostname's nameservers gave, found when they answered with a
|
||||||
CNAME and no address; it is empty when they answered with an address. When a
|
CNAME and no address; it is empty when they answered with an address. When a
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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
|
- 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).
|
resolvers in turn until one replies, not eight to one operator (closes #251).
|
||||||
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
|
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
|
||||||
|
|||||||
@@ -191,21 +191,39 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
|
|||||||
|
|
||||||
words := strings.Join(strings.Fields(page), " ")
|
words := strings.Join(strings.Fields(page), " ")
|
||||||
|
|
||||||
footer := "monitoring 1 domains + 1 hostnames"
|
footer := "monitoring 2 domains + 1 hostnames"
|
||||||
if !strings.Contains(words, footer) {
|
if !strings.Contains(words, footer) {
|
||||||
t.Errorf("dashboard does not say %q", footer)
|
t.Errorf("dashboard does not say %q", footer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With the tags taken out, the summary bar starts "Domains 1
|
// With the tags taken out, the summary bar starts "Domains 2
|
||||||
// Hostnames 1".
|
// Hostnames 1".
|
||||||
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
|
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
|
||||||
summary := "Domains 1 Hostnames 1"
|
summary := "Domains 2 Hostnames 1"
|
||||||
|
|
||||||
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
|
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
|
||||||
t.Errorf("summary bar does not say %q", summary)
|
t.Errorf("summary bar does not say %q", summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDashboardMarksDomainThatDoesNotExist checks that the Domains
|
||||||
|
// section says a domain that does not exist does not exist, and does
|
||||||
|
// not say so of a domain that exists.
|
||||||
|
func TestDashboardMarksDomainThatDoesNotExist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||||
|
domains := dashboardSection(t, page, "Domains")
|
||||||
|
|
||||||
|
if !strings.Contains(dashboardRow(t, domains, missingDomain), "does not exist") {
|
||||||
|
t.Errorf("row of %s does not say it does not exist", missingDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(dashboardRow(t, domains, testDomain), "does not exist") {
|
||||||
|
t.Errorf("row of %s says it does not exist", testDomain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// rowCells returns the text of each cell of a dashboard table row
|
// rowCells returns the text of each cell of a dashboard table row
|
||||||
// whose cells start with tag, "<th" or "<td".
|
// whose cells start with tag, "<th" or "<td".
|
||||||
func rowCells(row string, tag string) []string {
|
func rowCells(row string, tag string) []string {
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import (
|
|||||||
|
|
||||||
// statusDomainInfo holds status information for a monitored domain.
|
// statusDomainInfo holds status information for a monitored domain.
|
||||||
// RecordsByNameserver holds the domain's own records, in the form a
|
// RecordsByNameserver holds the domain's own records, in the form a
|
||||||
// hostname's Nameservers holds the hostname's.
|
// hostname's Nameservers holds the hostname's. NXDomain is true when
|
||||||
|
// the domain's parent zone's servers answered that it does not exist.
|
||||||
type statusDomainInfo struct {
|
type statusDomainInfo struct {
|
||||||
Nameservers []string `json:"nameservers"`
|
Nameservers []string `json:"nameservers"`
|
||||||
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
|
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
|
||||||
|
NXDomain bool `json:"nxdomain"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,7 @@ func buildDomains(
|
|||||||
resp.Domains[name] = &statusDomainInfo{
|
resp.Domains[name] = &statusDomainInfo{
|
||||||
Nameservers: ns,
|
Nameservers: ns,
|
||||||
RecordsByNameserver: records,
|
RecordsByNameserver: records,
|
||||||
|
NXDomain: ds.NXDomain,
|
||||||
LastChecked: ds.LastChecked,
|
LastChecked: ds.LastChecked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import (
|
|||||||
// failed. example.net is an apex domain, whose own records are saved
|
// failed. example.net is an apex domain, whose own records are saved
|
||||||
// with the hostnames' records, as the watcher saves them. Both names
|
// with the hostnames' records, as the watcher saves them. Both names
|
||||||
// resolve to domainAddress, whose port 443 entry lists them.
|
// resolve to domainAddress, whose port 443 entry lists them.
|
||||||
|
// missingDomain is an apex domain whose parent zone's servers answered
|
||||||
|
// that it does not exist, saved with no nameservers and no records.
|
||||||
const (
|
const (
|
||||||
|
missingDomain = "does-not-exist.example"
|
||||||
testHostname = "www.example.com"
|
testHostname = "www.example.com"
|
||||||
answeringNS = "ns1.example.com."
|
answeringNS = "ns1.example.com."
|
||||||
failedNS = "ns2.example.com."
|
failedNS = "ns2.example.com."
|
||||||
@@ -132,6 +135,12 @@ func setTestState(st *state.State) {
|
|||||||
Hostnames: []string{testDomain, testHostname},
|
Hostnames: []string{testDomain, testHostname},
|
||||||
LastChecked: now,
|
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.
|
// 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
|
// TestStatusPortsTellDomainsFromHostnames checks that a port entry in
|
||||||
// /api/v1/status lists an apex domain in domains and a hostname in
|
// /api/v1/status lists an apex domain in domains and a hostname in
|
||||||
// hostnames when both resolve to its address.
|
// hostnames when both resolve to its address.
|
||||||
|
|||||||
@@ -84,7 +84,11 @@
|
|||||||
{{ $name }}
|
{{ $name }}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-3 text-slate-400 break-all">
|
<td class="py-2 px-3 text-slate-400 break-all">
|
||||||
|
{{ if $ds.NXDomain }}
|
||||||
|
<span class="text-red-400">does not exist</span>
|
||||||
|
{{ else }}
|
||||||
{{ joinStrings $ds.Nameservers ", " }}
|
{{ joinStrings $ds.Nameservers ", " }}
|
||||||
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||||
{{ relTime $ds.LastChecked }}
|
{{ relTime $ds.LastChecked }}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ var (
|
|||||||
"no authoritative nameservers found",
|
"no authoritative nameservers found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNXDomain is returned when the servers of the zone a domain
|
||||||
|
// is in answer NXDOMAIN: the domain does not exist.
|
||||||
|
ErrNXDomain = errors.New("domain does not exist")
|
||||||
|
|
||||||
// ErrNoNameserverAnswered is returned when every nameserver
|
// ErrNoNameserverAnswered is returned when every nameserver
|
||||||
// asked about a name timed out, failed or returned a referral,
|
// asked about a name timed out, failed or returned a referral,
|
||||||
// so whether the name has addresses is unknown.
|
// so whether the name has addresses is unknown.
|
||||||
|
|||||||
@@ -17,6 +17,43 @@ func NewWithFailingTCP(log *slog.Logger) *Resolver {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWithQueryTimeout returns a Resolver whose queries over UDP give up
|
||||||
|
// after timeout, so a test that asks an address where nothing answers
|
||||||
|
// does not wait out the usual timeout.
|
||||||
|
func NewWithQueryTimeout(log *slog.Logger, timeout time.Duration) *Resolver {
|
||||||
|
r := NewFromLogger(log)
|
||||||
|
r.client = &udpClient{timeout: timeout}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// FollowDelegation exports followDelegation for testing.
|
||||||
|
func (r *Resolver) FollowDelegation(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
servers []string,
|
||||||
|
) ([]string, error) {
|
||||||
|
return r.followDelegation(ctx, domain, servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindAuthoritativeNameserversFrom exports findAuthoritativeNameservers
|
||||||
|
// for testing.
|
||||||
|
func (r *Resolver) FindAuthoritativeNameserversFrom(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
servers []string,
|
||||||
|
) ([]string, error) {
|
||||||
|
return r.findAuthoritativeNameservers(ctx, domain, servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveNSIterative exports resolveNSIterative for testing.
|
||||||
|
func (r *Resolver) ResolveNSIterative(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
) ([]string, error) {
|
||||||
|
return r.resolveNSIterative(ctx, domain)
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractRecordValue exports extractRecordValue for testing.
|
// ExtractRecordValue exports extractRecordValue for testing.
|
||||||
func ExtractRecordValue(rr dns.RR) string {
|
func ExtractRecordValue(rr dns.RR) string {
|
||||||
return extractRecordValue(rr)
|
return extractRecordValue(rr)
|
||||||
|
|||||||
@@ -204,6 +204,12 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
|||||||
return ips
|
return ips
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// followDelegation follows referrals from servers, the root servers, to
|
||||||
|
// domain and returns the NS set of domain's delegation. When the servers
|
||||||
|
// of the zone domain is in answer that domain does not exist, the error
|
||||||
|
// is ErrNXDomain. When they answer that it has no delegation of its own,
|
||||||
|
// because it is not the zone's apex, the set is empty and there is no
|
||||||
|
// error. Any other error means that no such answer came.
|
||||||
func (r *Resolver) followDelegation(
|
func (r *Resolver) followDelegation(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
@@ -234,10 +240,15 @@ func (r *Resolver) followDelegation(
|
|||||||
// An authoritative reply comes from the servers of the zone
|
// An authoritative reply comes from the servers of the zone
|
||||||
// domain is in; it is not a referral, even when its authority
|
// domain is in; it is not a referral, even when its authority
|
||||||
// section lists that zone's NS records. Without NS records in
|
// section lists that zone's NS records. Without NS records in
|
||||||
// the answer, domain is not the zone's apex and has no
|
// the answer, domain has no nameservers of its own: it does
|
||||||
// nameservers of its own.
|
// not exist, when the reply is NXDOMAIN, or else it is not the
|
||||||
|
// zone's apex.
|
||||||
|
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
|
||||||
|
return nil, ErrNXDomain
|
||||||
|
}
|
||||||
|
|
||||||
if resp.Authoritative {
|
if resp.Authoritative {
|
||||||
return nil, ErrNoNameservers
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
authNS := extractNSSet(resp.Ns)
|
authNS := extractNSSet(resp.Ns)
|
||||||
@@ -486,7 +497,8 @@ func (r *Resolver) resolveNSIPs(
|
|||||||
|
|
||||||
// resolveNSIterative queries for NS records using iterative
|
// resolveNSIterative queries for NS records using iterative
|
||||||
// resolution as a fallback when followDelegation finds no
|
// resolution as a fallback when followDelegation finds no
|
||||||
// authoritative answer in the delegation chain.
|
// authoritative answer in the delegation chain. Its result means what
|
||||||
|
// followDelegation's does.
|
||||||
func (r *Resolver) resolveNSIterative(
|
func (r *Resolver) resolveNSIterative(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
@@ -516,6 +528,16 @@ func (r *Resolver) resolveNSIterative(
|
|||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// As in followDelegation: domain has no nameservers of its
|
||||||
|
// own.
|
||||||
|
if resp.Authoritative && resp.Rcode == dns.RcodeNameError {
|
||||||
|
return nil, ErrNXDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Authoritative {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Follow delegation.
|
// Follow delegation.
|
||||||
authNS := extractNSSet(resp.Ns)
|
authNS := extractNSSet(resp.Ns)
|
||||||
if len(authNS) == 0 {
|
if len(authNS) == 0 {
|
||||||
@@ -601,12 +623,23 @@ func (r *Resolver) resolveARecord(
|
|||||||
// FindAuthoritativeNameservers traces the delegation chain from
|
// FindAuthoritativeNameservers traces the delegation chain from
|
||||||
// root servers to discover all authoritative nameservers for the
|
// root servers to discover all authoritative nameservers for the
|
||||||
// given domain, as the delegation from its parent zone's servers lists
|
// given domain, as the delegation from its parent zone's servers lists
|
||||||
// them. For a name that is not a zone apex it tries each
|
// them. When the servers asked answer that the name has no delegation
|
||||||
// parent name in turn, so it returns the nameservers of the zone the
|
// of its own, or does not exist, it tries each parent name in turn, so
|
||||||
// name is in.
|
// it returns the nameservers of the zone the name is in. When they do
|
||||||
|
// not answer, it returns the error and tries no parent name.
|
||||||
func (r *Resolver) FindAuthoritativeNameservers(
|
func (r *Resolver) FindAuthoritativeNameservers(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
|
) ([]string, error) {
|
||||||
|
return r.findAuthoritativeNameservers(ctx, domain, rootServerList())
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAuthoritativeNameservers is FindAuthoritativeNameservers with each
|
||||||
|
// walk starting at servers, the root servers.
|
||||||
|
func (r *Resolver) findAuthoritativeNameservers(
|
||||||
|
ctx context.Context,
|
||||||
|
domain string,
|
||||||
|
servers []string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
if checkCtx(ctx) != nil {
|
if checkCtx(ctx) != nil {
|
||||||
return nil, ErrContextCanceled
|
return nil, ErrContextCanceled
|
||||||
@@ -622,19 +655,16 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
|||||||
|
|
||||||
candidate := strings.Join(labels[i:], ".") + "."
|
candidate := strings.Join(labels[i:], ".") + "."
|
||||||
|
|
||||||
nsNames, err := r.followDelegation(
|
nsNames, err := r.followDelegation(ctx, candidate, servers)
|
||||||
ctx, candidate, rootServerList(),
|
if err != nil && !errors.Is(err, ErrNXDomain) {
|
||||||
)
|
return nil, err
|
||||||
if err == nil && len(nsNames) > 0 {
|
}
|
||||||
|
|
||||||
|
if len(nsNames) > 0 {
|
||||||
sort.Strings(nsNames)
|
sort.Strings(nsNames)
|
||||||
|
|
||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// The root servers would refuse every parent name too.
|
|
||||||
if errors.Is(err, ErrIntercepted) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, ErrNoNameservers
|
return nil, ErrNoNameservers
|
||||||
@@ -852,9 +882,7 @@ func readReply(
|
|||||||
// A reply with no answer that lists other nameservers, from a server
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
// that does not hold the name's zone, is a referral and says nothing
|
// that does not hold the name's zone, is a referral and says nothing
|
||||||
// about the name's records. A server named in the delegation that
|
// about the name's records. A server named in the delegation that
|
||||||
// does not hold the zone may send one, as do a parent zone's servers
|
// does not hold the zone may send one.
|
||||||
// when FindAuthoritativeNameservers found no delegation for the
|
|
||||||
// name's zone and moved on to a parent name.
|
|
||||||
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||||
len(extractNSSet(msg.Ns)) > 0 {
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
state.gotReferral = true
|
state.gotReferral = true
|
||||||
@@ -1022,12 +1050,22 @@ func (r *Resolver) queryEachNS(
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupNS returns the NS record set for a domain.
|
// LookupNS returns the NS record set of a domain, as the delegation from
|
||||||
|
// its parent zone's servers lists it, and never a parent name's. When
|
||||||
|
// they answer that the domain does not exist, the error is ErrNXDomain.
|
||||||
|
// When they answer that it has no delegation of its own, the set is
|
||||||
|
// empty and there is no error.
|
||||||
func (r *Resolver) LookupNS(
|
func (r *Resolver) LookupNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
) ([]string, error) {
|
) ([]string, error) {
|
||||||
return r.FindAuthoritativeNameservers(ctx, domain)
|
if checkCtx(ctx) != nil {
|
||||||
|
return nil, ErrContextCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.followDelegation(
|
||||||
|
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupAllRecords performs iterative resolution to find all DNS
|
// LookupAllRecords performs iterative resolution to find all DNS
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
|
// liveLookupNS looks up the NS record set of domain, a domain that has
|
||||||
// point, so that both entry points stay independently exercised.
|
// one, retrying until the delegation chain can be walked.
|
||||||
func liveLookupNS(
|
func liveLookupNS(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
r *resolver.Resolver,
|
r *resolver.Resolver,
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import (
|
|||||||
// Test helpers
|
// Test helpers
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// nonexistentDomain is a .com domain that does not exist.
|
||||||
|
const nonexistentDomain = "dnswatcher-test-does-not-exist.com"
|
||||||
|
|
||||||
|
// noAnswerAddress is 192.0.2.1, a documentation address: nothing
|
||||||
|
// answers there.
|
||||||
|
const noAnswerAddress = "192.0.2.1"
|
||||||
|
|
||||||
func newTestResolver(t *testing.T) *resolver.Resolver {
|
func newTestResolver(t *testing.T) *resolver.Resolver {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -88,6 +95,47 @@ func TestFindAuthoritativeNameservers_Subdomain(
|
|||||||
assert.Equal(t, fromZone, fromHost)
|
assert.Equal(t, fromZone, fromHost)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
|
||||||
|
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
|
||||||
|
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
|
||||||
|
// that the name has no delegation of its own, so it gets their names,
|
||||||
|
// not those of the cmu.edu servers. Every referral on the way gives the
|
||||||
|
// nameservers' addresses, so the walk sends few queries.
|
||||||
|
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
|
||||||
|
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
|
||||||
|
fromParent := liveLookupNS(t, r, "cmu.edu")
|
||||||
|
|
||||||
|
assert.Equal(t, fromZone, fromHost)
|
||||||
|
assert.NotEqual(t, fromParent, fromHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindAuthoritativeNameservers_NoAnswer starts each walk for
|
||||||
|
// www.google.com at 192.0.2.1, a documentation address where nothing
|
||||||
|
// answers. A walk that got no answer does not say that the name has no
|
||||||
|
// delegation of its own, so the lookup returns that walk's error, about
|
||||||
|
// www.google.com, and tries no parent name: trying google.com and com
|
||||||
|
// would end in ErrNoNameservers, or in the error of a walk for one of
|
||||||
|
// them.
|
||||||
|
func TestFindAuthoritativeNameservers_NoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||||
|
|
||||||
|
nameservers, err := r.FindAuthoritativeNameserversFrom(
|
||||||
|
t.Context(), "www.google.com", []string{noAnswerAddress},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotErrorIs(t, err, resolver.ErrNoNameservers)
|
||||||
|
assert.Contains(t, err.Error(), "query www.google.com. @"+noAnswerAddress)
|
||||||
|
assert.Empty(t, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -828,8 +876,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
|
|||||||
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
|
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
|
||||||
// ntpns.org, without the addresses of its nameservers, so the walk has
|
// ntpns.org, without the addresses of its nameservers, so the walk has
|
||||||
// to look them up to ask them. If it did not, the walk for g.ntpns.org
|
// to look them up to ask them. If it did not, the walk for g.ntpns.org
|
||||||
// would fail and LookupNS would return the nameservers of ntpns.org,
|
// would fail.
|
||||||
// which a.ntpns.org is not one of.
|
|
||||||
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -839,6 +886,131 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
|||||||
assert.Contains(t, nameservers, "a.ntpns.org.")
|
assert.Contains(t, nameservers, "a.ntpns.org.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com
|
||||||
|
// domain that does not exist. The .com servers answer NXDOMAIN, so the
|
||||||
|
// error is ErrNXDomain, and the domain does not get their names.
|
||||||
|
func TestLookupNS_DomainThatDoesNotExist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var (
|
||||||
|
nameservers []string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"LookupNS("+nonexistentDomain+")",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
nameservers, err = r.LookupNS(ctx, nonexistentDomain)
|
||||||
|
if errors.Is(err, resolver.ErrNXDomain) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNXDomain)
|
||||||
|
assert.Empty(t, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLookupNS_NoDelegationOfItsOwn looks up the nameservers of
|
||||||
|
// www.google.com, a name in the google.com zone with no delegation of
|
||||||
|
// its own, as a domain such as octocat.github.io is. The google.com
|
||||||
|
// servers answer with no NS records for it: the set is empty, and it is
|
||||||
|
// not ErrNXDomain.
|
||||||
|
func TestLookupNS_NoDelegationOfItsOwn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var nameservers []string
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"LookupNS(www.google.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
nameservers, err = r.LookupNS(ctx, "www.google.com")
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFollowDelegation_NoAnswer starts the walk LookupNS uses, for
|
||||||
|
// google.com, at 192.0.2.1, a documentation address where nothing
|
||||||
|
// answers. A walk that got no answer is an error, not an empty set,
|
||||||
|
// which the watcher would report as an NS Change with every nameserver
|
||||||
|
// removed.
|
||||||
|
func TestFollowDelegation_NoAnswer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := resolver.NewWithQueryTimeout(slog.Default(), 100*time.Millisecond)
|
||||||
|
|
||||||
|
nameservers, err := r.FollowDelegation(
|
||||||
|
t.Context(), "google.com.", []string{noAnswerAddress},
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Empty(t, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveNSIterative_NoDelegationOfItsOwn walks to the nameservers
|
||||||
|
// of www.google.com as the fallback walk does. As in
|
||||||
|
// TestLookupNS_NoDelegationOfItsOwn, the set is empty, with no error.
|
||||||
|
func TestResolveNSIterative_NoDelegationOfItsOwn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var nameservers []string
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"ResolveNSIterative(www.google.com)",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
nameservers, err = r.ResolveNSIterative(ctx, "www.google.com")
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveNSIterative_DomainThatDoesNotExist walks to the nameservers
|
||||||
|
// of a .com domain that does not exist as the fallback walk does. As in
|
||||||
|
// TestLookupNS_DomainThatDoesNotExist, the error is ErrNXDomain.
|
||||||
|
func TestResolveNSIterative_DomainThatDoesNotExist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newTestResolver(t)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
|
||||||
|
livednstest.Retry(
|
||||||
|
t,
|
||||||
|
"ResolveNSIterative("+nonexistentDomain+")",
|
||||||
|
func(ctx context.Context) error {
|
||||||
|
_, err = r.ResolveNSIterative(ctx, nonexistentDomain)
|
||||||
|
if errors.Is(err, resolver.ErrNXDomain) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, resolver.ErrNXDomain)
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// ResolveIPAddresses tests
|
// ResolveIPAddresses tests
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ type Params struct {
|
|||||||
// DomainState holds the monitoring state for an apex domain.
|
// DomainState holds the monitoring state for an apex domain.
|
||||||
// NameserverAddresses holds the sorted addresses each nameserver's name
|
// NameserverAddresses holds the sorted addresses each nameserver's name
|
||||||
// resolves to, by nameserver name. A state file written before it
|
// resolves to, by nameserver name. A state file written before it
|
||||||
// existed loads with it nil.
|
// existed loads with it nil. NXDomain is true when the domain's parent
|
||||||
|
// zone's servers answered that it does not exist; it then has no
|
||||||
|
// nameservers.
|
||||||
type DomainState struct {
|
type DomainState struct {
|
||||||
Nameservers []string `json:"nameservers"`
|
Nameservers []string `json:"nameservers"`
|
||||||
NameserverAddresses map[string][]string `json:"nameserverAddresses"`
|
NameserverAddresses map[string][]string `json:"nameserverAddresses"`
|
||||||
|
NXDomain bool `json:"nxdomain,omitempty"`
|
||||||
LastChecked time.Time `json:"lastChecked"`
|
LastChecked time.Time `json:"lastChecked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import (
|
|||||||
|
|
||||||
// DNSResolver performs iterative DNS resolution.
|
// DNSResolver performs iterative DNS resolution.
|
||||||
type DNSResolver interface {
|
type DNSResolver interface {
|
||||||
// LookupNS discovers authoritative nameservers for a domain.
|
// LookupNS returns a domain's NS record set, as its parent zone's
|
||||||
|
// servers delegate it: empty when they answer that it has none, and
|
||||||
|
// resolver.ErrNXDomain when they answer that it does not exist.
|
||||||
LookupNS(
|
LookupNS(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
domain string,
|
domain string,
|
||||||
|
|||||||
@@ -289,6 +289,13 @@ func (w *Watcher) checkDomain(
|
|||||||
domain string,
|
domain string,
|
||||||
) {
|
) {
|
||||||
nameservers, err := w.resolver.LookupNS(ctx, domain)
|
nameservers, err := w.resolver.LookupNS(ctx, domain)
|
||||||
|
|
||||||
|
// A domain that does not exist has no nameservers.
|
||||||
|
nxdomain := errors.Is(err, resolver.ErrNXDomain)
|
||||||
|
if nxdomain {
|
||||||
|
nameservers, err = []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logFailedLookup(
|
w.logFailedLookup(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -323,9 +330,18 @@ func (w *Watcher) checkDomain(
|
|||||||
w.state.SetDomainState(domain, &state.DomainState{
|
w.state.SetDomainState(domain, &state.DomainState{
|
||||||
Nameservers: nameservers,
|
Nameservers: nameservers,
|
||||||
NameserverAddresses: addresses,
|
NameserverAddresses: addresses,
|
||||||
|
NXDomain: nxdomain,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A domain that does not exist has no records of its own: none are
|
||||||
|
// asked for, and those saved by an earlier check are removed.
|
||||||
|
if nxdomain {
|
||||||
|
w.state.DeleteHostnameState(domain)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// The apex domain's records are also checked and saved as a
|
// The apex domain's records are also checked and saved as a
|
||||||
// hostname's, so that the port and TLS checks find its addresses.
|
// hostname's, so that the port and TLS checks find its addresses.
|
||||||
// Notifications about them name it as a domain (see nameLine).
|
// Notifications about them name it as a domain (see nameLine).
|
||||||
|
|||||||
@@ -482,6 +482,119 @@ func TestNSChangeDetection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDomainThatDoesNotExist checks a .com domain that does not exist,
|
||||||
|
// with nameservers and records saved by an earlier check. The .com
|
||||||
|
// servers answer that it does not exist, so it is saved with nxdomain
|
||||||
|
// set and no nameservers, an NS Change removes them all, and its saved
|
||||||
|
// records are removed rather than asked for at the .com servers.
|
||||||
|
func TestDomainThatDoesNotExist(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const domain = "dnswatcher-test-does-not-exist.com"
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{domain}
|
||||||
|
|
||||||
|
var deps *testDeps
|
||||||
|
|
||||||
|
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
||||||
|
var w *watcher.Watcher
|
||||||
|
|
||||||
|
w, deps = newTestWatcher(t, cfg)
|
||||||
|
|
||||||
|
deps.state.SetDomainState(domain, &state.DomainState{
|
||||||
|
Nameservers: []string{oldNS1, oldNS2},
|
||||||
|
})
|
||||||
|
deps.state.SetHostnameState(domain, &state.HostnameState{
|
||||||
|
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||||
|
oldNS1: {
|
||||||
|
Records: map[string][]string{"A": {oldIP}},
|
||||||
|
Status: "ok",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
started := time.Now()
|
||||||
|
|
||||||
|
w.RunOnce(ctx)
|
||||||
|
|
||||||
|
// When no server answered, the domain's state is not saved.
|
||||||
|
ds, _ := deps.state.GetDomainState(domain)
|
||||||
|
if ds.LastChecked.Before(started) {
|
||||||
|
return fmt.Errorf("%s: %w", domain, livednstest.ErrNoAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
ds, _ := deps.state.GetDomainState(domain)
|
||||||
|
if !ds.NXDomain || len(ds.Nameservers) != 0 {
|
||||||
|
t.Errorf("saved nxdomain %v and nameservers %v, want true and none",
|
||||||
|
ds.NXDomain, ds.Nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hs, ok := deps.state.GetHostnameState(domain); ok {
|
||||||
|
t.Errorf("records saved for %s: %v", domain, hs.RecordsByNameserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotified(t, deps, "NS Change: "+domain, "warning")
|
||||||
|
|
||||||
|
// That is the only notification, and it removes both nameservers,
|
||||||
|
// in either order.
|
||||||
|
for _, n := range deps.notifier.getNotifications() {
|
||||||
|
removed := strings.TrimPrefix(
|
||||||
|
n.Message, "Domain: "+domain+"\nAdded: \nRemoved: ",
|
||||||
|
)
|
||||||
|
if removed != oldNS1+", "+oldNS2 && removed != oldNS2+", "+oldNS1 {
|
||||||
|
t.Errorf("unexpected notification: %v", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDomainWithNoDelegationOfItsOwn checks a domain with no delegation
|
||||||
|
// of its own: codeberg.page is on the public suffix list, so
|
||||||
|
// docs.codeberg.page is a domain, but the .page servers delegate only
|
||||||
|
// codeberg.page, whose servers answer for it. It is saved with no
|
||||||
|
// nameservers and without nxdomain, and its records, asked at the
|
||||||
|
// codeberg.page servers, are saved. Those are testSmallDomain's two
|
||||||
|
// nameservers; github.io, the zone of the README's example, has eight.
|
||||||
|
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const domain = "docs.codeberg.page"
|
||||||
|
|
||||||
|
cfg := defaultTestConfig(t)
|
||||||
|
cfg.Domains = []string{domain}
|
||||||
|
|
||||||
|
var deps *testDeps
|
||||||
|
|
||||||
|
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
||||||
|
var w *watcher.Watcher
|
||||||
|
|
||||||
|
w, deps = newTestWatcher(t, cfg)
|
||||||
|
|
||||||
|
err := checkOnce(ctx, w, deps)
|
||||||
|
|
||||||
|
// A domain saved as not existing has no records to wait for;
|
||||||
|
// the checks below fail on it.
|
||||||
|
if ds, ok := deps.state.GetDomainState(domain); ok && ds.NXDomain {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
|
||||||
|
ds, _ := deps.state.GetDomainState(domain)
|
||||||
|
if ds.NXDomain || len(ds.Nameservers) != 0 {
|
||||||
|
t.Errorf("saved nxdomain %v and nameservers %v, want false and none",
|
||||||
|
ds.NXDomain, ds.Nameservers)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := deps.state.GetHostnameState(domain); !ok {
|
||||||
|
t.Errorf("no records saved for %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNSAddressChangeDetection(t *testing.T) {
|
func TestNSAddressChangeDetection(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user