3 Commits
Author SHA1 Message Date
sneak d6a5989f8e watcher: a name removed from the targets leaves the state (closes #223)
check / check (push) Canceled after 0s
At startup, before the first check, Run removes from the loaded state
the domain, hostname and certificate entries of names no longer in
DNSWATCHER_TARGETS, so the dashboard, /api/v1/status and the startup
notification count only configured names. A configured domain's own
records, saved as a hostname entry under its name, are kept. Nothing is
notified. Each port check, next to the removal of stale port entries,
now also removes the certificate entries for an address a name no
longer resolves to, except while none of its nameservers answered, as
port entries already were.

Model: opus-5-5
2026-10-02 08:47:47 +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 299 additions and 84 deletions
+18 -10
View File
@@ -201,7 +201,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
@@ -228,10 +230,11 @@ clears them.
false-positive change notifications. false-positive change notifications.
- State is written atomically (write to temp file, then rename) to prevent - State is written atomically (write to temp file, then rename) to prevent
corruption. corruption.
- A name removed from `DNSWATCHER_TARGETS` is removed from the state at the - A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup,
first check after startup, without a notification: its domain, hostname and before the first check, without a notification: its domain, hostname and
certificate entries go, as do the port entries of addresses no configured name certificate entries go, so the dashboard and `/api/v1/status` no longer list
has. The dashboard and `/api/v1/status` then no longer list or count it. or count it. The first check's port checks remove the port entries of
addresses no configured name has.
- Each port check also removes the certificate entries for an address their name - Each port check also removes the certificate entries for an address their name
no longer resolves to, except while none of the name's nameservers answer. no longer resolves to, except while none of the name's nameservers answer.
@@ -245,7 +248,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),
@@ -278,7 +282,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,
@@ -567,8 +573,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.
--- ---
@@ -712,7 +719,8 @@ docker run -d \
1. **Startup**: Check that the data directory can be written, and exit with an 1. **Startup**: Check that the data directory can be written, and exit with an
error naming it if not. Load state from disk. If no state file exists, start error naming it if not. Load state from disk. If no state file exists, start
with empty state (first check will establish baseline without triggering with empty state (first check will establish baseline without triggering
change notifications). change notifications). Remove from the state the names no longer in
`DNSWATCHER_TARGETS` (see State Management).
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on 2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
startup. startup.
3. **Periodic checks** (DNS always runs first): 3. **Periodic checks** (DNS always runs first):
+5 -1
View File
@@ -20,7 +20,11 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
# Completed Steps # Completed Steps
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so - 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
the dashboard and API, at the first check after startup (closes #223). the dashboard and API, at startup, before the first check (closes #223).
- 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>
+5
View File
@@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
w.maybeSendTestNotification(ctx) w.maybeSendTestNotification(ctx)
} }
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
func (w *Watcher) CleanupRemovedTargets() {
w.cleanupRemovedTargets()
}
// 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)
+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)
}
}
+5 -10
View File
@@ -11,11 +11,10 @@ import (
// TestRemovedTargetsLeaveTheState loads a state saved while a domain // TestRemovedTargetsLeaveTheState loads a state saved while a domain
// and a hostname now removed from the configuration were still in it, // and a hostname now removed from the configuration were still in it,
// and runs the port checks, which the first check after startup runs // and runs the removal that Run does before the first check. The
// after its DNS checks. The removed names' domain, hostname and // removed names' domain, hostname and certificate entries are gone,
// certificate entries are gone, the configured names' are kept, and // the configured names' are kept, and nothing is notified. Nothing is
// nothing is notified. Nothing is looked up: the watcher has no // looked up: the watcher has no resolver.
// resolver.
func TestRemovedTargetsLeaveTheState(t *testing.T) { func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Parallel() t.Parallel()
@@ -34,10 +33,6 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
deps.portChecker, deps.tlsChecker, deps.notifier, deps.portChecker, deps.tlsChecker, deps.notifier,
) )
// A state file is loaded, so changes are notified from the first
// check on.
w.SetFirstRun(false)
// The state a check of all four names saves, each name at ip1. // The state a check of all four names saves, each name at ip1.
for _, name := range []string{domain, removedDomain} { for _, name := range []string{domain, removedDomain} {
deps.state.SetDomainState(name, &state.DomainState{ deps.state.SetDomainState(name, &state.DomainState{
@@ -66,7 +61,7 @@ func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Fatalf("loading the state: %v", err) t.Fatalf("loading the state: %v", err)
} }
w.CheckAllPorts(t.Context()) w.CleanupRemovedTargets()
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
+73 -39
View File
@@ -130,6 +130,7 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(), "tlsInterval", w.config.TLSInterval.String(),
) )
w.cleanupRemovedTargets()
w.RunOnce(ctx) w.RunOnce(ctx)
w.maybeSendTestNotification(ctx) w.maybeSendTestNotification(ctx)
@@ -163,6 +164,31 @@ func (w *Watcher) Run(ctx context.Context) {
} }
} }
// cleanupRemovedTargets removes from the loaded state the domain,
// hostname and certificate entries of names no longer in the
// configuration, which changes only at a restart. Nothing is notified.
// A configured domain's own records are saved as a hostname entry under
// its name, which is kept.
func (w *Watcher) cleanupRemovedTargets() {
for _, name := range w.state.GetAllDomainNames() {
if !w.isDomain(name) {
w.state.DeleteDomainState(name)
}
}
for _, name := range w.state.GetAllHostnames() {
if !w.isConfigured(name) {
w.state.DeleteHostnameState(name)
}
}
for _, key := range w.state.GetAllCertificateKeys() {
if _, hostname := parseCertKey(key); !w.isConfigured(hostname) {
w.state.DeleteCertificateState(key)
}
}
}
// RunOnce performs a single complete monitoring cycle. // RunOnce performs a single complete monitoring cycle.
// DNS checks run first so that port and TLS checks use // DNS checks run first so that port and TLS checks use
// freshly resolved IP addresses. Port checks run before // freshly resolved IP addresses. Port checks run before
@@ -542,17 +568,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
@@ -774,11 +833,9 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
} }
// Phase 3: Remove port state entries that no longer have // Phase 3: Remove port state entries that no longer have
// any hostname referencing them, the domain, hostname and // any hostname referencing them, and certificate entries for
// certificate entries of names no longer configured, and // an address their name no longer has.
// certificate entries for an address their name no longer has.
w.cleanupStalePorts(associations) w.cleanupStalePorts(associations)
w.cleanupRemovedTargets()
w.cleanupStaleCertificates() w.cleanupStaleCertificates()
} }
@@ -863,38 +920,16 @@ func (w *Watcher) cleanupStalePorts(
} }
} }
// cleanupRemovedTargets removes the domain and hostname state entries // cleanupStaleCertificates removes the certificate entries for an
// of names no longer in the configuration. A configured domain's own // address their name no longer resolves to. An entry saved for a name
// records are saved as a hostname entry under its name, which is kept. // none of whose nameservers answered is kept: that name's addresses are
func (w *Watcher) cleanupRemovedTargets() { // not known, not gone.
for _, name := range w.state.GetAllDomainNames() {
if !slices.Contains(w.config.Domains, name) {
w.state.DeleteDomainState(name)
}
}
for _, name := range w.state.GetAllHostnames() {
if !w.isConfigured(name) {
w.state.DeleteHostnameState(name)
}
}
}
// cleanupStaleCertificates removes the certificate entries of names no
// longer configured, and those for an address their name no longer
// resolves to. An entry saved for a configured name none of whose
// nameservers answered is kept: that name's addresses are not known,
// not gone.
func (w *Watcher) cleanupStaleCertificates() { func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() { for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key) ip, hostname := parseCertKey(key)
if w.isConfigured(hostname) && if slices.Contains(w.collectIPs(hostname), ip) ||
slices.Contains(w.collectIPs(hostname), ip) { w.noNameserverAnswered(hostname) {
continue
}
if w.noNameserverAnswered(hostname) {
continue continue
} }
@@ -917,8 +952,7 @@ func parseCertKey(key string) (string, string) {
// isConfigured reports whether name is a configured domain or hostname. // isConfigured reports whether name is a configured domain or hostname.
func (w *Watcher) isConfigured(name string) bool { func (w *Watcher) isConfigured(name string) bool {
return slices.Contains(w.config.Domains, name) || return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
slices.Contains(w.config.Hostnames, name)
} }
// noNameserverAnswered reports whether name is a configured domain or // noNameserverAnswered reports whether name is a configured domain or
@@ -1013,8 +1047,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(