diff --git a/README.md b/README.md index 6650221..989e67e 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,13 @@ clears them. false-positive change notifications. - State is written atomically (write to temp file, then rename) to prevent corruption. +- A name removed from `DNSWATCHER_TARGETS` is removed from the state at startup, + before the first check, without a notification: its domain, hostname and + certificate entries go, so the dashboard and `/api/v1/status` no longer list + 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 + no longer resolves to, except while none of the name's nameservers answer. ### Web Dashboard @@ -712,7 +719,8 @@ docker run -d \ 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 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 startup. 3. **Periodic checks** (DNS always runs first): diff --git a/TODO.md b/TODO.md index 2d97fbb..65a5f4d 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 name removed from `DNSWATCHER_TARGETS` leaves the state, and so + 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 diff --git a/internal/state/state.go b/internal/state/state.go index 4f5a325..8198462 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -291,6 +291,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, @@ -314,6 +335,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() @@ -376,6 +419,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 diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index e932a2c..d9bf5cc 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -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) diff --git a/internal/watcher/removed_test.go b/internal/watcher/removed_test.go new file mode 100644 index 0000000..792a3b1 --- /dev/null +++ b/internal/watcher/removed_test.go @@ -0,0 +1,162 @@ +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) + } +} + +// 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) + } + }) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 0a09014..8642989 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -130,6 +130,7 @@ func (w *Watcher) Run(ctx context.Context) { "tlsInterval", w.config.TLSInterval.String(), ) + w.cleanupRemovedTargets() w.RunOnce(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. // DNS checks run first so that port and TLS checks use // freshly resolved IP addresses. Port checks run before @@ -807,8 +833,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 @@ -892,11 +920,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 }