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
+16 -9
View File
@@ -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(
+41
View File
@@ -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, "<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)
}
}
+64 -24
View File
@@ -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(
+62 -1
View File
@@ -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)
}
}
+62 -40
View File
@@ -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 &middot; 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 }}
+2
View File
@@ -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"`
+12 -1
View File
@@ -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)
+101
View File
@@ -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
View File
@@ -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),
)