1 Commits
Author SHA1 Message Date
sneak ecbc8ac224 watcher: a name removed from the targets leaves the state (closes #223)
check / check (push) Canceled after 0s
The port checks, which the first check after startup runs after its DNS
checks, now also remove the domain and hostname entries of names no
longer in DNSWATCHER_TARGETS, and the certificate entries of those names
and of addresses a name no longer resolves to. A configured domain's own
records, saved as a hostname entry under its name, are kept, and so are
the certificate entries of a configured name none of whose nameservers
answered, as its port entries already were. Nothing is notified.

Model: opus-5-5
2026-10-02 08:17:49 +00:00
5 changed files with 303 additions and 3 deletions
+6
View File
@@ -228,6 +228,12 @@ 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
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 ### Web Dashboard
+2
View File
@@ -19,6 +19,8 @@ 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
the dashboard and API, at the first check after startup (closes #223).
- 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
+64
View File
@@ -291,6 +291,27 @@ func (s *State) GetDomainState(
return ds, ok 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. // SetHostnameState updates the state for a hostname.
func (s *State) SetHostnameState( func (s *State) SetHostnameState(
hostname string, hostname string,
@@ -314,6 +335,28 @@ func (s *State) GetHostnameState(
return hs, ok 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. // SetPortState updates the state for a port.
func (s *State) SetPortState(key string, ps *PortState) { func (s *State) SetPortState(key string, ps *PortState) {
s.mu.Lock() s.mu.Lock()
@@ -376,6 +419,27 @@ func (s *State) GetCertificateState(
return cs, ok 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 // checkDataDirWritable creates the data directory if needed, then writes
// and removes the temp file that Save uses. It runs at startup so that an // 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 // unwritable directory stops the process, instead of the process running
+167
View File
@@ -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)
}
})
}
}
+64 -3
View File
@@ -774,8 +774,12 @@ 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. // 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.cleanupStalePorts(associations)
w.cleanupRemovedTargets()
w.cleanupStaleCertificates()
} }
// buildPortAssociations constructs a map from IP:port keys to // 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 // noNameserverAnswered reports whether name is a configured domain or
// hostname and none of its nameservers answered on its last check. // hostname and none of its nameservers answered on its last check.
func (w *Watcher) noNameserverAnswered(name string) bool { func (w *Watcher) noNameserverAnswered(name string) bool {
if !slices.Contains(w.config.Hostnames, name) && if !w.isConfigured(name) {
!slices.Contains(w.config.Domains, name) {
return false return false
} }