From 5388a112655e0e436d633f73ed00f9ff95280cf4 Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 2 Oct 2026 08:34:36 +0000 Subject: [PATCH] watcher: Port Change notifications list domains apart from hostnames (closes #248) 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 --- README.md | 4 ++- TODO.md | 2 ++ internal/watcher/message_test.go | 48 ++++++++++++++++++++++++++++++++ internal/watcher/watcher.go | 43 ++++++++++++++++++++++++---- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 54db1ad..6650221 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,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 diff --git a/TODO.md b/TODO.md index 54a414c..2d97fbb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 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, diff --git a/internal/watcher/message_test.go b/internal/watcher/message_test.go index 4659076..e446bd2 100644 --- a/internal/watcher/message_test.go +++ b/internal/watcher/message_test.go @@ -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) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 87ab71a..0a09014 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -542,17 +542,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 @@ -952,8 +985,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( -- 2.54.0