diff --git a/README.md b/README.md index daea081..4461a44 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ notification endpoint set, changes show only on the dashboard; see removed gets only the NS change notification. When the lookup of a nameserver's addresses fails or finds none, its previous addresses are 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) @@ -183,18 +189,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. @@ -229,7 +235,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. @@ -263,7 +270,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, @@ -455,7 +464,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 { diff --git a/TODO.md b/TODO.md index f71bbcf..3f5d249 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 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 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 diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6d4ac57..9addc74 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -40,12 +40,16 @@ 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 - Alerts []notify.AlertEntry - StateAge string - GeneratedAt string + Snapshot state.Snapshot + Hostnames map[string]*state.HostnameState + DomainRecords map[string]*state.HostnameState + Alerts []notify.AlertEntry + StateAge string + GeneratedAt string } // HandleDashboard returns the dashboard page handler. @@ -58,12 +62,15 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc { ) { snap := h.state.GetSnapshot() alerts := h.notifyHistory.Recent() + hostnames, domainRecords := splitHostnames(snap) data := dashboardData{ - Snapshot: snap, - Alerts: alerts, - StateAge: relTime(snap.LastUpdated), - GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), + Snapshot: snap, + Hostnames: hostnames, + DomainRecords: domainRecords, + Alerts: alerts, + StateAge: relTime(snap.LastUpdated), + GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"), } writer.Header().Set( diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index d60a36e..e81db5a 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -115,3 +115,44 @@ 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, " "+heading+" ") { + 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) + } +} diff --git a/internal/handlers/status.go b/internal/handlers/status.go index f1104ba..a76d98a 100644 --- a/internal/handlers/status.go +++ b/internal/handlers/status.go @@ -9,9 +9,12 @@ import ( ) // statusDomainInfo holds status information for a monitored domain. +// RecordsByNameserver holds the domain's own records, in the form a +// hostname's Nameservers holds the hostname's. type statusDomainInfo struct { - Nameservers []string `json:"nameservers"` - LastChecked time.Time `json:"lastChecked"` + Nameservers []string `json:"nameservers"` + RecordsByNameserver map[string]*statusHostnameNSInfo `json:"recordsByNameserver"` + LastChecked time.Time `json:"lastChecked"` } // statusHostnameNSInfo holds per-nameserver status for a hostname. @@ -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,41 +145,54 @@ 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, - LastChecked: ds.LastChecked, + 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, } + } +} - for ns, nsState := range hs.RecordsByNameserver { - recs := make(map[string][]string, len(nsState.Records)) - for rtype, vals := range nsState.Records { - copied := make([]string, len(vals)) - copy(copied, vals) - recs[rtype] = copied - } +// nameserverInfo copies each nameserver's answer saved in hs. +func nameserverInfo( + hs *state.HostnameState, +) map[string]*statusHostnameNSInfo { + info := make(map[string]*statusHostnameNSInfo) - info.Nameservers[ns] = &statusHostnameNSInfo{ - Records: recs, - Status: nsState.Status, - Error: nsState.Error, - LastChecked: nsState.LastChecked, - } + for ns, nsState := range hs.RecordsByNameserver { + recs := make(map[string][]string, len(nsState.Records)) + for rtype, vals := range nsState.Records { + copied := make([]string, len(vals)) + copy(copied, vals) + recs[rtype] = copied } - resp.Hostnames[name] = info + info[ns] = &statusHostnameNSInfo{ + Records: recs, + Status: nsState.Status, + Error: nsState.Error, + LastChecked: nsState.LastChecked, + } } + + return info } func buildPorts( diff --git a/internal/handlers/status_test.go b/internal/handlers/status_test.go index 156e247..d93f503 100644 --- a/internal/handlers/status_test.go +++ b/internal/handlers/status_test.go @@ -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) + } +} diff --git a/internal/handlers/templates/dashboard.html b/internal/handlers/templates/dashboard.html index 422e34f..6ab9d53 100644 --- a/internal/handlers/templates/dashboard.html +++ b/internal/handlers/templates/dashboard.html @@ -39,7 +39,7 @@ Hostnames
- {{ len .Snapshot.Hostnames }} + {{ len .Hostnames }}
@@ -94,6 +94,24 @@
+ {{ if .DomainRecords }} +
+ + + + + + + + + + + + {{ template "records" .DomainRecords }} + +
DomainNSStatusRecordsChecked
+
+ {{ end }} {{ else }}

No domains configured. @@ -108,7 +126,7 @@ > Hostnames - {{ if .Snapshot.Hostnames }} + {{ if .Hostnames }}

@@ -121,43 +139,7 @@ - {{ range $name, $hs := .Snapshot.Hostnames }} - {{ range $ns, $nsr := $hs.RecordsByNameserver }} - - - - - - - - {{ end }} - {{ end }} + {{ template "records" .Hostnames }}
- {{ $name }} - - {{ $ns }} - - {{ if eq $nsr.Status "ok" }} - ok - {{ else }} - {{ $nsr.Status }} - {{ end }} - - {{ if $nsr.Error }} - {{ $nsr.Error }} - {{ else }} - {{ formatRecords $nsr.Records }} - {{ end }} - - {{ relTime $nsr.LastChecked }} -
@@ -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 +{{/* ---- One row per nameserver of each name in the map it is given ---- */}} +{{ define "records" }} +{{ range $name, $hs := . }} +{{ range $ns, $nsr := $hs.RecordsByNameserver }} + + + {{ $name }} + + + {{ $ns }} + + + {{ if eq $nsr.Status "ok" }} + ok + {{ else }} + {{ $nsr.Status }} + {{ end }} + + + {{ if $nsr.Error }} + {{ $nsr.Error }} + {{ else }} + {{ formatRecords $nsr.Records }} + {{ end }} + + + {{ relTime $nsr.LastChecked }} + + +{{ end }} +{{ end }} +{{ end }} diff --git a/internal/state/state.go b/internal/state/state.go index ec1fff1..4f5a325 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -122,6 +122,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"` diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 863bb2a..e932a2c 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -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) diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index 2716ba0..4659076 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -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) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6a76074..87ab71a 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -271,8 +271,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) } @@ -541,6 +542,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 @@ -557,8 +569,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, ", "), ) @@ -590,8 +602,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", prevNS.Records, "New", cur.Records, @@ -618,8 +630,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( @@ -648,8 +660,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( @@ -660,8 +672,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( @@ -683,8 +695,8 @@ func (w *Watcher) detectInconsistencies( ns1, ns2 := pair[0], pair[1] msg := fmt.Sprintf( - "Hostname: %s\n%s", - hostname, + "%s\n%s", + w.nameLine(hostname), recordDifferences( ns1, current.RecordsByNameserver[ns1].Records, ns2, current.RecordsByNameserver[ns2].Records, @@ -1180,7 +1192,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 @@ -1194,8 +1208,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), )