Compare commits
1
Commits
d6a5989f8e
...
ecbc8ac224
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbc8ac224 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user