Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9cac1a471 | ||
|
|
008ec5d13a | ||
|
|
e46db71821 | ||
|
|
1218df9467 |
@@ -82,6 +82,12 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
nameserver's addresses fails or finds none, its previous addresses are
|
||||
kept and nothing is sent. The lookup fails when no nameserver it asks
|
||||
answers every one of its queries, for A, AAAA and CNAME.
|
||||
- Also watches the domain's own records as a hostname's are watched (see DNS
|
||||
Hostname Monitoring below): its A, AAAA, CNAME, MX, TXT, SRV, CAA and NS
|
||||
records, stored per nameserver. Their changes are notified as a hostname's
|
||||
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
||||
address change, in a message that starts `Domain:` where a hostname's starts
|
||||
`Hostname:`.
|
||||
|
||||
### DNS Hostname Monitoring (Subdomains)
|
||||
|
||||
@@ -198,18 +204,18 @@ Supported notification backends:
|
||||
All configured endpoints receive every notification. Notification content
|
||||
includes:
|
||||
|
||||
- **DNS record changes**: Which hostname, which nameserver, what record type,
|
||||
old values, new values.
|
||||
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
||||
record type, old values, new values.
|
||||
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||
addresses.
|
||||
- **CNAME address changes**: Which hostname, the old and new addresses at the
|
||||
end of its CNAME chain.
|
||||
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
||||
at the end of its CNAME chain.
|
||||
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
|
||||
REFUSED, network error), which hostname/domain affected.
|
||||
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
|
||||
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
|
||||
which hostname affected.
|
||||
which hostname or domain affected.
|
||||
- **Port changes**: Which IP:port, its new state, all associated hostnames.
|
||||
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
|
||||
hostname and IP.
|
||||
@@ -244,7 +250,8 @@ 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.
|
||||
- **Domains** with their discovered nameservers, and each domain's own records
|
||||
per nameserver and status, shown as a hostname's are.
|
||||
- **Hostnames** with per-nameserver DNS records and status. For a nameserver
|
||||
whose query failed, the reason is shown in place of the records.
|
||||
- **Ports** with open/closed state and associated hostnames.
|
||||
@@ -278,7 +285,9 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
`/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,
|
||||
@@ -447,7 +456,12 @@ anew each time, so no one root server gets every first query. A server that does
|
||||
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
|
||||
referral that leads no closer to the name is passed over for the next one. When
|
||||
a referral names a zone's nameservers without their addresses, the addresses of
|
||||
all of them are looked up, so that each can be asked.
|
||||
all of them are looked up, so that each can be asked. When it gives addresses
|
||||
for only some of them, those are asked first, and the others are looked up and
|
||||
asked only if none of those gives a usable reply. Both hold in the walk to a
|
||||
name's nameservers and in the lookup of a nameserver's own address. Such a
|
||||
lookup can need others in turn; lookups go at most three deep, one inside
|
||||
another, so delegations that point at each other still end.
|
||||
|
||||
This approach ensures:
|
||||
|
||||
@@ -470,7 +484,8 @@ resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
|
||||
|
||||
The state file (`DATA_DIR/state.json`) contains the complete monitoring
|
||||
snapshot. Hostname records are stored **per authoritative nameserver**, not as a
|
||||
merged view, to enable inconsistency detection.
|
||||
merged view, to enable inconsistency detection. `hostnames` also holds each
|
||||
domain's own records, under the domain's name.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -21,6 +21,12 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous
|
||||
records and alerts nothing; the other types are still saved (closes #231).
|
||||
- 2026-10-02: nameservers a referral names without addresses are looked up,
|
||||
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221).
|
||||
- 2026-10-02: an apex domain is not counted or listed as a hostname; its records
|
||||
show under Domains, and notifications about them say `Domain:` (closes #224).
|
||||
- 2026-10-02: the dashboard lists each nameserver's record types in one fixed
|
||||
order, the README's, then any other type, not a random one (closes #226).
|
||||
- 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or
|
||||
a certificate check failed, which only the state file showed (closes #225).
|
||||
- 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"maps"
|
||||
"math"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -40,9 +43,13 @@ func newDashboardTemplate() *template.Template {
|
||||
)
|
||||
}
|
||||
|
||||
// dashboardData is the data passed to the dashboard template.
|
||||
// dashboardData is the data passed to the dashboard template. Hostnames
|
||||
// and DomainRecords split the records in Snapshot.Hostnames, which also
|
||||
// holds the apex domains' own (see splitHostnames).
|
||||
type dashboardData struct {
|
||||
Snapshot state.Snapshot
|
||||
Hostnames map[string]*state.HostnameState
|
||||
DomainRecords map[string]*state.HostnameState
|
||||
Alerts []notify.AlertEntry
|
||||
StateAge string
|
||||
GeneratedAt string
|
||||
@@ -58,9 +65,12 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||
) {
|
||||
snap := h.state.GetSnapshot()
|
||||
alerts := h.notifyHistory.Recent()
|
||||
hostnames, domainRecords := splitHostnames(snap)
|
||||
|
||||
data := dashboardData{
|
||||
Snapshot: snap,
|
||||
Hostnames: hostnames,
|
||||
DomainRecords: domainRecords,
|
||||
Alerts: alerts,
|
||||
StateAge: relTime(snap.LastUpdated),
|
||||
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
@@ -122,16 +132,37 @@ func joinStrings(items []string, sep string) string {
|
||||
}
|
||||
|
||||
// formatRecords formats a map of record type → values into a
|
||||
// compact display string.
|
||||
// compact display string. Record types are listed in the order the
|
||||
// README lists them, any other type after them in alphabetical order,
|
||||
// so rows of nameservers with the same records read the same.
|
||||
func formatRecords(records map[string][]string) string {
|
||||
if len(records) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
order := []string{"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS"}
|
||||
|
||||
position := func(rtype string) int {
|
||||
i := slices.Index(order, rtype)
|
||||
if i < 0 {
|
||||
return len(order)
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
rtypes := slices.Collect(maps.Keys(records))
|
||||
slices.SortFunc(rtypes, func(a, b string) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(position(a), position(b)),
|
||||
strings.Compare(a, b),
|
||||
)
|
||||
})
|
||||
|
||||
var parts []string
|
||||
|
||||
for rtype, values := range records {
|
||||
for _, v := range values {
|
||||
for _, rtype := range rtypes {
|
||||
for _, v := range records[rtype] {
|
||||
parts = append(parts, rtype+": "+v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -80,6 +81,45 @@ func TestFormatRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatRecordsTypeOrder checks that record types are listed in
|
||||
// the README's order (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS), with
|
||||
// any other type after them in alphabetical order.
|
||||
func TestFormatRecordsTypeOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := handlers.FormatRecords(map[string][]string{
|
||||
"SOA": {"ns1.example.com. hostmaster.example.com. 1 2 3 4 5"},
|
||||
"NS": {"ns1.example.com.", "ns2.example.com."},
|
||||
"CAA": {`0 issue "letsencrypt.org"`},
|
||||
"DNAME": {"example.net."},
|
||||
"TXT": {"v=spf1 -all"},
|
||||
"SRV": {"10 5 443 www.example.com."},
|
||||
"MX": {"10 mail.example.com."},
|
||||
"CNAME": {"www.example.com."},
|
||||
"AAAA": {"2001:db8::1"},
|
||||
"A": {"192.0.2.1"},
|
||||
})
|
||||
|
||||
want := strings.Join([]string{
|
||||
"A: 192.0.2.1",
|
||||
"AAAA: 2001:db8::1",
|
||||
"CNAME: www.example.com.",
|
||||
"MX: 10 mail.example.com.",
|
||||
"TXT: v=spf1 -all",
|
||||
"SRV: 10 5 443 www.example.com.",
|
||||
`CAA: 0 issue "letsencrypt.org"`,
|
||||
"NS: ns1.example.com.",
|
||||
"NS: ns2.example.com.",
|
||||
"DNAME: example.net.",
|
||||
"SOA: ns1.example.com. hostmaster.example.com. 1 2 3 4 5",
|
||||
}, ", ")
|
||||
|
||||
if got != want {
|
||||
t.Errorf("FormatRecords lists types out of order:\n got %q\nwant %q",
|
||||
got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardRow returns the table row of page that contains name.
|
||||
func dashboardRow(t *testing.T, page string, name string) string {
|
||||
t.Helper()
|
||||
@@ -115,3 +155,53 @@ func TestDashboardShowsFailureReasons(t *testing.T) {
|
||||
t.Errorf("row of %s does not show %q", certKey, certFailedReason)
|
||||
}
|
||||
}
|
||||
|
||||
// dashboardSection returns the section of page under heading.
|
||||
func dashboardSection(t *testing.T, page string, heading string) string {
|
||||
t.Helper()
|
||||
|
||||
for section := range strings.SplitSeq(page, "<section") {
|
||||
words := strings.Join(strings.Fields(section), " ")
|
||||
if strings.Contains(words, "> "+heading+" </h2>") {
|
||||
return section
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("dashboard has no section headed %q", heading)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestDashboardShowsDomainRecordsUnderDomains checks that the dashboard
|
||||
// shows an apex domain's own records in the Domains section, and
|
||||
// neither lists nor counts the domain as a hostname.
|
||||
func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
page := get(t, newHandlersWithFailures(t).HandleDashboard())
|
||||
|
||||
domains := dashboardSection(t, page, "Domains")
|
||||
if !strings.Contains(dashboardRow(t, domains, domainAddress), testDomain) {
|
||||
t.Errorf("row of %s does not name %s", domainAddress, testDomain)
|
||||
}
|
||||
|
||||
if strings.Contains(dashboardSection(t, page, "Hostnames"), testDomain) {
|
||||
t.Errorf("Hostnames section lists the domain %s", testDomain)
|
||||
}
|
||||
|
||||
words := strings.Join(strings.Fields(page), " ")
|
||||
|
||||
footer := "monitoring 1 domains + 1 hostnames"
|
||||
if !strings.Contains(words, footer) {
|
||||
t.Errorf("dashboard does not say %q", footer)
|
||||
}
|
||||
|
||||
// With the tags taken out, the summary bar starts "Domains 1
|
||||
// Hostnames 1".
|
||||
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(page, " ")
|
||||
summary := "Domains 1 Hostnames 1"
|
||||
|
||||
if !strings.Contains(strings.Join(strings.Fields(text), " "), summary) {
|
||||
t.Errorf("summary bar does not say %q", summary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ 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.
|
||||
type statusDomainInfo struct {
|
||||
Nameservers []string `json:"nameservers"`
|
||||
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
|
||||
LastChecked time.Time `json:"lastChecked"`
|
||||
}
|
||||
|
||||
@@ -100,8 +103,10 @@ func buildStatusResponse(
|
||||
Certificates: make(map[string]*statusCertificateInfo),
|
||||
}
|
||||
|
||||
buildDomains(snap, resp)
|
||||
buildHostnames(snap, resp)
|
||||
hostnames, domainRecords := splitHostnames(snap)
|
||||
|
||||
buildDomains(snap, domainRecords, resp)
|
||||
buildHostnames(hostnames, resp)
|
||||
buildPorts(snap, resp)
|
||||
buildCertificates(snap, resp)
|
||||
buildCounts(resp)
|
||||
@@ -109,8 +114,30 @@ func buildStatusResponse(
|
||||
return resp
|
||||
}
|
||||
|
||||
// splitHostnames returns the records saved in snap.Hostnames in two
|
||||
// maps: the hostnames' and the apex domains' own. The watcher saves a
|
||||
// domain's own records there under the domain's name, which has an
|
||||
// entry in snap.Domains too.
|
||||
func splitHostnames(
|
||||
snap state.Snapshot,
|
||||
) (map[string]*state.HostnameState, map[string]*state.HostnameState) {
|
||||
hostnames := make(map[string]*state.HostnameState)
|
||||
domainRecords := make(map[string]*state.HostnameState)
|
||||
|
||||
for name, hs := range snap.Hostnames {
|
||||
if _, isDomain := snap.Domains[name]; isDomain {
|
||||
domainRecords[name] = hs
|
||||
} else {
|
||||
hostnames[name] = hs
|
||||
}
|
||||
}
|
||||
|
||||
return hostnames, domainRecords
|
||||
}
|
||||
|
||||
func buildDomains(
|
||||
snap state.Snapshot,
|
||||
domainRecords map[string]*state.HostnameState,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for name, ds := range snap.Domains {
|
||||
@@ -118,22 +145,36 @@ func buildDomains(
|
||||
copy(ns, ds.Nameservers)
|
||||
sort.Strings(ns)
|
||||
|
||||
records := make(map[string]*statusHostnameNSInfo)
|
||||
if hs, ok := domainRecords[name]; ok {
|
||||
records = nameserverInfo(hs)
|
||||
}
|
||||
|
||||
resp.Domains[name] = &statusDomainInfo{
|
||||
Nameservers: ns,
|
||||
RecordsByNameserver: records,
|
||||
LastChecked: ds.LastChecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildHostnames(
|
||||
snap state.Snapshot,
|
||||
hostnames map[string]*state.HostnameState,
|
||||
resp *statusResponse,
|
||||
) {
|
||||
for name, hs := range snap.Hostnames {
|
||||
info := &statusHostnameInfo{
|
||||
Nameservers: make(map[string]*statusHostnameNSInfo),
|
||||
for name, hs := range hostnames {
|
||||
resp.Hostnames[name] = &statusHostnameInfo{
|
||||
Nameservers: nameserverInfo(hs),
|
||||
LastChecked: hs.LastChecked,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nameserverInfo copies each nameserver's answer saved in hs.
|
||||
func nameserverInfo(
|
||||
hs *state.HostnameState,
|
||||
) map[string]*statusHostnameNSInfo {
|
||||
info := make(map[string]*statusHostnameNSInfo)
|
||||
|
||||
for ns, nsState := range hs.RecordsByNameserver {
|
||||
recs := make(map[string][]string, len(nsState.Records))
|
||||
@@ -143,7 +184,7 @@ func buildHostnames(
|
||||
recs[rtype] = copied
|
||||
}
|
||||
|
||||
info.Nameservers[ns] = &statusHostnameNSInfo{
|
||||
info[ns] = &statusHostnameNSInfo{
|
||||
Records: recs,
|
||||
Status: nsState.Status,
|
||||
Error: nsState.Error,
|
||||
@@ -151,8 +192,7 @@ func buildHostnames(
|
||||
}
|
||||
}
|
||||
|
||||
resp.Hostnames[name] = info
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func buildPorts(
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,7 +20,8 @@ import (
|
||||
|
||||
// The state the handler tests serve: www.example.com has one nameserver
|
||||
// that answered and one whose query failed, and its certificate check
|
||||
// failed.
|
||||
// failed. example.net is an apex domain, whose own records are saved
|
||||
// with the hostnames' records, as the watcher saves them.
|
||||
const (
|
||||
testHostname = "www.example.com"
|
||||
answeringNS = "ns1.example.com."
|
||||
@@ -27,6 +29,9 @@ const (
|
||||
nsFailureReason = "server returned a referral"
|
||||
certKey = "192.0.2.1:443:www.example.com"
|
||||
certFailedReason = "x509: certificate has expired or is not yet valid"
|
||||
testDomain = "example.net"
|
||||
domainNS = "a.iana-servers.net."
|
||||
domainAddress = "192.0.2.2"
|
||||
)
|
||||
|
||||
// newHandlersWithFailures builds real Handlers whose state holds the
|
||||
@@ -85,6 +90,22 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
st.SetDomainState(testDomain, &state.DomainState{
|
||||
Nameservers: []string{domainNS},
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
st.SetHostnameState(testDomain, &state.HostnameState{
|
||||
RecordsByNameserver: map[string]*state.NameserverRecordState{
|
||||
domainNS: {
|
||||
Records: map[string][]string{"A": {domainAddress}},
|
||||
Status: "ok",
|
||||
LastChecked: now,
|
||||
},
|
||||
},
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
hnd, err := handlers.New(nil, handlers.Params{
|
||||
Logger: log,
|
||||
Globals: glob,
|
||||
@@ -156,3 +177,43 @@ func TestStatusGivesFailureReasons(t *testing.T) {
|
||||
got, certFailedReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusGivesDomainRecordsUnderTheDomain checks that /api/v1/status
|
||||
// gives an apex domain's own records in its domain entry, and neither
|
||||
// lists nor counts the domain as a hostname.
|
||||
func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := get(t, newHandlersWithFailures(t).HandleStatus())
|
||||
|
||||
var resp struct {
|
||||
Counts struct {
|
||||
Hostnames int `json:"hostnames"`
|
||||
} `json:"counts"`
|
||||
Domains map[string]struct {
|
||||
RecordsByNameserver map[string]struct {
|
||||
Records map[string][]string `json:"records"`
|
||||
} `json:"recordsByNameserver"`
|
||||
} `json:"domains"`
|
||||
Hostnames map[string]any `json:"hostnames"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
|
||||
if resp.Counts.Hostnames != 1 {
|
||||
t.Errorf("counts.hostnames = %d, want 1", resp.Counts.Hostnames)
|
||||
}
|
||||
|
||||
if _, listed := resp.Hostnames[testDomain]; listed {
|
||||
t.Errorf("hostnames lists the domain %s", testDomain)
|
||||
}
|
||||
|
||||
records := resp.Domains[testDomain].RecordsByNameserver[domainNS].Records
|
||||
if !slices.Equal(records["A"], []string{domainAddress}) {
|
||||
t.Errorf("domain %s records at %s = %v, want A %s",
|
||||
testDomain, domainNS, records, domainAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
Hostnames
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-teal-400 mt-1">
|
||||
{{ len .Snapshot.Hostnames }}
|
||||
{{ len .Hostnames }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
|
||||
@@ -94,6 +94,24 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ if .DomainRecords }}
|
||||
<div class="overflow-x-auto mt-4">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-wider">
|
||||
<th class="py-2 px-3">Domain</th>
|
||||
<th class="py-2 px-3">NS</th>
|
||||
<th class="py-2 px-3">Status</th>
|
||||
<th class="py-2 px-3">Records</th>
|
||||
<th class="py-2 px-3">Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ template "records" .DomainRecords }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p class="text-slate-600 italic text-xs">
|
||||
No domains configured.
|
||||
@@ -108,7 +126,7 @@
|
||||
>
|
||||
Hostnames
|
||||
</h2>
|
||||
{{ if .Snapshot.Hostnames }}
|
||||
{{ if .Hostnames }}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-xs">
|
||||
<thead>
|
||||
@@ -121,43 +139,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800">
|
||||
{{ range $name, $hs := .Snapshot.Hostnames }}
|
||||
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $ns }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $nsr.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $nsr.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ if $nsr.Error }}
|
||||
<span class="text-red-400">{{ $nsr.Error }}</span>
|
||||
{{ else }}
|
||||
{{ formatRecords $nsr.Records }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ template "records" .Hostnames }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -373,8 +355,48 @@
|
||||
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
|
||||
>
|
||||
dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
|
||||
{{ len .Snapshot.Hostnames }} hostnames
|
||||
{{ len .Hostnames }} hostnames
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{/* ---- One row per nameserver of each name in the map it is given ---- */}}
|
||||
{{ define "records" }}
|
||||
{{ range $name, $hs := . }}
|
||||
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
|
||||
<tr class="hover:bg-surface-800/50">
|
||||
<td class="py-2 px-3 text-slate-200 font-medium">
|
||||
{{ $name }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-400 break-all">
|
||||
{{ $ns }}
|
||||
</td>
|
||||
<td class="py-2 px-3">
|
||||
{{ if eq $nsr.Status "ok" }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
|
||||
>ok</span
|
||||
>
|
||||
{{ else }}
|
||||
<span
|
||||
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
|
||||
>{{ $nsr.Status }}</span
|
||||
>
|
||||
{{ end }}
|
||||
</td>
|
||||
<td
|
||||
class="py-2 px-3 text-slate-400 break-all max-w-xs"
|
||||
>
|
||||
{{ if $nsr.Error }}
|
||||
<span class="text-red-400">{{ $nsr.Error }}</span>
|
||||
{{ else }}
|
||||
{{ formatRecords $nsr.Records }}
|
||||
{{ end }}
|
||||
</td>
|
||||
<td class="py-2 px-3 text-slate-500 whitespace-nowrap">
|
||||
{{ relTime $nsr.LastChecked }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -39,6 +39,13 @@ var (
|
||||
"CNAME chain depth exceeded",
|
||||
)
|
||||
|
||||
// ErrLookupDepthExceeded is returned when nameserver addresses
|
||||
// were not looked up because lookups were already maxLookupDepth
|
||||
// deep, one inside another.
|
||||
ErrLookupDepthExceeded = errors.New(
|
||||
"lookups of nameserver addresses go too deep",
|
||||
)
|
||||
|
||||
// ErrContextCanceled wraps context cancellation for the
|
||||
// resolver's iterative queries.
|
||||
ErrContextCanceled = errors.New("context canceled")
|
||||
|
||||
@@ -66,12 +66,33 @@ func (r *Resolver) QueryEachNS(
|
||||
return r.queryEachNS(ctx, nameservers, hostname, recordTypes())
|
||||
}
|
||||
|
||||
// ResolveNSIPs exports resolveNSIPs for testing.
|
||||
// ResolveNSIPs exports resolveNSIPs for testing, looking each name up
|
||||
// as a lookup that no other lookup started.
|
||||
func (r *Resolver) ResolveNSIPs(
|
||||
ctx context.Context,
|
||||
nsNames []string,
|
||||
) []string {
|
||||
return r.resolveNSIPs(ctx, nsNames)
|
||||
ips, _ := r.resolveNSIPs(ctx, nsNames, 1)
|
||||
|
||||
return ips
|
||||
}
|
||||
|
||||
// MaxLookupDepth exports maxLookupDepth for testing.
|
||||
const MaxLookupDepth = maxLookupDepth
|
||||
|
||||
// QueryZone exports queryZone for testing.
|
||||
func (r *Resolver) QueryZone(
|
||||
ctx context.Context,
|
||||
given []string,
|
||||
withoutAddresses []string,
|
||||
zone string,
|
||||
name string,
|
||||
qtype uint16,
|
||||
depth int,
|
||||
) (*dns.Msg, error) {
|
||||
return r.queryZone(
|
||||
ctx, given, withoutAddresses, zone, name, qtype, depth,
|
||||
)
|
||||
}
|
||||
|
||||
// RootServerList exports rootServerList for testing.
|
||||
|
||||
+130
-43
@@ -19,6 +19,16 @@ const (
|
||||
maxRetries = 2
|
||||
maxDelegation = 20
|
||||
timeoutMultiplier = 2
|
||||
|
||||
// maxLookupDepth is how many lookups of nameserver addresses may be
|
||||
// under way one inside another. Looking up a nameserver's address
|
||||
// can meet a referral that names nameservers without their
|
||||
// addresses, which are then looked up in turn; without a limit,
|
||||
// delegations that point at each other would never end. Each level
|
||||
// multiplies the queries sent. pool.ntp.org needs three: the
|
||||
// address of its nameserver g.ntpns.org can need a.ntpns.org's,
|
||||
// which needs a bitnames.com nameserver's.
|
||||
maxLookupDepth = 3
|
||||
)
|
||||
|
||||
// ErrRefused is returned when a DNS server refuses a query.
|
||||
@@ -201,13 +211,15 @@ func (r *Resolver) followDelegation(
|
||||
// servers are the root servers, the servers of zone ".".
|
||||
zone := "."
|
||||
|
||||
var withoutAddresses []string
|
||||
|
||||
for range maxDelegation {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, zone, domain, dns.TypeNS,
|
||||
resp, err := r.queryZone(
|
||||
ctx, servers, withoutAddresses, zone, domain, dns.TypeNS, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -232,18 +244,7 @@ func (r *Resolver) followDelegation(
|
||||
return r.resolveNSIterative(ctx, domain)
|
||||
}
|
||||
|
||||
glue := extractGlue(resp.Extra)
|
||||
nextServers := glueIPs(authNS, glue)
|
||||
|
||||
if len(nextServers) == 0 {
|
||||
nextServers = r.resolveNSIPs(ctx, authNS)
|
||||
}
|
||||
|
||||
if len(nextServers) == 0 {
|
||||
return nil, ErrNoNameservers
|
||||
}
|
||||
|
||||
servers = nextServers
|
||||
servers, withoutAddresses = referralNameservers(resp)
|
||||
zone = referralZone(resp)
|
||||
}
|
||||
|
||||
@@ -322,13 +323,20 @@ func (r *Resolver) queryServers(
|
||||
return nil, fmt.Errorf("all servers failed: %w", lastErr)
|
||||
}
|
||||
|
||||
// isErrorReply reports whether msg is an error reply: one with any code
|
||||
// but NOERROR and NXDOMAIN, such as SERVFAIL, NOTIMP or FORMERR. An error
|
||||
// reply says nothing about the name's records.
|
||||
func isErrorReply(msg *dns.Msg) bool {
|
||||
return msg.Rcode != dns.RcodeSuccess && msg.Rcode != dns.RcodeNameError
|
||||
}
|
||||
|
||||
// usableReply reports whether resp, a reply from one of the servers of
|
||||
// zone to a query about name, is usable. An error reply such as SERVFAIL
|
||||
// is not. Nor is a referral, unless it refers the query to a zone below
|
||||
// zone that name is in: a server that refers it back to zone, up or
|
||||
// sideways does not serve zone as it should.
|
||||
func usableReply(resp *dns.Msg, zone string, name string) bool {
|
||||
if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeNameError {
|
||||
if isErrorReply(resp) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -369,24 +377,110 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
|
||||
return extractNSSet(resp.Answer)
|
||||
}
|
||||
|
||||
// referralNameservers returns the IPv4 addresses that resp, a referral,
|
||||
// gives for the nameservers it names, and the names of the nameservers
|
||||
// it gives no address for.
|
||||
func referralNameservers(resp *dns.Msg) ([]string, []string) {
|
||||
glue := extractGlue(resp.Extra)
|
||||
|
||||
var given, withoutAddresses []string
|
||||
|
||||
for _, ns := range extractNSSet(resp.Ns) {
|
||||
ips := glueIPs([]string{ns}, glue)
|
||||
if len(ips) == 0 {
|
||||
withoutAddresses = append(withoutAddresses, ns)
|
||||
}
|
||||
|
||||
given = append(given, ips...)
|
||||
}
|
||||
|
||||
return given, withoutAddresses
|
||||
}
|
||||
|
||||
// queryZone asks the servers of zone about name as queryServers does:
|
||||
// first those at given, the addresses a referral gave, and only when
|
||||
// none of them gives a usable reply, the nameservers named
|
||||
// withoutAddresses, once their addresses are looked up. depth is how
|
||||
// many lookups of a nameserver's address are under way, 0 in the walk
|
||||
// to a domain's nameservers; at maxLookupDepth, no address is looked
|
||||
// up. When the limit is why none was found, here or in a lookup this
|
||||
// one started, the error is ErrLookupDepthExceeded.
|
||||
func (r *Resolver) queryZone(
|
||||
ctx context.Context,
|
||||
given []string,
|
||||
withoutAddresses []string,
|
||||
zone string,
|
||||
name string,
|
||||
qtype uint16,
|
||||
depth int,
|
||||
) (*dns.Msg, error) {
|
||||
err := fmt.Errorf(
|
||||
"no address for any nameserver of %s: %w", zone, ErrNoNameservers,
|
||||
)
|
||||
|
||||
if len(given) > 0 {
|
||||
var resp *dns.Msg
|
||||
|
||||
resp, err = r.queryServers(ctx, given, zone, name, qtype)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(withoutAddresses) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if depth >= maxLookupDepth {
|
||||
return nil, fmt.Errorf(
|
||||
"addresses of the nameservers of %s not looked up: %w",
|
||||
zone, ErrLookupDepthExceeded,
|
||||
)
|
||||
}
|
||||
|
||||
lookedUp, limitErr := r.resolveNSIPs(ctx, withoutAddresses, depth+1)
|
||||
if limitErr != nil {
|
||||
return nil, limitErr
|
||||
}
|
||||
|
||||
if len(lookedUp) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.queryServers(ctx, lookedUp, zone, name, qtype)
|
||||
}
|
||||
|
||||
// resolveNSIPs returns the addresses of every nameserver in nsNames
|
||||
// whose name resolves, for a referral that carries none. The walk can
|
||||
// then go on to the zone's other nameservers when one gives no usable
|
||||
// reply.
|
||||
// whose name resolves, each looked up at depth (see resolveARecord).
|
||||
// The walk can then go on to the zone's other nameservers when one
|
||||
// gives no usable reply. When none resolves and the depth limit
|
||||
// stopped one of the lookups, it returns that lookup's error.
|
||||
func (r *Resolver) resolveNSIPs(
|
||||
ctx context.Context,
|
||||
nsNames []string,
|
||||
) []string {
|
||||
var ips []string
|
||||
depth int,
|
||||
) ([]string, error) {
|
||||
var (
|
||||
ips []string
|
||||
limitErr error
|
||||
)
|
||||
|
||||
for _, ns := range nsNames {
|
||||
resolved, err := r.resolveARecord(ctx, ns)
|
||||
if err == nil {
|
||||
resolved, err := r.resolveARecord(ctx, ns, depth)
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
ips = append(ips, resolved...)
|
||||
case errors.Is(err, ErrLookupDepthExceeded):
|
||||
limitErr = err
|
||||
}
|
||||
}
|
||||
|
||||
return ips
|
||||
if len(ips) > 0 {
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
return nil, limitErr
|
||||
}
|
||||
|
||||
// resolveNSIterative queries for NS records using iterative
|
||||
@@ -441,11 +535,14 @@ func (r *Resolver) resolveNSIterative(
|
||||
return nil, ErrNoNameservers
|
||||
}
|
||||
|
||||
// resolveARecord resolves a hostname to IPv4 addresses using
|
||||
// iterative resolution through the delegation chain.
|
||||
// resolveARecord resolves a hostname, a nameserver's name, to IPv4
|
||||
// addresses using iterative resolution through the delegation chain.
|
||||
// depth is how many lookups of a nameserver's address are under way,
|
||||
// this one included: 1 for a lookup that no other lookup started.
|
||||
func (r *Resolver) resolveARecord(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
depth int,
|
||||
) ([]string, error) {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
@@ -455,13 +552,16 @@ func (r *Resolver) resolveARecord(
|
||||
servers := rootServerList()
|
||||
zone := "."
|
||||
|
||||
var withoutAddresses []string
|
||||
|
||||
for range maxDelegation {
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
resp, err := r.queryServers(
|
||||
ctx, servers, zone, hostname, dns.TypeA,
|
||||
resp, err := r.queryZone(
|
||||
ctx, servers, withoutAddresses, zone, hostname, dns.TypeA,
|
||||
depth,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -488,17 +588,7 @@ func (r *Resolver) resolveARecord(
|
||||
break
|
||||
}
|
||||
|
||||
glue := extractGlue(resp.Extra)
|
||||
nextServers := glueIPs(authNS, glue)
|
||||
|
||||
if len(nextServers) == 0 {
|
||||
// Resolve NS IPs iteratively — but guard
|
||||
// against infinite recursion by using only
|
||||
// already-resolved servers.
|
||||
break
|
||||
}
|
||||
|
||||
servers = nextServers
|
||||
servers, withoutAddresses = referralNameservers(resp)
|
||||
zone = referralZone(resp)
|
||||
}
|
||||
|
||||
@@ -587,7 +677,7 @@ func (r *Resolver) queryNameserver(
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
nsIPs, err := r.resolveARecord(ctx, nsHostname)
|
||||
nsIPs, err := r.resolveARecord(ctx, nsHostname, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err)
|
||||
}
|
||||
@@ -742,10 +832,7 @@ func readReply(
|
||||
return nil
|
||||
}
|
||||
|
||||
// A reply with any other code but NOERROR, such as SERVFAIL, NOTIMP or
|
||||
// FORMERR, is an error reply and says nothing about the name's
|
||||
// records. usableReply takes the same codes as no usable reply.
|
||||
if msg.Rcode != dns.RcodeSuccess {
|
||||
if isErrorReply(msg) {
|
||||
state.errorReply = dns.RcodeToString[msg.Rcode]
|
||||
|
||||
return fmt.Errorf(
|
||||
|
||||
@@ -3,6 +3,7 @@ package resolver_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -163,6 +164,112 @@ func TestResolveNSIPs_EveryNameserver(t *testing.T) {
|
||||
assert.ElementsMatch(t, want, got)
|
||||
}
|
||||
|
||||
// TestResolveNSIPs_ZoneDelegatedWithoutAddresses looks up the address
|
||||
// of a.ntpns.org, a nameserver of pool.ntp.org. The org servers delegate
|
||||
// ntpns.org to nameservers in other zones and give none of their
|
||||
// addresses, so those are looked up on the way.
|
||||
func TestResolveNSIPs_ZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
ips := liveResolveNSIPs(t, r, []string{"a.ntpns.org."}, 1)
|
||||
|
||||
for _, ip := range ips {
|
||||
assert.NotNil(t, net.ParseIP(ip), "should be valid IP: %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryZone_GivenAddressesFail asks the servers of ntp.org about
|
||||
// pool.ntp.org, as the walk to a name under ntp.org does after the org
|
||||
// servers' referral. That referral names four nameservers and gives an
|
||||
// address for ns1.everett.org alone; here the given address is
|
||||
// 192.0.2.1, where nothing answers, so the other three must be looked
|
||||
// up and asked.
|
||||
func TestQueryZone_GivenAddressesFail(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
|
||||
var resp *dns.Msg
|
||||
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"QueryZone(192.0.2.1 and three ntp.org nameservers, pool.ntp.org)",
|
||||
func(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
resp, err = r.QueryZone(
|
||||
ctx, []string{"192.0.2.1"},
|
||||
[]string{"anyns.pch.net.", "dns1.udel.edu.", "dns2.udel.edu."},
|
||||
"ntp.org.", "pool.ntp.org.", dns.TypeNS, 0,
|
||||
)
|
||||
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
assert.NotEmpty(t, resolver.NSSetFrom(resp, "pool.ntp.org."))
|
||||
}
|
||||
|
||||
// TestQueryZone_LookupDepth asks the servers of g.ntpns.org, a
|
||||
// nameserver of pool.ntp.org, for its address, as looking that address
|
||||
// up does when anyns.pch.net, one of the servers of ntpns.org, gives the
|
||||
// referral to g.ntpns.org without addresses. Their addresses are looked
|
||||
// up (here only a.ntpns.org's), and that needs a bitnames.com
|
||||
// nameserver's address, as the org servers delegate ntpns.org without
|
||||
// addresses. From depth 1, where looking up g.ntpns.org's address
|
||||
// starts, that makes three lookups and the address is found. From one
|
||||
// below maxLookupDepth, the bitnames.com lookup would be past the limit,
|
||||
// so nothing can be asked, and the error says the limit is why.
|
||||
func TestQueryZone_LookupDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
withoutAddresses := []string{"a.ntpns.org."}
|
||||
|
||||
var resp *dns.Msg
|
||||
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"QueryZone(a.ntpns.org without its address, g.ntpns.org)",
|
||||
func(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
resp, err = r.QueryZone(
|
||||
ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.",
|
||||
dns.TypeA, 1,
|
||||
)
|
||||
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
assert.NotEmpty(t, resp.Answer)
|
||||
|
||||
var limitErr error
|
||||
|
||||
// Any other error is live DNS not answering, and is retried.
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"QueryZone(a.ntpns.org without its address, g.ntpns.org, "+
|
||||
"one below the limit)",
|
||||
func(ctx context.Context) error {
|
||||
_, limitErr = r.QueryZone(
|
||||
ctx, nil, withoutAddresses, "g.ntpns.org.", "g.ntpns.org.",
|
||||
dns.TypeA, resolver.MaxLookupDepth-1,
|
||||
)
|
||||
if limitErr == nil ||
|
||||
errors.Is(limitErr, resolver.ErrLookupDepthExceeded) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return limitErr
|
||||
},
|
||||
)
|
||||
|
||||
require.ErrorIs(t, limitErr, resolver.ErrLookupDepthExceeded)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// QueryNameserver tests
|
||||
// ----------------------------------------------------------------
|
||||
@@ -186,6 +293,20 @@ func TestQueryNameserver_BasicA(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestQueryNameserver_ZoneDelegatedWithoutAddresses asks a.ntpns.org, a
|
||||
// nameserver of pool.ntp.org, about pool.ntp.org, as the watcher does.
|
||||
// The org servers delegate ntpns.org without the addresses of its
|
||||
// nameservers, so finding a.ntpns.org's address needs a lookup inside
|
||||
// the one QueryNameserver starts.
|
||||
func TestQueryNameserver_ZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
resp := liveQueryNameserver(t, r, "a.ntpns.org.", "pool.ntp.org", "A")
|
||||
|
||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||
}
|
||||
|
||||
func TestQueryNameserver_AAAA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -706,6 +827,21 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
|
||||
assert.Equal(t, fromFind, fromLookup)
|
||||
}
|
||||
|
||||
// TestLookupNS_ParentZoneDelegatedWithoutAddresses looks up the
|
||||
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
|
||||
// ntpns.org, without the addresses of its nameservers, so the walk has
|
||||
// to look them up to ask them. If it did not, the walk for g.ntpns.org
|
||||
// would fail and LookupNS would return the nameservers of ntpns.org,
|
||||
// which a.ntpns.org is not one of.
|
||||
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
nameservers := liveLookupNS(t, r, "g.ntpns.org")
|
||||
|
||||
assert.Contains(t, nameservers, "a.ntpns.org.")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// ResolveIPAddresses tests
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -129,6 +129,8 @@ type CertificateState struct {
|
||||
}
|
||||
|
||||
// Snapshot is the complete monitoring state persisted to disk.
|
||||
// Hostnames also holds each apex domain's own records, under the
|
||||
// domain's name, which has an entry in Domains too.
|
||||
type Snapshot struct {
|
||||
Version int `json:"version"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// NewForTest creates a Watcher without fx for unit testing.
|
||||
// NewForTest creates a Watcher without fx for unit testing. A nil cfg
|
||||
// is an empty configuration.
|
||||
func NewForTest(
|
||||
cfg *config.Config,
|
||||
st *state.State,
|
||||
@@ -19,6 +20,10 @@ func NewForTest(
|
||||
tc TLSChecker,
|
||||
n Notifier,
|
||||
) *Watcher {
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
log: slog.Default(),
|
||||
config: cfg,
|
||||
@@ -96,6 +101,12 @@ func (w *Watcher) DetectNSAddressChanges(
|
||||
w.detectNSAddressChanges(ctx, domain, prev, current)
|
||||
}
|
||||
|
||||
// MaybeSendTestNotification exports maybeSendTestNotification for
|
||||
// testing.
|
||||
func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
|
||||
w.maybeSendTestNotification(ctx)
|
||||
}
|
||||
|
||||
// CheckAllPorts exports checkAllPorts for testing.
|
||||
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
||||
w.checkAllPorts(ctx)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||
)
|
||||
|
||||
@@ -64,3 +68,100 @@ b.ns.example.net.: 192.0.2.2`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every kind of notification about a configured apex domain's own
|
||||
// records names it as a domain.
|
||||
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
notifier := &mockNotifier{}
|
||||
w := watcher.NewForTest(
|
||||
&config.Config{Domains: []string{domain}},
|
||||
nil, nil, nil, nil, notifier,
|
||||
)
|
||||
|
||||
// nsA's address changes, which also makes it differ from nsC; nsB
|
||||
// fails; nsC answers again; nsD is gone.
|
||||
nsD := "d.ns.example.net."
|
||||
w.DetectHostnameChanges(t.Context(), domain,
|
||||
saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||
nsC: failed(),
|
||||
nsD: answered(map[string][]string{"A": {ip1}}),
|
||||
}),
|
||||
saved(map[string]*state.NameserverRecordState{
|
||||
nsA: answered(map[string][]string{"A": {ip2}}),
|
||||
nsB: failed(),
|
||||
nsC: answered(map[string][]string{"A": {ip1}}),
|
||||
}),
|
||||
)
|
||||
|
||||
// The address at the end of its CNAME chain changes.
|
||||
w.DetectHostnameChanges(
|
||||
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
||||
)
|
||||
|
||||
// NS Failure is sent for nsB failing and for nsD being gone.
|
||||
want := map[string]int{
|
||||
"Record Change": 1,
|
||||
"Inconsistency": 1,
|
||||
"NS Failure": 2,
|
||||
"NS Recovery": 1,
|
||||
"CNAME Address Change": 1,
|
||||
}
|
||||
|
||||
sent := make(map[string]int)
|
||||
|
||||
for _, n := range notifier.getNotifications() {
|
||||
kind, _, _ := strings.Cut(n.Title, ":")
|
||||
sent[kind]++
|
||||
|
||||
if !strings.HasPrefix(n.Message, "Domain: "+domain+"\n") {
|
||||
t.Errorf("%s message does not name the domain:\n%s",
|
||||
n.Title, n.Message)
|
||||
}
|
||||
}
|
||||
|
||||
if !maps.Equal(sent, want) {
|
||||
t.Errorf("sent %v, want %v", sent, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The startup notification counts the configured domains and hostnames,
|
||||
// although the state's hostnames also hold the apex domain's own
|
||||
// records. Nothing is looked up: the watcher has no resolver.
|
||||
func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaultTestConfig(t)
|
||||
cfg.SendTestNotification = true
|
||||
cfg.Domains = []string{domain}
|
||||
cfg.Hostnames = []string{host}
|
||||
|
||||
deps := newTestDeps(t, cfg)
|
||||
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
|
||||
|
||||
// The state a check of both names saves.
|
||||
deps.state.SetDomainState(domain, &state.DomainState{
|
||||
Nameservers: []string{nsA},
|
||||
})
|
||||
|
||||
for _, name := range []string{domain, host} {
|
||||
deps.state.SetHostnameState(name, saved(
|
||||
map[string]*state.NameserverRecordState{
|
||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
w.MaybeSendTestNotification(t.Context())
|
||||
|
||||
notifications := deps.notifier.getNotifications()
|
||||
|
||||
counts := "\nMonitoring 1 domain(s) and 1 hostname(s).\n"
|
||||
if len(notifications) != 1 ||
|
||||
!strings.Contains(notifications[0].Message, counts) {
|
||||
t.Errorf("sent %v, want one message with %q", notifications, counts)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-17
@@ -272,8 +272,9 @@ func (w *Watcher) checkDomain(
|
||||
LastChecked: now,
|
||||
})
|
||||
|
||||
// The apex domain's records are also checked as a hostname's, so
|
||||
// that the port and TLS checks find its addresses.
|
||||
// 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).
|
||||
w.checkHostname(ctx, domain)
|
||||
}
|
||||
|
||||
@@ -580,6 +581,17 @@ func (w *Watcher) detectHostnameChanges(
|
||||
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
|
||||
}
|
||||
|
||||
// nameLine is the line a notification about name's records starts with:
|
||||
// "Domain: " and the name for a configured apex domain, whose own
|
||||
// records are checked as a hostname's are, and "Hostname: " otherwise.
|
||||
func (w *Watcher) nameLine(name string) string {
|
||||
if slices.Contains(w.config.Domains, name) {
|
||||
return "Domain: " + name
|
||||
}
|
||||
|
||||
return "Hostname: " + name
|
||||
}
|
||||
|
||||
// detectCNAMEAddressChanges notifies when the addresses at the end of
|
||||
// hostname's CNAME chain differ from those the previous check saved,
|
||||
// including a change from or to none. When the previous addresses are
|
||||
@@ -596,8 +608,8 @@ func (w *Watcher) detectCNAMEAddressChanges(
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nOld: %s\nNew: %s",
|
||||
hostname,
|
||||
"%s\nOld: %s\nNew: %s",
|
||||
w.nameLine(hostname),
|
||||
strings.Join(old, ", "),
|
||||
strings.Join(cur, ", "),
|
||||
)
|
||||
@@ -636,8 +648,8 @@ func (w *Watcher) detectRecordChanges(
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s\n%s",
|
||||
hostname, ns,
|
||||
"%s\nNameserver: %s\n%s",
|
||||
w.nameLine(hostname), ns,
|
||||
recordDifferences(
|
||||
"Old", oldRecords,
|
||||
"New", newRecords,
|
||||
@@ -664,8 +676,8 @@ func (w *Watcher) detectNSDisappearances(
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s disappeared",
|
||||
hostname, ns,
|
||||
"%s\nNameserver: %s disappeared",
|
||||
w.nameLine(hostname), ns,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -694,8 +706,8 @@ func (w *Watcher) detectNSFailures(
|
||||
switch {
|
||||
case prevNS.Status == statusOK && cur.Status == statusError:
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s\nError: %s",
|
||||
hostname, ns, cur.Error,
|
||||
"%s\nNameserver: %s\nError: %s",
|
||||
w.nameLine(hostname), ns, cur.Error,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -706,8 +718,8 @@ func (w *Watcher) detectNSFailures(
|
||||
)
|
||||
case prevNS.Status == statusError && cur.Status == statusOK:
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\nNameserver: %s recovered",
|
||||
hostname, ns,
|
||||
"%s\nNameserver: %s recovered",
|
||||
w.nameLine(hostname), ns,
|
||||
)
|
||||
|
||||
w.notify.SendNotification(
|
||||
@@ -735,8 +747,8 @@ func (w *Watcher) detectInconsistencies(
|
||||
failed := slices.Concat(state1.FailedTypes, state2.FailedTypes)
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\n%s",
|
||||
hostname,
|
||||
"%s\n%s",
|
||||
w.nameLine(hostname),
|
||||
recordDifferences(
|
||||
ns1, withoutTypes(state1.Records, failed),
|
||||
ns2, withoutTypes(state2.Records, failed),
|
||||
@@ -1234,7 +1246,9 @@ func (w *Watcher) saveState() {
|
||||
// after the first full scan completes, if SEND_TEST_NOTIFICATION
|
||||
// is enabled. The message is informational, not an error or anomaly
|
||||
// alert. It is written before it reaches any endpoint, so it claims
|
||||
// nothing about whether the endpoints work.
|
||||
// nothing about whether the endpoints work. Domains and hostnames are
|
||||
// counted from the configuration: the state's hostnames also hold each
|
||||
// apex domain's own records.
|
||||
func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
||||
if !w.config.SendTestNotification {
|
||||
return
|
||||
@@ -1248,8 +1262,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
|
||||
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
|
||||
"This is a test notification, sent to every configured "+
|
||||
"notification endpoint.",
|
||||
len(snap.Domains),
|
||||
len(snap.Hostnames),
|
||||
len(w.config.Domains),
|
||||
len(w.config.Hostnames),
|
||||
len(snap.Ports),
|
||||
len(snap.Certificates),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user