dashboard, status API and notifications tell apex domains from hostnames (closes #224)
check / check (push) Canceled after 0s

An apex domain's own records are still saved with the hostnames'
records, under the domain's name, so the port and TLS checks find its
addresses. Notifications about them now start `Domain:`, decided by the
configured domains. The dashboard and /api/v1/status, which read only
the saved state, take a hostname entry whose name also has a domain
entry as that domain's own records: the dashboard shows them in a second
table under Domains, the API in the domain's `recordsByNameserver`, and
neither lists or counts them as hostnames. The startup notification
counts domains and hostnames from the configuration. README says which
of a domain's own records are watched and how their changes are
notified.

Model: opus-5-5
This commit is contained in:
2026-10-02 07:39:26 +00:00
parent 250f3dd687
commit 558bedb5e9
11 changed files with 411 additions and 100 deletions
+18 -8
View File
@@ -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 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)
@@ -183,18 +189,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, which nameserver, what record type, - **DNS record changes**: Which hostname or domain, which nameserver, what
old values, new values. record type, 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, the old and new addresses at the - **CNAME address changes**: Which hostname or domain, the old and new addresses
end of its CNAME chain. at the 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 affected. which hostname or domain 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.
@@ -229,7 +235,8 @@ 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. - **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 - **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.
@@ -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` 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). 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 `/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,
@@ -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 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. merged view, to enable inconsistency detection. `hostnames` also holds each
domain's own records, under the domain's name.
```json ```json
{ {
+2
View File
@@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: 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 - 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
+8 -1
View File
@@ -40,9 +40,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 { 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
@@ -58,9 +62,12 @@ 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"),
+41
View File
@@ -115,3 +115,44 @@ 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)
}
}
+49 -9
View File
@@ -9,8 +9,11 @@ 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"`
} }
@@ -100,8 +103,10 @@ func buildStatusResponse(
Certificates: make(map[string]*statusCertificateInfo), Certificates: make(map[string]*statusCertificateInfo),
} }
buildDomains(snap, resp) hostnames, domainRecords := splitHostnames(snap)
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)
@@ -109,8 +114,30 @@ 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 {
@@ -118,22 +145,36 @@ 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(
snap state.Snapshot, hostnames map[string]*state.HostnameState,
resp *statusResponse, resp *statusResponse,
) { ) {
for name, hs := range snap.Hostnames { for name, hs := range hostnames {
info := &statusHostnameInfo{ resp.Hostnames[name] = &statusHostnameInfo{
Nameservers: make(map[string]*statusHostnameNSInfo), Nameservers: nameserverInfo(hs),
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))
@@ -143,7 +184,7 @@ func buildHostnames(
recs[rtype] = copied recs[rtype] = copied
} }
info.Nameservers[ns] = &statusHostnameNSInfo{ info[ns] = &statusHostnameNSInfo{
Records: recs, Records: recs,
Status: nsState.Status, Status: nsState.Status,
Error: nsState.Error, Error: nsState.Error,
@@ -151,8 +192,7 @@ func buildHostnames(
} }
} }
resp.Hostnames[name] = info return info
}
} }
func buildPorts( func buildPorts(
+62 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"testing" "testing"
"time" "time"
@@ -19,7 +20,8 @@ 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. // failed. example.net is an apex domain, whose own records are saved
// 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."
@@ -27,6 +29,9 @@ 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
@@ -85,6 +90,22 @@ 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,
@@ -156,3 +177,43 @@ 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)
}
}
+62 -40
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 .Snapshot.Hostnames }} {{ len .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,6 +94,24 @@
</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.
@@ -108,7 +126,7 @@
> >
Hostnames Hostnames
</h2> </h2>
{{ if .Snapshot.Hostnames }} {{ if .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>
@@ -121,43 +139,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ range $name, $hs := .Snapshot.Hostnames }} {{ template "records" .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>
@@ -373,8 +355,48 @@
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 .Snapshot.Hostnames }} hostnames {{ len .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,6 +122,8 @@ 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"`
+12 -1
View File
@@ -10,7 +10,8 @@ import (
"sneak.berlin/go/dnswatcher/internal/state" "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( func NewForTest(
cfg *config.Config, cfg *config.Config,
st *state.State, st *state.State,
@@ -19,6 +20,10 @@ 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,
@@ -96,6 +101,12 @@ 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,8 +1,12 @@
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"
) )
@@ -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
View File
@@ -271,8 +271,9 @@ func (w *Watcher) checkDomain(
LastChecked: now, LastChecked: now,
}) })
// The apex domain's records are also checked as a hostname's, so // The apex domain's records are also checked and saved as a
// that the port and TLS checks find its addresses. // hostname's, so that the port and TLS checks find its addresses.
// Notifications about them name it as a domain (see nameLine).
w.checkHostname(ctx, domain) w.checkHostname(ctx, domain)
} }
@@ -541,6 +542,17 @@ 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
@@ -557,8 +569,8 @@ func (w *Watcher) detectCNAMEAddressChanges(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hostname: %s\nOld: %s\nNew: %s", "%s\nOld: %s\nNew: %s",
hostname, w.nameLine(hostname),
strings.Join(old, ", "), strings.Join(old, ", "),
strings.Join(cur, ", "), strings.Join(cur, ", "),
) )
@@ -590,8 +602,8 @@ func (w *Watcher) detectRecordChanges(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s\n%s", "%s\nNameserver: %s\n%s",
hostname, ns, w.nameLine(hostname), ns,
recordDifferences( recordDifferences(
"Old", prevNS.Records, "Old", prevNS.Records,
"New", cur.Records, "New", cur.Records,
@@ -618,8 +630,8 @@ func (w *Watcher) detectNSDisappearances(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s disappeared", "%s\nNameserver: %s disappeared",
hostname, ns, w.nameLine(hostname), ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -648,8 +660,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(
"Hostname: %s\nNameserver: %s\nError: %s", "%s\nNameserver: %s\nError: %s",
hostname, ns, cur.Error, w.nameLine(hostname), ns, cur.Error,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -660,8 +672,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(
"Hostname: %s\nNameserver: %s recovered", "%s\nNameserver: %s recovered",
hostname, ns, w.nameLine(hostname), ns,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -683,8 +695,8 @@ func (w *Watcher) detectInconsistencies(
ns1, ns2 := pair[0], pair[1] ns1, ns2 := pair[0], pair[1]
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hostname: %s\n%s", "%s\n%s",
hostname, w.nameLine(hostname),
recordDifferences( recordDifferences(
ns1, current.RecordsByNameserver[ns1].Records, ns1, current.RecordsByNameserver[ns1].Records,
ns2, current.RecordsByNameserver[ns2].Records, ns2, current.RecordsByNameserver[ns2].Records,
@@ -1180,7 +1192,9 @@ 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. // 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) { func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
if !w.config.SendTestNotification { if !w.config.SendTestNotification {
return return
@@ -1194,8 +1208,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(snap.Domains), len(w.config.Domains),
len(snap.Hostnames), len(w.config.Hostnames),
len(snap.Ports), len(snap.Ports),
len(snap.Certificates), len(snap.Certificates),
) )