dashboard and status API list a port's domains apart from its hostnames (closes #245)
check / check (push) Canceled after 0s

A port entry in the state saves the apex domains that resolve to its
address with its hostnames. The dashboard's Ports table now has a
Domains column next to Hostnames, and a port entry in /api/v1/status
has a `domains` list, with `hostnames` no longer holding a domain. A
name is taken as a domain when it has a domain entry, as the dashboard
and API already tell a domain's own records from a hostname's. Both
read the split from one function, buildPorts. The state file is
unchanged; README says its port `hostnames` include domains.

Model: opus-5-5
This commit is contained in:
2026-10-02 08:16:50 +00:00
parent e46db71821
commit 58a992b49a
7 changed files with 140 additions and 18 deletions
+8 -4
View File
@@ -239,7 +239,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
per nameserver and status, shown as a hostname's are. 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 the domains and hostnames that resolve to
each address, in separate columns.
- **TLS certificates** with CN, issuer, expiry, and status. For a failed check, - **TLS certificates** with CN, issuer, expiry, and status. For a failed check,
the reason is shown in place of CN, issuer and expiry. the reason is shown in place of CN, issuer and expiry.
- **Recent alerts** (last 100 notifications sent since the process started), - **Recent alerts** (last 100 notifications sent since the process started),
@@ -272,7 +273,9 @@ 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). A domain's own records are in its entry in `domains`, under
`recordsByNameserver`, in the form a hostname's entry in `hostnames` has them `recordsByNameserver`, in the form a hostname's entry in `hostnames` has them
under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. under `nameservers`; `hostnames` and `counts.hostnames` hold no domain. A port
entry lists the domains that resolve to its address in `domains`, and the
hostnames in `hostnames`.
`/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,
@@ -556,8 +559,9 @@ previous check's list is kept, or `null` when no earlier check saved one. A
state file without it loads, and the first check after that saves it without a state file without it loads, and the first check after that saves it without a
notification. notification.
A port entry in the older format, with one `hostname` instead of the `hostnames` A port entry's `hostnames` lists every name that resolves to its address,
list, loads as a list of that one name. domains included. A port entry in the older format, with one `hostname` instead
of the `hostnames` list, loads as a list of that one name.
--- ---
+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: the dashboard's Ports table and `/api/v1/status` port entries list
a port's domains apart from its hostnames (closes #245).
- 2026-10-02: an apex domain is not counted or listed as a hostname; its records - 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). 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 - 2026-10-02: the dashboard lists each nameserver's record types in one fixed
+5 -1
View File
@@ -45,11 +45,14 @@ func newDashboardTemplate() *template.Template {
// dashboardData is the data passed to the dashboard template. Hostnames // dashboardData is the data passed to the dashboard template. Hostnames
// and DomainRecords split the records in Snapshot.Hostnames, which also // and DomainRecords split the records in Snapshot.Hostnames, which also
// holds the apex domains' own (see splitHostnames). // holds the apex domains' own (see splitHostnames). Ports holds
// Snapshot.Ports with each port's names split into domains and
// hostnames, as /api/v1/status gives them (see buildPorts).
type dashboardData struct { type dashboardData struct {
Snapshot state.Snapshot Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState DomainRecords map[string]*state.HostnameState
Ports map[string]*statusPortInfo
Alerts []notify.AlertEntry Alerts []notify.AlertEntry
StateAge string StateAge string
GeneratedAt string GeneratedAt string
@@ -71,6 +74,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
Snapshot: snap, Snapshot: snap,
Hostnames: hostnames, Hostnames: hostnames,
DomainRecords: domainRecords, DomainRecords: domainRecords,
Ports: buildPorts(snap),
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"),
+48
View File
@@ -205,3 +205,51 @@ func TestDashboardShowsDomainRecordsUnderDomains(t *testing.T) {
t.Errorf("summary bar does not say %q", summary) t.Errorf("summary bar does not say %q", summary)
} }
} }
// rowCells returns the text of each cell of a dashboard table row
// whose cells start with tag, "<th" or "<td".
func rowCells(row string, tag string) []string {
tags := regexp.MustCompile(`<[^>]*>`)
var cells []string
for _, cell := range strings.Split(row, tag)[1:] {
text := tags.ReplaceAllString(tag+cell, " ")
cells = append(cells, strings.Join(strings.Fields(text), " "))
}
return cells
}
// TestDashboardPortsTellDomainsFromHostnames checks that the Ports
// table lists an apex domain under Domains and a hostname under
// Hostnames when both resolve to the port's address.
func TestDashboardPortsTellDomainsFromHostnames(t *testing.T) {
t.Parallel()
page := get(t, newHandlersWithFailures(t).HandleDashboard())
ports := dashboardSection(t, page, "Ports")
headings := rowCells(dashboardRow(t, ports, "Address</th>"), "<th")
cells := rowCells(dashboardRow(t, ports, sharedPort), "<td")
if len(cells) != len(headings) {
t.Fatalf("row of %s has cells %q under headings %q",
sharedPort, cells, headings)
}
under := make(map[string]string)
for i, heading := range headings {
under[heading] = cells[i]
}
if under["Domains"] != testDomain {
t.Errorf("row of %s lists %q under Domains, want %q",
sharedPort, under["Domains"], testDomain)
}
if under["Hostnames"] != testHostname {
t.Errorf("row of %s lists %q under Hostnames, want %q",
sharedPort, under["Hostnames"], testHostname)
}
}
+26 -9
View File
@@ -32,8 +32,11 @@ type statusHostnameInfo struct {
} }
// statusPortInfo holds status information for a monitored port. // statusPortInfo holds status information for a monitored port.
// Domains and Hostnames list the apex domains and the hostnames that
// resolve to its address.
type statusPortInfo struct { type statusPortInfo struct {
Open bool `json:"open"` Open bool `json:"open"`
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"` Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"` LastChecked time.Time `json:"lastChecked"`
} }
@@ -99,7 +102,6 @@ func buildStatusResponse(
LastUpdated: snap.LastUpdated, LastUpdated: snap.LastUpdated,
Domains: make(map[string]*statusDomainInfo), Domains: make(map[string]*statusDomainInfo),
Hostnames: make(map[string]*statusHostnameInfo), Hostnames: make(map[string]*statusHostnameInfo),
Ports: make(map[string]*statusPortInfo),
Certificates: make(map[string]*statusCertificateInfo), Certificates: make(map[string]*statusCertificateInfo),
} }
@@ -107,7 +109,7 @@ func buildStatusResponse(
buildDomains(snap, domainRecords, resp) buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp) buildHostnames(hostnames, resp)
buildPorts(snap, resp) resp.Ports = buildPorts(snap)
buildCertificates(snap, resp) buildCertificates(snap, resp)
buildCounts(resp) buildCounts(resp)
@@ -195,21 +197,36 @@ func nameserverInfo(
return info return info
} }
func buildPorts( // buildPorts returns the port entries saved in snap. A port entry
snap state.Snapshot, // saves apex domains with its hostnames; they are told apart as in
resp *statusResponse, // splitHostnames, by a domain entry in snap.Domains.
) { func buildPorts(snap state.Snapshot) map[string]*statusPortInfo {
ports := make(map[string]*statusPortInfo, len(snap.Ports))
for key, ps := range snap.Ports { for key, ps := range snap.Ports {
hostnames := make([]string, len(ps.Hostnames)) domains := []string{}
copy(hostnames, ps.Hostnames) hostnames := []string{}
for _, name := range ps.Hostnames {
if _, isDomain := snap.Domains[name]; isDomain {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
sort.Strings(domains)
sort.Strings(hostnames) sort.Strings(hostnames)
resp.Ports[key] = &statusPortInfo{ ports[key] = &statusPortInfo{
Open: ps.Open, Open: ps.Open,
Domains: domains,
Hostnames: hostnames, Hostnames: hostnames,
LastChecked: ps.LastChecked, LastChecked: ps.LastChecked,
} }
} }
return ports
} }
func buildCertificates( func buildCertificates(
+45 -2
View File
@@ -21,7 +21,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. example.net is an apex domain, whose own records are saved // failed. example.net is an apex domain, whose own records are saved
// with the hostnames' records, as the watcher saves them. // with the hostnames' records, as the watcher saves them. Both names
// resolve to domainAddress, whose port 443 entry lists them.
const ( const (
testHostname = "www.example.com" testHostname = "www.example.com"
answeringNS = "ns1.example.com." answeringNS = "ns1.example.com."
@@ -32,6 +33,7 @@ const (
testDomain = "example.net" testDomain = "example.net"
domainNS = "a.iana-servers.net." domainNS = "a.iana-servers.net."
domainAddress = "192.0.2.2" domainAddress = "192.0.2.2"
sharedPort = domainAddress + ":443"
) )
// newHandlersWithFailures builds real Handlers whose state holds the // newHandlersWithFailures builds real Handlers whose state holds the
@@ -70,7 +72,9 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
st.SetHostnameState(testHostname, &state.HostnameState{ st.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
answeringNS: { answeringNS: {
Records: map[string][]string{"A": {"192.0.2.1"}}, Records: map[string][]string{
"A": {"192.0.2.1", domainAddress},
},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
@@ -106,6 +110,12 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
LastChecked: now, LastChecked: now,
}) })
st.SetPortState(sharedPort, &state.PortState{
Open: true,
Hostnames: []string{testDomain, testHostname},
LastChecked: now,
})
hnd, err := handlers.New(nil, handlers.Params{ hnd, err := handlers.New(nil, handlers.Params{
Logger: log, Logger: log,
Globals: glob, Globals: glob,
@@ -217,3 +227,36 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
testDomain, domainNS, records, domainAddress) testDomain, domainNS, records, domainAddress)
} }
} }
// TestStatusPortsTellDomainsFromHostnames checks that a port entry in
// /api/v1/status lists an apex domain in domains and a hostname in
// hostnames when both resolve to its address.
func TestStatusPortsTellDomainsFromHostnames(t *testing.T) {
t.Parallel()
body := get(t, newHandlersWithFailures(t).HandleStatus())
var resp struct {
Ports map[string]struct {
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"`
} `json:"ports"`
}
err := json.Unmarshal([]byte(body), &resp)
if err != nil {
t.Fatalf("decoding response: %v", err)
}
port := resp.Ports[sharedPort]
if !slices.Equal(port.Domains, []string{testDomain}) {
t.Errorf("port %s domains = %v, want [%s]",
sharedPort, port.Domains, testDomain)
}
if !slices.Equal(port.Hostnames, []string{testHostname}) {
t.Errorf("port %s hostnames = %v, want [%s]",
sharedPort, port.Hostnames, testHostname)
}
}
+6 -2
View File
@@ -157,19 +157,20 @@
> >
Ports Ports
</h2> </h2>
{{ if .Snapshot.Ports }} {{ if .Ports }}
<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>
<tr class="text-slate-500 uppercase tracking-wider"> <tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Address</th> <th class="py-2 px-3">Address</th>
<th class="py-2 px-3">State</th> <th class="py-2 px-3">State</th>
<th class="py-2 px-3">Domains</th>
<th class="py-2 px-3">Hostnames</th> <th class="py-2 px-3">Hostnames</th>
<th class="py-2 px-3">Checked</th> <th class="py-2 px-3">Checked</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-slate-800"> <tbody class="divide-y divide-slate-800">
{{ range $key, $ps := .Snapshot.Ports }} {{ range $key, $ps := .Ports }}
<tr class="hover:bg-surface-800/50"> <tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium"> <td class="py-2 px-3 text-slate-200 font-medium">
{{ $key }} {{ $key }}
@@ -187,6 +188,9 @@
> >
{{ end }} {{ end }}
</td> </td>
<td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Domains ", " }}
</td>
<td class="py-2 px-3 text-slate-400 break-all"> <td class="py-2 px-3 text-slate-400 break-all">
{{ joinStrings $ps.Hostnames ", " }} {{ joinStrings $ps.Hostnames ", " }}
</td> </td>