diff --git a/README.md b/README.md index 5939483..4a512eb 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,12 @@ 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 the + first check after startup, without a notification: its domain, hostname and + certificate entries go, as do the port entries of addresses no configured name + has. The dashboard and `/api/v1/status` then no longer list or count it. +- 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 diff --git a/TODO.md b/TODO.md index 397b53c..42f171c 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 the first check after startup (closes #223). - 2026-10-02: nameservers a referral names without addresses are looked up, 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 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/removed_test.go b/internal/watcher/removed_test.go new file mode 100644 index 0000000..a35f073 --- /dev/null +++ b/internal/watcher/removed_test.go @@ -0,0 +1,167 @@ +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 port checks, which the first check after startup runs +// after its DNS checks. 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, + ) + + // 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. + 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.CheckAllPorts(t.Context()) + + 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 87ab71a..9b0caa3 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -774,8 +774,12 @@ 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, the domain, hostname and + // certificate entries of names no longer configured, and + // certificate entries for an address their name no longer has. w.cleanupStalePorts(associations) + w.cleanupRemovedTargets() + w.cleanupStaleCertificates() } // buildPortAssociations constructs a map from IP:port keys to @@ -859,11 +863,68 @@ func (w *Watcher) cleanupStalePorts( } } +// cleanupRemovedTargets removes the domain and hostname state entries +// of names no longer in the configuration. 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 !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() { + for _, key := range w.state.GetAllCertificateKeys() { + ip, hostname := parseCertKey(key) + + if w.isConfigured(hostname) && + slices.Contains(w.collectIPs(hostname), ip) { + continue + } + + if 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 slices.Contains(w.config.Domains, 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 }