diff --git a/README.md b/README.md index 4e8c13d..1e74e69 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ rejected. - Port transitioned from open to closed (or vice versa). - New IP appeared (from DNS change) and its port state was recorded. - IP disappeared (from DNS change) — noted in the DNS change - notification; port state for that IP is removed. + notification; port state for that IP is removed. When none of a name's + nameservers answered, its addresses are not known, so the port state saved + for them is kept. ### TLS Certificate Monitoring diff --git a/TODO.md b/TODO.md index 897bda6..4be3637 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-01: when none of a configured name's nameservers answered, the port + state saved for its addresses is kept, not removed (closes #193). - 2026-10-01: `make fmt-check` fails on a file `goimports` would change; both format scripts run `goimports` at its pinned commit, not from `PATH` (#119). - 2026-10-01: a hostname is queried at the servers of the zone it is in, found diff --git a/internal/watcher/export_test.go b/internal/watcher/export_test.go index 772253f..557e787 100644 --- a/internal/watcher/export_test.go +++ b/internal/watcher/export_test.go @@ -67,6 +67,11 @@ func (w *Watcher) DetectNSAddressChanges( w.detectNSAddressChanges(ctx, domain, prev, current) } +// CheckAllPorts exports checkAllPorts for testing. +func (w *Watcher) CheckAllPorts(ctx context.Context) { + w.checkAllPorts(ctx) +} + // BuildHostnameState exports buildHostnameState for testing. func BuildHostnameState( results map[string]*resolver.NameserverResponse, diff --git a/internal/watcher/nsfailure_test.go b/internal/watcher/nsfailure_test.go index 91a5580..c4c1b6d 100644 --- a/internal/watcher/nsfailure_test.go +++ b/internal/watcher/nsfailure_test.go @@ -331,3 +331,62 @@ func TestNameserverThatRefuses(t *testing.T) { ) } } + +// TestPortStateWhenNoNameserverAnswered runs the port checks on +// hostname state built here, which gives the hostname no address. The +// port state saved for its old address is kept only when the hostname +// is configured and none of its nameservers answered. +func TestPortStateWhenNoNameserverAnswered(t *testing.T) { + t.Parallel() + + noneAnswered := saved(map[string]*state.NameserverRecordState{ + nsA: failed(), nsB: failed(), + }) + oneAnsweredNoAddress := saved(map[string]*state.NameserverRecordState{ + nsA: answered(map[string][]string{}), nsB: failed(), + }) + + tests := []struct { + name string + hostname *state.HostnameState + configured bool + wantKept bool + }{ + {"no nameserver answered", noneAnswered, true, true}, + {"one answered with no address", oneAnsweredNoAddress, true, false}, + {"no nameserver answered, not configured", noneAnswered, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + if tt.configured { + cfg.Hostnames = []string{host} + } + + // The port checks read the saved hostname state and look + // nothing up, so the watcher has no resolver. + deps := newTestDeps(t, cfg) + w := watcher.NewForTest( + cfg, deps.state, nil, + deps.portChecker, deps.tlsChecker, deps.notifier, + ) + + key := ip1 + ":443" + + deps.state.SetHostnameState(host, tt.hostname) + deps.state.SetPortState(key, &state.PortState{ + Open: true, Hostnames: []string{host}, + }) + + w.CheckAllPorts(t.Context()) + + _, kept := deps.state.GetPortState(key) + if kept != tt.wantKept { + t.Errorf("port state %s kept: %v, want %v", key, kept, tt.wantKept) + } + }) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 2a2c523..1011332 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "slices" "sort" "strings" "sync" @@ -709,15 +710,46 @@ func parsePortKey(key string) (string, int) { } // cleanupStalePorts removes port state entries that are no -// longer referenced by any hostname in the current DNS data. +// longer referenced by any hostname in the current DNS data. 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) cleanupStalePorts( currentAssociations map[string][]string, ) { for _, key := range w.state.GetAllPortKeys() { - if _, exists := currentAssociations[key]; !exists { - w.state.DeletePortState(key) + if _, exists := currentAssociations[key]; exists { + continue + } + + ps, ok := w.state.GetPortState(key) + if ok && slices.ContainsFunc(ps.Hostnames, w.noNameserverAnswered) { + continue + } + + w.state.DeletePortState(key) + } +} + +// 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) { + return false + } + + hs, ok := w.state.GetHostnameState(name) + if !ok { + return false + } + + for _, nsState := range hs.RecordsByNameserver { + if nsState.Status == statusOK { + return false } } + + return true } func (w *Watcher) collectIPs(hostname string) []string {