3 Commits
Author SHA1 Message Date
sneak 9282119b41 resolver, watcher: a record type whose query fails keeps its previous records (closes #231)
check / check (push) Waiting to run
The resolver lists in FailedTypes each record type whose query to a
nameserver got no usable reply (no reply, a code other than NOERROR or
NXDOMAIN, a referral, or a truncated reply whose TCP retry failed) and
logs it unless shutdown cut it short. A nameserver that answered no
type has failed.
The watcher saves such a type in failedTypes with the previous check's
records, leaves it out of the comparison with other nameservers on that
check, and compares it with the next answer. When the previous check
did not know its records either, it is also in unknownTypes and not
compared until it answers. A nameserver whose A, AAAA or CNAME query
failed is no answer when following a CNAME or resolving addresses.

Model: opus-5-5
2026-10-02 08:43:19 +00:00
clawbot 9b524e9d63 watcher: Port Change notifications list domains apart from hostnames (closes #248)
check / check (push) Canceled after 0s
A Port Change notification's `Hosts:` line listed a port's apex domains
and hostnames together. It now has a `Domains:` line and a `Hostnames:`
line, and leaves out one that would name nothing. A name is a domain
when it is a configured domain, the rule record notifications already
use to start `Domain:` or `Hostname:`; that rule is now one method,
isDomain, which both use. The port entries saved in the state are
unchanged. README describes the new lines.

Model: opus-5-5
2026-10-02 10:42:07 +02:00
clawbot b43402a631 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
2026-10-02 10:31:07 +02:00
11 changed files with 295 additions and 59 deletions
+11 -5
View File
@@ -216,7 +216,9 @@ includes:
- **NS recoveries**: Which nameserver recovered, which hostname/domain.
- **NS inconsistencies**: Which nameservers disagree, what each one returned,
which hostname or domain affected.
- **Port changes**: Which IP:port, its new state, all associated hostnames.
- **Port changes**: Which IP:port, its new state, and the domains and the
hostnames that resolve to it, on a `Domains:` line and a `Hostnames:` line. A
line that would name nothing is left out.
- **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname
@@ -254,7 +256,8 @@ dnswatcher includes an unauthenticated, read-only web dashboard at the root URL
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.
- **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,
the reason is shown in place of CN, issuer and expiry.
- **Recent alerts** (last 100 notifications sent since the process started),
@@ -287,7 +290,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
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.
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
Basic Auth. It has the Prometheus Go client's default metrics only (Go runtime,
@@ -583,8 +588,9 @@ for A, AAAA and CNAME, the 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 notification.
A port entry in the older format, with one `hostname` instead of the `hostnames`
list, loads as a list of that one name.
A port entry's `hostnames` lists every name that resolves to its address,
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.
---
+4
View File
@@ -21,6 +21,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous
records and alerts nothing; the other types are still saved (closes #231).
- 2026-10-02: a Port Change notification lists the port's domains on a
`Domains:` line and its hostnames on a `Hostnames:` line (closes #248).
- 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: nameservers a referral names without addresses are looked up,
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
+5 -1
View File
@@ -45,11 +45,14 @@ func newDashboardTemplate() *template.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).
// 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 {
Snapshot state.Snapshot
Hostnames map[string]*state.HostnameState
DomainRecords map[string]*state.HostnameState
Ports map[string]*statusPortInfo
Alerts []notify.AlertEntry
StateAge string
GeneratedAt string
@@ -71,6 +74,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
Snapshot: snap,
Hostnames: hostnames,
DomainRecords: domainRecords,
Ports: buildPorts(snap),
Alerts: alerts,
StateAge: relTime(snap.LastUpdated),
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)
}
}
// 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(`<[^>]*>`)
parts := strings.Split(row, tag)[1:]
cells := make([]string, 0, len(parts))
for _, cell := range parts {
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.
// Domains and Hostnames list the apex domains and the hostnames that
// resolve to its address.
type statusPortInfo struct {
Open bool `json:"open"`
Domains []string `json:"domains"`
Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"`
}
@@ -99,7 +102,6 @@ func buildStatusResponse(
LastUpdated: snap.LastUpdated,
Domains: make(map[string]*statusDomainInfo),
Hostnames: make(map[string]*statusHostnameInfo),
Ports: make(map[string]*statusPortInfo),
Certificates: make(map[string]*statusCertificateInfo),
}
@@ -107,7 +109,7 @@ func buildStatusResponse(
buildDomains(snap, domainRecords, resp)
buildHostnames(hostnames, resp)
buildPorts(snap, resp)
resp.Ports = buildPorts(snap)
buildCertificates(snap, resp)
buildCounts(resp)
@@ -195,21 +197,36 @@ func nameserverInfo(
return info
}
func buildPorts(
snap state.Snapshot,
resp *statusResponse,
) {
// buildPorts returns the port entries saved in snap. A port entry
// saves apex domains with its hostnames; they are told apart as in
// 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 {
hostnames := make([]string, len(ps.Hostnames))
copy(hostnames, ps.Hostnames)
domains := []string{}
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)
resp.Ports[key] = &statusPortInfo{
ports[key] = &statusPortInfo{
Open: ps.Open,
Domains: domains,
Hostnames: hostnames,
LastChecked: ps.LastChecked,
}
}
return ports
}
func buildCertificates(
+60 -12
View File
@@ -21,7 +21,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. 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 (
testHostname = "www.example.com"
answeringNS = "ns1.example.com."
@@ -32,6 +33,7 @@ const (
testDomain = "example.net"
domainNS = "a.iana-servers.net."
domainAddress = "192.0.2.2"
sharedPort = domainAddress + ":443"
)
// newHandlersWithFailures builds real Handlers whose state holds the
@@ -65,12 +67,31 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
t.Fatalf("state.New: %v", err)
}
setTestState(st)
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
Notify: notifier,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
}
// setTestState sets the entries described above in st.
func setTestState(st *state.State) {
now := time.Now()
st.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
answeringNS: {
Records: map[string][]string{"A": {"192.0.2.1"}},
Records: map[string][]string{
"A": {"192.0.2.1", domainAddress},
},
Status: "ok",
LastChecked: now,
},
@@ -106,17 +127,11 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
LastChecked: now,
})
hnd, err := handlers.New(nil, handlers.Params{
Logger: log,
Globals: glob,
State: st,
Notify: notifier,
st.SetPortState(sharedPort, &state.PortState{
Open: true,
Hostnames: []string{testDomain, testHostname},
LastChecked: now,
})
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
}
// get serves one GET request to handler and returns the response body.
@@ -217,3 +232,36 @@ func TestStatusGivesDomainRecordsUnderTheDomain(t *testing.T) {
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
</h2>
{{ if .Snapshot.Ports }}
{{ if .Ports }}
<div class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead>
<tr class="text-slate-500 uppercase tracking-wider">
<th class="py-2 px-3">Address</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">Checked</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{{ range $key, $ps := .Snapshot.Ports }}
{{ range $key, $ps := .Ports }}
<tr class="hover:bg-surface-800/50">
<td class="py-2 px-3 text-slate-200 font-medium">
{{ $key }}
@@ -187,6 +188,9 @@
>
{{ end }}
</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">
{{ joinStrings $ps.Hostnames ", " }}
</td>
+19 -10
View File
@@ -8,6 +8,7 @@ import (
"net"
"slices"
"sort"
"strconv"
"strings"
"time"
@@ -724,14 +725,15 @@ func (r *Resolver) queryTypes(
}
type queryState struct {
gotNXDomain bool
errorReply string // code of an error reply, such as SERVFAIL
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
gotNXDomain bool
gotErrorReply bool
errorReply string // its code, such as SERVFAIL, or number if unnamed
gotRefused bool
gotTimeout bool
gotReferral bool
netErr error
hasRecords bool
answered bool
}
// queryEachType asks the nameserver at nsIP about hostname once for each
@@ -833,7 +835,14 @@ func readReply(
}
if isErrorReply(msg) {
state.errorReply = dns.RcodeToString[msg.Rcode]
state.gotErrorReply = true
code, named := dns.RcodeToString[msg.Rcode]
if !named {
code = strconv.Itoa(msg.Rcode)
}
state.errorReply = code
return fmt.Errorf(
"server returned %s: %w", state.errorReply, ErrUnusableReply,
@@ -912,7 +921,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.errorReply != "" && !state.answered:
case state.gotErrorReply && !state.answered:
resp.Status = StatusError
resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.answered:
+30 -15
View File
@@ -1,11 +1,13 @@
package resolver
import (
"strconv"
"syscall"
"testing"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestClassifyResponse sets a nameserver's status from the results of
@@ -29,7 +31,9 @@ func TestClassifyResponse(t *testing.T) {
},
{
"some types answered with no records, another got SERVFAIL",
queryState{answered: true, errorReply: "SERVFAIL"},
queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
StatusNoData, "",
},
{
@@ -60,7 +64,7 @@ func TestClassifyResponse(t *testing.T) {
},
{
"every query got NOTIMP",
queryState{errorReply: "NOTIMP"},
queryState{gotErrorReply: true, errorReply: "NOTIMP"},
StatusError, "server returned NOTIMP",
},
}
@@ -80,23 +84,27 @@ func TestClassifyResponse(t *testing.T) {
// TestReadReply checks which replies to a query about one record type,
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A
// reply with any other code is not, and the type's query has failed.
// reply with any other code is not, and the type's query has failed; a
// nameserver whose only reply it is has failed, and Error gives the
// code, or its number when the code has no name.
func TestReadReply(t *testing.T) {
t.Parallel()
tests := []struct {
rcode int
answered bool
rcode int
wantStatus string
wantError string
}{
{dns.RcodeSuccess, true},
{dns.RcodeNameError, true},
{dns.RcodeServerFailure, false},
{dns.RcodeNotImplemented, false},
{dns.RcodeFormatError, false},
{dns.RcodeSuccess, StatusNoData, ""},
{dns.RcodeNameError, StatusNXDomain, ""},
{dns.RcodeServerFailure, StatusError, "server returned SERVFAIL"},
{dns.RcodeNotImplemented, StatusError, "server returned NOTIMP"},
{dns.RcodeFormatError, StatusError, "server returned FORMERR"},
{12, StatusError, "server returned 12"}, // unassigned, no name
}
for _, tt := range tests {
t.Run(dns.RcodeToString[tt.rcode], func(t *testing.T) {
t.Run(strconv.Itoa(tt.rcode), func(t *testing.T) {
t.Parallel()
msg := new(dns.Msg)
@@ -104,13 +112,20 @@ func TestReadReply(t *testing.T) {
msg.Rcode = tt.rcode
resp := &NameserverResponse{Records: map[string][]string{}}
err := readReply(msg, resp, &queryState{})
if tt.answered {
assert.NoError(t, err)
var state queryState
err := readReply(msg, resp, &state)
classifyResponse(resp, state)
if tt.wantStatus == StatusError {
require.ErrorIs(t, err, ErrUnusableReply)
} else {
assert.ErrorIs(t, err, ErrUnusableReply)
require.NoError(t, err)
}
assert.Equal(t, tt.wantStatus, resp.Status)
assert.Equal(t, tt.wantError, resp.Error)
})
}
}
+48
View File
@@ -165,3 +165,51 @@ func TestStartupNotificationCountsConfiguredNames(t *testing.T) {
t.Errorf("sent %v, want one message with %q", notifications, counts)
}
}
// A Port Change notification lists the configured apex domain and the
// hostname that resolve to the port's address on separate lines. The
// port checks read the saved hostname state and look nothing up, so the
// watcher has no resolver.
func TestPortChangeListsDomainsApartFromHostnames(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{domain}
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
// Both names resolve to ip1, whose port 443 the previous check
// found open. It is closed now.
for _, name := range []string{domain, host} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
}
key := ip1 + ":443"
deps.state.SetPortState(key, &state.PortState{
Open: true, Hostnames: []string{domain, host},
})
deps.portChecker.closed = true
w.CheckAllPorts(t.Context())
title := "Port Change: " + key
want := `Domains: example.net
Hostnames: www.example.net
Address: 192.0.2.1:443
Port now closed`
got := deps.notifier.getNotifications()
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
}
}
+38 -5
View File
@@ -581,17 +581,50 @@ func (w *Watcher) detectHostnameChanges(
w.detectCNAMEAddressChanges(ctx, hostname, prev, current)
}
// isDomain reports whether name is a configured apex domain, whose own
// records are checked and saved as a hostname's are.
func (w *Watcher) isDomain(name string) bool {
return slices.Contains(w.config.Domains, name)
}
// 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.
// "Domain: " and the name for a configured apex domain, and
// "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string {
if slices.Contains(w.config.Domains, name) {
if w.isDomain(name) {
return "Domain: " + name
}
return "Hostname: " + name
}
// portNameLines lists the names that resolve to a port's address, the
// configured apex domains on one line and the hostnames on the next,
// leaving out a line that would name nothing.
func (w *Watcher) portNameLines(names []string) string {
var domains, hostnames []string
for _, name := range names {
if w.isDomain(name) {
domains = append(domains, name)
} else {
hostnames = append(hostnames, name)
}
}
var lines []string
if len(domains) > 0 {
lines = append(lines, "Domains: "+strings.Join(domains, ", "))
}
if len(hostnames) > 0 {
lines = append(lines, "Hostnames: "+strings.Join(hostnames, ", "))
}
return strings.Join(lines, "\n")
}
// 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
@@ -1006,8 +1039,8 @@ func (w *Watcher) checkSinglePort(
}
msg := fmt.Sprintf(
"Hosts: %s\nAddress: %s\nPort now %s",
strings.Join(hostnames, ", "), key, stateStr,
"%s\nAddress: %s\nPort now %s",
w.portNameLines(hostnames), key, stateStr,
)
w.notify.SendNotification(