1 Commits
Author SHA1 Message Date
clawbot d0ac850407 resolver: ask a referral's nameservers that come without addresses (closes #221)
check / check (push) Canceled after 0s
Looking up a nameserver's own address followed only the addresses a
referral gave, so a nameserver whose zone is delegated without them,
such as a.ntpns.org of pool.ntp.org, never resolved. The walk to a
name's nameservers looked addresses up only when a referral gave none.
Both now go through queryZone, which asks the nameservers whose
addresses the referral gives first and, if none of them gives a usable
reply, looks up and asks the others. maxLookupDepth stops lookups three
deep, so delegations that point at each other still end; when the limit
is why no address was found, the error is ErrLookupDepthExceeded, not
"no address".

Model: opus-5-5
2026-10-02 07:43:26 +00:00
11 changed files with 103 additions and 489 deletions
+8 -18
View File
@@ -81,12 +81,6 @@ notification endpoint set, changes show only on the dashboard; see
removed gets only the NS change notification. When the lookup of a removed gets only the NS change notification. When the lookup of a
nameserver's addresses fails or finds none, its previous addresses are nameserver's addresses fails or finds none, its previous addresses are
kept and nothing is sent. kept and nothing is sent.
- 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) ### DNS Hostname Monitoring (Subdomains)
@@ -189,18 +183,18 @@ Supported notification backends:
All configured endpoints receive every notification. Notification content All configured endpoints receive every notification. Notification content
includes: includes:
- **DNS record changes**: Which hostname or domain, which nameserver, what - **DNS record changes**: Which hostname, which nameserver, what record type,
record type, old values, new values. old values, new values.
- **DNS NS changes**: Which domain, which nameservers were added/removed. - **DNS NS changes**: Which domain, which nameservers were added/removed.
- **NS address changes**: Which domain, which nameserver, its old and new - **NS address changes**: Which domain, which nameserver, its old and new
addresses. addresses.
- **CNAME address changes**: Which hostname or domain, the old and new addresses - **CNAME address changes**: Which hostname, the old and new addresses at the
at the end of its CNAME chain. end of its CNAME chain.
- **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL, - **NS query failures**: Which nameserver failed, error type (timeout, SERVFAIL,
REFUSED, network error), which hostname/domain affected. REFUSED, network error), which hostname/domain affected.
- **NS recoveries**: Which nameserver recovered, which hostname/domain. - **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned, - **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname or domain affected. which hostname affected.
- **Port changes**: Which IP:port, its new state, all associated hostnames. - **Port changes**: Which IP:port, its new state, all associated hostnames.
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated - **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP. hostname and IP.
@@ -235,8 +229,7 @@ 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.
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 associated hostnames. - **Ports** with open/closed state and associated hostnames.
@@ -270,9 +263,7 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
In `/api/v1/status`, each nameserver entry and certificate entry whose `status` 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).
`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 `/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,
@@ -469,8 +460,7 @@ resolver: the HTTP client looks up the webhook's or Sentry's host name with it.
The state file (`DATA_DIR/state.json`) contains the complete monitoring The state file (`DATA_DIR/state.json`) contains the complete monitoring
snapshot. Hostname records are stored **per authoritative nameserver**, not as a snapshot. Hostname records are stored **per authoritative nameserver**, not as a
merged view, to enable inconsistency detection. `hostnames` also holds each merged view, to enable inconsistency detection.
domain's own records, under the domain's name.
```json ```json
{ {
-4
View File
@@ -21,10 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: nameservers a referral names without addresses are looked up, - 2026-10-02: nameservers a referral names without addresses are looked up,
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). 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 - 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). 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 - 2026-10-02: a name's CNAME is stored once per nameserver, not once per record
+4 -35
View File
@@ -1,14 +1,11 @@
package handlers package handlers
import ( import (
"cmp"
"embed" "embed"
"fmt" "fmt"
"html/template" "html/template"
"maps"
"math" "math"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@@ -43,13 +40,9 @@ func newDashboardTemplate() *template.Template {
) )
} }
// dashboardData is the data passed to the dashboard template. Hostnames // dashboardData is the data passed to the dashboard template.
// and DomainRecords split the records in Snapshot.Hostnames, which also
// holds the apex domains' own (see splitHostnames).
type dashboardData struct { type dashboardData struct {
Snapshot state.Snapshot Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState
Alerts []notify.AlertEntry Alerts []notify.AlertEntry
StateAge string StateAge string
GeneratedAt string GeneratedAt string
@@ -65,12 +58,9 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
) { ) {
snap := h.state.GetSnapshot() snap := h.state.GetSnapshot()
alerts := h.notifyHistory.Recent() alerts := h.notifyHistory.Recent()
hostnames, domainRecords := splitHostnames(snap)
data := dashboardData{ data := dashboardData{
Snapshot: snap, Snapshot: snap,
Hostnames: hostnames,
DomainRecords: domainRecords,
Alerts: alerts, Alerts: alerts,
StateAge: relTime(snap.LastUpdated), StateAge: relTime(snap.LastUpdated),
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
@@ -132,37 +122,16 @@ func joinStrings(items []string, sep string) string {
} }
// formatRecords formats a map of record type → values into a // formatRecords formats a map of record type → values into a
// compact display string. Record types are listed in the order the // compact display string.
// 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 { func formatRecords(records map[string][]string) string {
if len(records) == 0 { if len(records) == 0 {
return "-" 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 var parts []string
for _, rtype := range rtypes { for rtype, values := range records {
for _, v := range records[rtype] { for _, v := range values {
parts = append(parts, rtype+": "+v) parts = append(parts, rtype+": "+v)
} }
} }
-90
View File
@@ -1,7 +1,6 @@
package handlers_test package handlers_test
import ( import (
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -81,45 +80,6 @@ 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. // dashboardRow returns the table row of page that contains name.
func dashboardRow(t *testing.T, page string, name string) string { func dashboardRow(t *testing.T, page string, name string) string {
t.Helper() t.Helper()
@@ -155,53 +115,3 @@ func TestDashboardShowsFailureReasons(t *testing.T) {
t.Errorf("row of %s does not show %q", certKey, certFailedReason) t.Errorf("row of %s does not show %q", certKey, certFailedReason)
} }
} }
// dashboardSection returns the section of page under heading.
func dashboardSection(t *testing.T, page string, heading string) string {
t.Helper()
for section := range strings.SplitSeq(page, "<section") {
words := strings.Join(strings.Fields(section), " ")
if strings.Contains(words, "> "+heading+" </h2>") {
return section
}
}
t.Fatalf("dashboard has no section headed %q", heading)
return ""
}
// TestDashboardShowsDomainRecordsUnderDomains checks that the dashboard
// shows an apex domain's own records in the Domains section, and
// neither lists nor counts the domain as a hostname.
func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
domains := dashboardSection(t, page, "Domains")
if !strings.Contains(dashboardRow(t, domains, domainAddress), testDomain) {
t.Errorf("row of %s does not name %s", domainAddress, testDomain)
}
if strings.Contains(dashboardSection(t, page, "Hostnames"), testDomain) {
t.Errorf("Hostnames section lists the domain %s", testDomain)
}
words := strings.Join(strings.Fields(page), " ")
footer := "monitoring 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 -49
View File
@@ -9,11 +9,8 @@ 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
// hostname's Nameservers holds the hostname's.
type statusDomainInfo struct { type statusDomainInfo struct {
Nameservers []string `json:"nameservers"` Nameservers []string `json:"nameservers"`
RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -103,10 +100,8 @@ func buildStatusResponse(
Certificates: make(map[string]*statusCertificateInfo), Certificates: make(map[string]*statusCertificateInfo),
} }
hostnames, domainRecords := splitHostnames(snap) buildDomains(snap, resp)
buildHostnames(snap, resp)
buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp)
buildPorts(snap, resp) buildPorts(snap, resp)
buildCertificates(snap, resp) buildCertificates(snap, resp)
buildCounts(resp) buildCounts(resp)
@@ -114,30 +109,8 @@ func buildStatusResponse(
return resp return resp
} }
// splitHostnames returns the records saved in snap.Hostnames in two
// maps: the hostnames' and the apex domains' own. The watcher saves a
// domain's own records there under the domain's name, which has an
// entry in snap.Domains too.
func splitHostnames(
snap state.Snapshot,
) (map[string]*state.HostnameState, map[string]*state.HostnameState) {
hostnames := make(map[string]*state.HostnameState)
domainRecords := make(map[string]*state.HostnameState)
for name, hs := range snap.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domainRecords[name] = hs
} else {
hostnames[name] = hs
}
}
return hostnames, domainRecords
}
func buildDomains( func buildDomains(
snap state.Snapshot, snap state.Snapshot,
domainRecords map[string]*state.HostnameState,
resp *statusResponse, resp *statusResponse,
) { ) {
for name, ds := range snap.Domains { for name, ds := range snap.Domains {
@@ -145,36 +118,22 @@ func buildDomains(
copy(ns, ds.Nameservers) copy(ns, ds.Nameservers)
sort.Strings(ns) sort.Strings(ns)
records := make(map[string]*statusHostnameNSInfo)
if hs, ok := domainRecords[name]; ok {
records = nameserverInfo(hs)
}
resp.Domains[name] = &statusDomainInfo{ resp.Domains[name] = &statusDomainInfo{
Nameservers: ns, Nameservers: ns,
RecordsByNameserver: records,
LastChecked: ds.LastChecked, LastChecked: ds.LastChecked,
} }
} }
} }
func buildHostnames( func buildHostnames(
hostnames map[string]*state.HostnameState, snap state.Snapshot,
resp *statusResponse, resp *statusResponse,
) { ) {
for name, hs := range hostnames { for name, hs := range snap.Hostnames {
resp.Hostnames[name] = &statusHostnameInfo{ info := &statusHostnameInfo{
Nameservers: nameserverInfo(hs), Nameservers: make(map[string]*statusHostnameNSInfo),
LastChecked: hs.LastChecked, 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 { for ns, nsState := range hs.RecordsByNameserver {
recs := make(map[string][]string, len(nsState.Records)) recs := make(map[string][]string, len(nsState.Records))
@@ -184,7 +143,7 @@ func nameserverInfo(
recs[rtype] = copied recs[rtype] = copied
} }
info[ns] = &statusHostnameNSInfo{ info.Nameservers[ns] = &statusHostnameNSInfo{
Records: recs, Records: recs,
Status: nsState.Status, Status: nsState.Status,
Error: nsState.Error, Error: nsState.Error,
@@ -192,7 +151,8 @@ func nameserverInfo(
} }
} }
return info resp.Hostnames[name] = info
}
} }
func buildPorts( func buildPorts(
+1 -62
View File
@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
"time" "time"
@@ -20,8 +19,7 @@ import (
// The state the handler tests serve: www.example.com has one nameserver // The state the handler tests serve: www.example.com has one nameserver
// that answered and one whose query failed, and its certificate check // that answered and one whose query failed, and its certificate check
// failed. example.net is an apex domain, whose own records are saved // failed.
// with the hostnames' records, as the watcher saves them.
const ( const (
testHostname = "www.example.com" testHostname = "www.example.com"
answeringNS = "ns1.example.com." answeringNS = "ns1.example.com."
@@ -29,9 +27,6 @@ const (
nsFailureReason = "server returned a referral" nsFailureReason = "server returned a referral"
certKey = "192.0.2.1:443:www.example.com" certKey = "192.0.2.1:443:www.example.com"
certFailedReason = "x509: certificate has expired or is not yet valid" 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 // newHandlersWithFailures builds real Handlers whose state holds the
@@ -90,22 +85,6 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
LastChecked: now, 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{ hnd, err := handlers.New(nil, handlers.Params{
Logger: log, Logger: log,
Globals: glob, Globals: glob,
@@ -177,43 +156,3 @@ func TestStatusGivesFailureReasons(t *testing.T) {
got, certFailedReason) 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)
}
}
+40 -62
View File
@@ -39,7 +39,7 @@
Hostnames Hostnames
</div> </div>
<div class="text-2xl font-bold text-teal-400 mt-1"> <div class="text-2xl font-bold text-teal-400 mt-1">
{{ len .Hostnames }} {{ len .Snapshot.Hostnames }}
</div> </div>
</div> </div>
<div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4"> <div class="bg-surface-800 border border-slate-700/50 rounded-lg p-4">
@@ -94,24 +94,6 @@
</tbody> </tbody>
</table> </table>
</div> </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 }} {{ else }}
<p class="text-slate-600 italic text-xs"> <p class="text-slate-600 italic text-xs">
No domains configured. No domains configured.
@@ -126,7 +108,7 @@
> >
Hostnames Hostnames
</h2> </h2>
{{ if .Hostnames }} {{ if .Snapshot.Hostnames }}
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left text-xs"> <table class="w-full text-left text-xs">
<thead> <thead>
@@ -139,7 +121,43 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ template "records" .Hostnames }} {{ range $name, $hs := .Snapshot.Hostnames }}
{{ range $ns, $nsr := $hs.RecordsByNameserver }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $name }}
</td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ $ns }}
</td>
<td class="py-2 px-3">
{{ if eq $nsr.Status "ok" }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-teal-900/50 text-teal-400 border border-teal-700/30"
>ok</span
>
{{ else }}
<span
class="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold uppercase bg-red-900/50 text-red-400 border border-red-700/30"
>{{ $nsr.Status }}</span
>
{{ end }}
</td>
<td
class="py-2 px-3 text-slate-400 break-all max-w-xs"
>
{{ 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 }}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -355,48 +373,8 @@
class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8" class="text-[11px] text-slate-700 border-t border-slate-800 pt-4 mt-8"
> >
dnswatcher &middot; monitoring {{ len .Snapshot.Domains }} domains + dnswatcher &middot; monitoring {{ len .Snapshot.Domains }} domains +
{{ len .Hostnames }} hostnames {{ len .Snapshot.Hostnames }} hostnames
</div> </div>
</div> </div>
</body> </body>
</html> </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 }}
-2
View File
@@ -122,8 +122,6 @@ type CertificateState struct {
} }
// Snapshot is the complete monitoring state persisted to disk. // 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 { type Snapshot struct {
Version int `json:"version"` Version int `json:"version"`
LastUpdated time.Time `json:"lastUpdated"` LastUpdated time.Time `json:"lastUpdated"`
+1 -12
View File
@@ -10,8 +10,7 @@ import (
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
) )
// NewForTest creates a Watcher without fx for unit testing. A nil cfg // NewForTest creates a Watcher without fx for unit testing.
// is an empty configuration.
func NewForTest( func NewForTest(
cfg *config.Config, cfg *config.Config,
st *state.State, st *state.State,
@@ -20,10 +19,6 @@ func NewForTest(
tc TLSChecker, tc TLSChecker,
n Notifier, n Notifier,
) *Watcher { ) *Watcher {
if cfg == nil {
cfg = &config.Config{}
}
return &Watcher{ return &Watcher{
log: slog.Default(), log: slog.Default(),
config: cfg, config: cfg,
@@ -101,12 +96,6 @@ func (w *Watcher) DetectNSAddressChanges(
w.detectNSAddressChanges(ctx, domain, prev, current) 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. // CheckAllPorts exports checkAllPorts for testing.
func (w *Watcher) CheckAllPorts(ctx context.Context) { func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx) w.checkAllPorts(ctx)
-101
View File
@@ -1,12 +1,8 @@
package watcher_test package watcher_test
import ( import (
"maps"
"strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
@@ -68,100 +64,3 @@ 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)
}
}
+17 -31
View File
@@ -271,9 +271,8 @@ func (w *Watcher) checkDomain(
LastChecked: now, LastChecked: now,
}) })
// The apex domain's records are also checked and saved as a // The apex domain's records are also checked as a hostname's, so
// hostname's, so that the port and TLS checks find its addresses. // that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine).
w.checkHostname(ctx, domain) w.checkHostname(ctx, domain)
} }
@@ -542,17 +541,6 @@ func (w *Watcher) detectHostnameChanges(
w.detectCNAMEAddressChanges(ctx, hostname, prev, current) 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 // detectCNAMEAddressChanges notifies when the addresses at the end of
// hostname's CNAME chain differ from those the previous check saved, // hostname's CNAME chain differ from those the previous check saved,
// including a change from or to none. When the previous addresses are // including a change from or to none. When the previous addresses are
@@ -569,8 +557,8 @@ func (w *Watcher) detectCNAMEAddressChanges(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nOld: %s\nNew: %s", "Hostname: %s\nOld: %s\nNew: %s",
w.nameLine(hostname), hostname,
strings.Join(old, ", "), strings.Join(old, ", "),
strings.Join(cur, ", "), strings.Join(cur, ", "),
) )
@@ -602,8 +590,8 @@ func (w *Watcher) detectRecordChanges(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s\n%s", "Hostname: %s\nNameserver: %s\n%s",
w.nameLine(hostname), ns, hostname, ns,
recordDifferences( recordDifferences(
"Old", prevNS.Records, "Old", prevNS.Records,
"New", cur.Records, "New", cur.Records,
@@ -630,8 +618,8 @@ func (w *Watcher) detectNSDisappearances(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s disappeared", "Hostname: %s\nNameserver: %s disappeared",
w.nameLine(hostname), ns, hostname, ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -660,8 +648,8 @@ func (w *Watcher) detectNSFailures(
switch { switch {
case prevNS.Status == statusOK && cur.Status == statusError: case prevNS.Status == statusOK && cur.Status == statusError:
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s\nError: %s", "Hostname: %s\nNameserver: %s\nError: %s",
w.nameLine(hostname), ns, cur.Error, hostname, ns, cur.Error,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -672,8 +660,8 @@ func (w *Watcher) detectNSFailures(
) )
case prevNS.Status == statusError && cur.Status == statusOK: case prevNS.Status == statusError && cur.Status == statusOK:
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\nNameserver: %s recovered", "Hostname: %s\nNameserver: %s recovered",
w.nameLine(hostname), ns, hostname, ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -695,8 +683,8 @@ func (w *Watcher) detectInconsistencies(
ns1, ns2 := pair[0], pair[1] ns1, ns2 := pair[0], pair[1]
msg := fmt.Sprintf( msg := fmt.Sprintf(
"%s\n%s", "Hostname: %s\n%s",
w.nameLine(hostname), hostname,
recordDifferences( recordDifferences(
ns1, current.RecordsByNameserver[ns1].Records, ns1, current.RecordsByNameserver[ns1].Records,
ns2, current.RecordsByNameserver[ns2].Records, ns2, current.RecordsByNameserver[ns2].Records,
@@ -1192,9 +1180,7 @@ func (w *Watcher) saveState() {
// after the first full scan completes, if SEND_TEST_NOTIFICATION // after the first full scan completes, if SEND_TEST_NOTIFICATION
// is enabled. The message is informational, not an error or anomaly // is enabled. The message is informational, not an error or anomaly
// alert. It is written before it reaches any endpoint, so it claims // alert. It is written before it reaches any endpoint, so it claims
// nothing about whether the endpoints work. Domains and hostnames are // nothing about whether the endpoints work.
// counted from the configuration: the state's hostnames also hold each
// apex domain's own records.
func (w *Watcher) maybeSendTestNotification(ctx context.Context) { func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
if !w.config.SendTestNotification { if !w.config.SendTestNotification {
return return
@@ -1208,8 +1194,8 @@ func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
"Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+ "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
"This is a test notification, sent to every configured "+ "This is a test notification, sent to every configured "+
"notification endpoint.", "notification endpoint.",
len(w.config.Domains), len(snap.Domains),
len(w.config.Hostnames), len(snap.Hostnames),
len(snap.Ports), len(snap.Ports),
len(snap.Certificates), len(snap.Certificates),
) )