watcher: keep port state when no nameserver of a name answered (closes #193)
check / check (push) Failing after 2m13s
check / check (push) Failing after 2m13s
The port check removed the saved port state of every address no configured name resolves to. A name whose nameservers all timed out or failed is saved with no records, so its addresses looked gone and their port state was removed; when the nameservers answered again the port state was recorded afresh, and a port that opened or closed in the meantime was not notified. The removal now keeps an entry when one of the names saved on it is configured and none of its nameservers answered on its last check. A name whose nameservers answer with no addresses still loses it, and so does a name no longer configured, whose saved state is never checked again. Model: opus-5-5
This commit is contained in:
@@ -109,7 +109,9 @@ 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
|
- 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
|
### TLS Certificate Monitoring
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ 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: `make fmt-check` fails on a file `goimports` would change; both
|
- 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).
|
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
|
- 2026-10-01: a hostname is queried at the servers of the zone it is in, found
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -709,15 +710,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user