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, takes those names off each port entry's list of
names and removes a port entry left with none, 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
This commit was merged in pull request #246.
This commit is contained in:
2026-10-02 11:17:59 +02:00
parent 9b9e26d6b2
commit 6332b48379
6 changed files with 398 additions and 4 deletions
+64
View File
@@ -298,6 +298,27 @@ func (s *State) GetDomainState(
return ds, ok
}
// DeleteDomainState removes a domain state entry.
func (s *State) DeleteDomainState(domain string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Domains, domain)
}
// GetAllDomainNames returns the names of all domain state entries.
func (s *State) GetAllDomainNames() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.snapshot.Domains))
for name := range s.snapshot.Domains {
names = append(names, name)
}
return names
}
// SetHostnameState updates the state for a hostname.
func (s *State) SetHostnameState(
hostname string,
@@ -321,6 +342,28 @@ func (s *State) GetHostnameState(
return hs, ok
}
// DeleteHostnameState removes a hostname state entry.
func (s *State) DeleteHostnameState(hostname string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Hostnames, hostname)
}
// GetAllHostnames returns the names of all hostname state entries,
// which include each apex domain's own records.
func (s *State) GetAllHostnames() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.snapshot.Hostnames))
for name := range s.snapshot.Hostnames {
names = append(names, name)
}
return names
}
// SetPortState updates the state for a port.
func (s *State) SetPortState(key string, ps *PortState) {
s.mu.Lock()
@@ -383,6 +426,27 @@ func (s *State) GetCertificateState(
return cs, ok
}
// DeleteCertificateState removes a certificate state entry.
func (s *State) DeleteCertificateState(key string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.snapshot.Certificates, key)
}
// GetAllCertificateKeys returns all certificate state keys.
func (s *State) GetAllCertificateKeys() []string {
s.mu.RLock()
defer s.mu.RUnlock()
keys := make([]string, 0, len(s.snapshot.Certificates))
for k := range s.snapshot.Certificates {
keys = append(keys, k)
}
return keys
}
// checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an
// unwritable directory stops the process, instead of the process running
+5
View File
@@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
w.maybeSendTestNotification(ctx)
}
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
func (w *Watcher) CleanupRemovedTargets() {
w.cleanupRemovedTargets()
}
// CheckAllPorts exports checkAllPorts for testing.
func (w *Watcher) CheckAllPorts(ctx context.Context) {
w.checkAllPorts(ctx)
+224
View File
@@ -0,0 +1,224 @@
package watcher_test
import (
"maps"
"slices"
"testing"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// TestRemovedTargetsLeaveTheState loads a state saved while a domain
// and a hostname now removed from the configuration were still in it,
// and runs the removal that Run does before the first check. The
// removed names' domain, hostname and certificate entries are gone,
// the configured names' are kept, and nothing is notified. Nothing is
// looked up: the watcher has no resolver.
func TestRemovedTargetsLeaveTheState(t *testing.T) {
t.Parallel()
const (
removedDomain = "example.com"
removedHost = "www.example.com"
)
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,
)
// The state a check of all four names saves, each name at ip1.
for _, name := range []string{domain, removedDomain} {
deps.state.SetDomainState(name, &state.DomainState{
Nameservers: []string{nsA},
})
}
for _, name := range []string{domain, host, removedDomain, removedHost} {
deps.state.SetHostnameState(name, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {ip1}}),
},
))
deps.state.SetCertificateState(
ip1+":443:"+name, &state.CertificateState{Status: "ok"},
)
}
err := deps.state.Save()
if err != nil {
t.Fatalf("saving the state: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state: %v", err)
}
w.CleanupRemovedTargets()
snap := deps.state.GetSnapshot()
got := slices.Sorted(maps.Keys(snap.Domains))
if want := []string{domain}; !slices.Equal(got, want) {
t.Errorf("domain entries %v, want %v", got, want)
}
got = slices.Sorted(maps.Keys(snap.Hostnames))
if want := []string{domain, host}; !slices.Equal(got, want) {
t.Errorf("hostname entries %v, want %v", got, want)
}
got = slices.Sorted(maps.Keys(snap.Certificates))
if want := []string{
ip1 + ":443:" + domain, ip1 + ":443:" + host,
}; !slices.Equal(got, want) {
t.Errorf("certificate entries %v, want %v", got, want)
}
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
}
// TestRemovedTargetsLeaveThePortEntries loads a state whose port
// entries name a domain and a hostname now removed from the
// configuration, and runs the removal that Run does before the first
// check. The removed names are off each port entry's list of names, the
// entry only they had is gone, the entry that also names configured
// names is kept for the port checks, and nothing is notified.
func TestRemovedTargetsLeaveThePortEntries(t *testing.T) {
t.Parallel()
const (
removedDomain = "example.com"
removedHost = "www.example.com"
)
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,
)
// The port 443 entries a check of all four names saves: each name
// at ip1, except the removed hostname, at ip2.
deps.state.SetPortState(ip1+":443", &state.PortState{
Open: true, Hostnames: []string{removedDomain, domain, host},
})
deps.state.SetPortState(ip2+":443", &state.PortState{
Open: true, Hostnames: []string{removedHost},
})
err := deps.state.Save()
if err != nil {
t.Fatalf("saving the state: %v", err)
}
err = deps.state.Load()
if err != nil {
t.Fatalf("loading the state: %v", err)
}
w.CleanupRemovedTargets()
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
snap := deps.state.GetSnapshot()
got := slices.Sorted(maps.Keys(snap.Ports))
if want := []string{ip1 + ":443"}; !slices.Equal(got, want) {
t.Fatalf("port entries %v, want %v", got, want)
}
got = snap.Ports[ip1+":443"].Hostnames
if want := []string{domain, host}; !slices.Equal(got, want) {
t.Errorf("names of port entry %s:443 %v, want %v", ip1, got, want)
}
}
// TestCertificateStateForAnAddressGone runs the port checks on hostname
// state built here for a configured hostname, with certificate entries
// saved for it at ip1, ip2 and an IPv6 address. When its nameservers
// answered with ip1 and the IPv6 address, the entry for ip2 is removed.
// When none of them answered, its addresses are not known, and every
// entry is kept. Nothing is notified, and nothing is looked up.
func TestCertificateStateForAnAddressGone(t *testing.T) {
t.Parallel()
const ip6 = "2001:db8::1"
tests := []struct {
name string
hostname *state.HostnameState
want []string
}{
{
"answered without ip2",
saved(map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{
"A": {ip1}, "AAAA": {ip6},
}),
}),
[]string{ip1 + ":443:" + host, ip6 + ":443:" + host},
},
{
"no nameserver answered",
saved(map[string]*state.NameserverRecordState{
nsA: failed(), nsB: failed(),
}),
[]string{
ip1 + ":443:" + host,
ip2 + ":443:" + host,
ip6 + ":443:" + host,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host}
deps := newTestDeps(t, cfg)
w := watcher.NewForTest(
cfg, deps.state, nil,
deps.portChecker, deps.tlsChecker, deps.notifier,
)
w.SetFirstRun(false)
deps.state.SetHostnameState(host, tt.hostname)
for _, ip := range []string{ip1, ip2, ip6} {
deps.state.SetCertificateState(
ip+":443:"+host, &state.CertificateState{Status: "ok"},
)
}
w.CheckAllPorts(t.Context())
got := slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
if !slices.Equal(got, tt.want) {
t.Errorf("certificate entries %v, want %v", got, tt.want)
}
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
t.Errorf("sent %v, want nothing", sent)
}
})
}
}
+93 -3
View File
@@ -131,6 +131,7 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval.String(),
)
w.cleanupRemovedTargets()
w.RunOnce(ctx)
w.maybeSendTestNotification(ctx)
@@ -201,6 +202,59 @@ func (w *Watcher) detectFirstRun() {
}
}
// 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, and takes those names
// off each port entry's list of names, removing a port entry left with
// none. 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)
}
}
for _, key := range w.state.GetAllPortKeys() {
ps, ok := w.state.GetPortState(key)
if !ok {
continue
}
var names []string
for _, name := range ps.Hostnames {
if w.isConfigured(name) {
names = append(names, name)
}
}
if len(names) == 0 {
w.state.DeletePortState(key)
continue
}
w.state.SetPortState(key, &state.PortState{
Open: ps.Open,
Hostnames: names,
LastChecked: ps.LastChecked,
})
}
}
// runDNSChecks performs DNS resolution for all configured domains
// and hostnames, updating state with freshly resolved records.
// This must complete before port or TLS checks run so those
@@ -861,8 +915,10 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
}
// Phase 3: Remove port state entries that no longer have
// any hostname referencing them.
// any hostname referencing them, and certificate entries for
// an address their name no longer has.
w.cleanupStalePorts(associations)
w.cleanupStaleCertificates()
}
// buildPortAssociations constructs a map from IP:port keys to
@@ -946,11 +1002,45 @@ func (w *Watcher) cleanupStalePorts(
}
}
// cleanupStaleCertificates removes the certificate entries for an
// address their name no longer resolves to. An entry saved for a name
// none of whose nameservers answered is kept: that name's addresses are
// not known, not gone.
func (w *Watcher) cleanupStaleCertificates() {
for _, key := range w.state.GetAllCertificateKeys() {
ip, hostname := parseCertKey(key)
if slices.Contains(w.collectIPs(hostname), ip) ||
w.noNameserverAnswered(hostname) {
continue
}
w.state.DeleteCertificateState(key)
}
}
// parseCertKey splits an "ip:port:hostname" certificate key into its
// address and hostname.
func parseCertKey(key string) (string, string) {
lastColon := strings.LastIndex(key, ":")
if lastColon < 0 {
return "", key
}
ip, _ := parsePortKey(key[:lastColon])
return ip, key[lastColon+1:]
}
// isConfigured reports whether name is a configured domain or hostname.
func (w *Watcher) isConfigured(name string) bool {
return w.isDomain(name) || slices.Contains(w.config.Hostnames, name)
}
// noNameserverAnswered reports whether name is a configured domain or
// hostname and none of its nameservers answered on its last check.
func (w *Watcher) noNameserverAnswered(name string) bool {
if !slices.Contains(w.config.Hostnames, name) &&
!slices.Contains(w.config.Domains, name) {
if !w.isConfigured(name) {
return false
}