watcher: keep port state when no nameserver of a name answered (closes #193) #195

Merged
clawbot merged 1 commits from issue-193-keep-port-state into next 2026-10-02 00:44:50 +02:00
5 changed files with 176 additions and 5 deletions
+3 -1
View File
@@ -110,7 +110,9 @@ Contributions that introduce mocked, faked, or stubbed DNS will be rejected.
- Port transitioned from open to closed (or vice versa). - Port transitioned from open to closed (or vice versa).
- New IP appeared (from DNS change) and its port state was recorded. - New IP appeared (from DNS change) and its port state was recorded.
- IP disappeared (from DNS change) — noted in the DNS change notification; - IP disappeared (from DNS change) — noted in the DNS change notification;
port state for that IP is removed. 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 ### TLS Certificate Monitoring
+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-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: `ResolveIPAddresses` returns an error, not no addresses, when no - 2026-10-01: `ResolveIPAddresses` returns an error, not no addresses, when no
nameserver of the name's zone answered (closes #190). nameserver of the name's zone answered (closes #190).
- 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run - 2026-10-01: `make fmt` and `make fmt-check` cover Markdown with prettier, run
+5
View File
@@ -67,6 +67,11 @@ func (w *Watcher) DetectNSAddressChanges(
w.detectNSAddressChanges(ctx, domain, prev, current) 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. // BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState( func BuildHostnameState(
results map[string]*resolver.NameserverResponse, results map[string]*resolver.NameserverResponse,
+115
View File
@@ -331,3 +331,118 @@ func TestNameserverThatRefuses(t *testing.T) {
) )
} }
} }
// TestPortStateWhenNoNameserverAnswered runs the port checks on
// hostname state built here, which gives the name no address. The port
// state saved for its old address is kept only when the name is a
// configured hostname or domain 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(),
})
configured := []string{host}
tests := []struct {
name string
hostname *state.HostnameState
hostnames []string
domains []string
wantKept bool
}{
{"no nameserver answered", noneAnswered, configured, nil, true},
{
"no nameserver answered, configured as a domain",
noneAnswered, nil, configured, true,
},
{
"one answered with no address",
oneAnsweredNoAddress, configured, nil, false,
},
{"no nameserver answered, not configured", noneAnswered, nil, nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = tt.hostnames
cfg.Domains = tt.domains
// 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)
}
})
}
}
// TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway saves the
// port state of an address two configured hostnames resolve to. While
// none of the first one's nameservers answer, the port checks run with
// the other one still at that address, then after it moved away; the
// port state is kept both times.
func TestPortStateWhenNoNameserverAnsweredAndOtherNameMovesAway(
t *testing.T,
) {
t.Parallel()
const other = "mail.example.net"
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{host, other}
// 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.SetPortState(key, &state.PortState{
Open: true, Hostnames: []string{host, other},
})
deps.state.SetHostnameState(host, saved(
map[string]*state.NameserverRecordState{nsA: failed(), nsB: failed()},
))
for _, otherIP := range []string{ip1, ip2} {
deps.state.SetHostnameState(other, saved(
map[string]*state.NameserverRecordState{
nsA: answered(map[string][]string{"A": {otherIP}}),
},
))
w.CheckAllPorts(t.Context())
if _, kept := deps.state.GetPortState(key); !kept {
t.Fatalf("port state %s removed with %s at %s", key, other, otherIP)
}
}
}
+51 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -708,15 +709,46 @@ func parsePortKey(key string) (string, int) {
} }
// cleanupStalePorts removes port state entries that are no // 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( func (w *Watcher) cleanupStalePorts(
currentAssociations map[string][]string, currentAssociations map[string][]string,
) { ) {
for _, key := range w.state.GetAllPortKeys() { for _, key := range w.state.GetAllPortKeys() {
if _, exists := currentAssociations[key]; !exists { if _, exists := currentAssociations[key]; exists {
w.state.DeletePortState(key) 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 { func (w *Watcher) collectIPs(hostname string) []string {
@@ -795,9 +827,24 @@ func (w *Watcher) checkSinglePort(
) )
} }
// A configured name on the saved list none of whose nameservers
// answered stays on it, so the entry is kept when the other names
// stop resolving to this address.
savedHostnames := slices.Clone(hostnames)
if hasPrev {
for _, name := range prev.Hostnames {
if !slices.Contains(hostnames, name) && w.noNameserverAnswered(name) {
savedHostnames = append(savedHostnames, name)
}
}
sort.Strings(savedHostnames)
}
w.state.SetPortState(key, &state.PortState{ w.state.SetPortState(key, &state.PortState{
Open: result.Open, Open: result.Open,
Hostnames: hostnames, Hostnames: savedHostnames,
LastChecked: now, LastChecked: now,
}) })
} }