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) Successful in 1m27s
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 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 or domain 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, 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 - **TLS expiry warnings**: Expiry date and days remaining, CN, associated
hostname and IP. hostname and IP.
- **TLS certificate changes**: Old and new CN and issuer, associated hostname - **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. 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),
@@ -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 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,
@@ -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 earlier check saved one. A state file without it loads, and the first check
after that saves it without a notification. after that saves it without a 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.
--- ---
+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 - 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). 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, - 2026-10-02: nameservers a referral names without addresses are looked up,
three deep at most; `pool.ntp.org`'s nameservers resolve (closes #221). 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 - 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 // 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(`<[^>]*>`)
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. // 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(
+60 -12
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
@@ -65,12 +67,31 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
t.Fatalf("state.New: %v", err) 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() now := time.Now()
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,17 +127,11 @@ func newHandlersWithFailures(t *testing.T) *handlers.Handlers {
LastChecked: now, LastChecked: now,
}) })
hnd, err := handlers.New(nil, handlers.Params{ st.SetPortState(sharedPort, &state.PortState{
Logger: log, Open: true,
Globals: glob, Hostnames: []string{testDomain, testHostname},
State: st, LastChecked: now,
Notify: notifier,
}) })
if err != nil {
t.Fatalf("handlers.New: %v", err)
}
return hnd
} }
// get serves one GET request to handler and returns the response body. // 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) 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>
+19 -10
View File
@@ -8,6 +8,7 @@ import (
"net" "net"
"slices" "slices"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -724,14 +725,15 @@ func (r *Resolver) queryTypes(
} }
type queryState struct { type queryState struct {
gotNXDomain bool gotNXDomain bool
errorReply string // code of an error reply, such as SERVFAIL gotErrorReply bool
gotRefused bool errorReply string // its code, such as SERVFAIL, or number if unnamed
gotTimeout bool gotRefused bool
gotReferral bool gotTimeout bool
netErr error gotReferral bool
hasRecords bool netErr error
answered bool hasRecords bool
answered bool
} }
// queryEachType asks the nameserver at nsIP about hostname once for each // queryEachType asks the nameserver at nsIP about hostname once for each
@@ -833,7 +835,14 @@ func readReply(
} }
if isErrorReply(msg) { 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( return fmt.Errorf(
"server returned %s: %w", state.errorReply, ErrUnusableReply, "server returned %s: %w", state.errorReply, ErrUnusableReply,
@@ -912,7 +921,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotTimeout && !state.answered: case state.gotTimeout && !state.answered:
resp.Status = StatusTimeout resp.Status = StatusTimeout
resp.Error = "all queries timed out" resp.Error = "all queries timed out"
case state.errorReply != "" && !state.answered: case state.gotErrorReply && !state.answered:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned " + state.errorReply resp.Error = "server returned " + state.errorReply
case state.gotRefused && !state.answered: case state.gotRefused && !state.answered:
+30 -15
View File
@@ -1,11 +1,13 @@
package resolver package resolver
import ( import (
"strconv"
"syscall" "syscall"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
// TestClassifyResponse sets a nameserver's status from the results of // 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", "some types answered with no records, another got SERVFAIL",
queryState{answered: true, errorReply: "SERVFAIL"}, queryState{
answered: true, gotErrorReply: true, errorReply: "SERVFAIL",
},
StatusNoData, "", StatusNoData, "",
}, },
{ {
@@ -60,7 +64,7 @@ func TestClassifyResponse(t *testing.T) {
}, },
{ {
"every query got NOTIMP", "every query got NOTIMP",
queryState{errorReply: "NOTIMP"}, queryState{gotErrorReply: true, errorReply: "NOTIMP"},
StatusError, "server returned 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, // TestReadReply checks which replies to a query about one record type,
// built here, are an answer: one with the code NOERROR or NXDOMAIN. A // 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) { func TestReadReply(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
rcode int rcode int
answered bool wantStatus string
wantError string
}{ }{
{dns.RcodeSuccess, true}, {dns.RcodeSuccess, StatusNoData, ""},
{dns.RcodeNameError, true}, {dns.RcodeNameError, StatusNXDomain, ""},
{dns.RcodeServerFailure, false}, {dns.RcodeServerFailure, StatusError, "server returned SERVFAIL"},
{dns.RcodeNotImplemented, false}, {dns.RcodeNotImplemented, StatusError, "server returned NOTIMP"},
{dns.RcodeFormatError, false}, {dns.RcodeFormatError, StatusError, "server returned FORMERR"},
{12, StatusError, "server returned 12"}, // unassigned, no name
} }
for _, tt := range tests { 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() t.Parallel()
msg := new(dns.Msg) msg := new(dns.Msg)
@@ -104,13 +112,20 @@ func TestReadReply(t *testing.T) {
msg.Rcode = tt.rcode msg.Rcode = tt.rcode
resp := &NameserverResponse{Records: map[string][]string{}} resp := &NameserverResponse{Records: map[string][]string{}}
err := readReply(msg, resp, &queryState{})
if tt.answered { var state queryState
assert.NoError(t, err)
err := readReply(msg, resp, &state)
classifyResponse(resp, state)
if tt.wantStatus == StatusError {
require.ErrorIs(t, err, ErrUnusableReply)
} else { } 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) 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) 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: // nameLine is the line a notification about name's records starts with:
// "Domain: " and the name for a configured apex domain, whose own // "Domain: " and the name for a configured apex domain, and
// records are checked as a hostname's are, and "Hostname: " otherwise. // "Hostname: " otherwise.
func (w *Watcher) nameLine(name string) string { func (w *Watcher) nameLine(name string) string {
if slices.Contains(w.config.Domains, name) { if w.isDomain(name) {
return "Domain: " + name return "Domain: " + name
} }
return "Hostname: " + 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 // 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
@@ -1006,8 +1039,8 @@ func (w *Watcher) checkSinglePort(
} }
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hosts: %s\nAddress: %s\nPort now %s", "%s\nAddress: %s\nPort now %s",
strings.Join(hostnames, ", "), key, stateStr, w.portNameLines(hostnames), key, stateStr,
) )
w.notify.SendNotification( w.notify.SendNotification(