Compare commits
2
Commits
65b3212e9d
...
396a3bd229
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
396a3bd229 | ||
|
|
6332b48379 |
@@ -73,9 +73,13 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
to discover all authoritative nameservers (NS records) for each domain.
|
||||
- Queries **every** discovered authoritative nameserver independently.
|
||||
- Stores the domain's NS record set, as its parent zone's servers delegate it,
|
||||
and the IPv4 and IPv6 addresses each nameserver's name resolves to.
|
||||
and the IPv4 and IPv6 addresses each nameserver's name resolves to. The set is
|
||||
only ever the domain's own delegation: a domain that its parent zone's servers
|
||||
answer does not exist, or has no delegation, has no nameservers. When they do
|
||||
not answer, the check fails and the set from the previous check is kept.
|
||||
- Any change triggers a notification:
|
||||
- NS added to or removed from that set.
|
||||
- NS added to or removed from that set. A domain that had nameservers on the
|
||||
previous check and no longer exists gets one with all of them removed.
|
||||
- NS address change: a nameserver that stays in the set resolves to
|
||||
different addresses than on the previous check. A nameserver added or
|
||||
removed gets only the NS change notification. When the lookup of a
|
||||
@@ -87,7 +91,9 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
records, stored per nameserver. Their changes are notified as a hostname's
|
||||
are, as a record change, NS query failure, NS recovery, inconsistency or CNAME
|
||||
address change, in a message that starts `Domain:` where a hostname's starts
|
||||
`Hostname:`.
|
||||
`Hostname:`. A domain with no nameservers of its own has these records asked
|
||||
at the servers of the zone it is in, as a hostname has: for a `.com` domain
|
||||
that does not exist, the `.com` servers, which answer that it does not exist.
|
||||
|
||||
### DNS Hostname Monitoring (Subdomains)
|
||||
|
||||
@@ -95,7 +101,10 @@ notification endpoint set, changes show only on the dashboard; see
|
||||
via the Public Suffix List).
|
||||
- Every **1 hour** by default, performs a full iterative trace to discover the
|
||||
authoritative nameservers of the zone the hostname is in, which is not always
|
||||
its last two labels (a name under `co.uk`, or in a delegated subdomain).
|
||||
its last two labels (a name under `co.uk`, or in a delegated subdomain). The
|
||||
trace moves from a name to its parent only when the servers asked answer that
|
||||
the name has no delegation of its own. When they do not answer, the check
|
||||
fails and the hostname's records from the previous check are kept.
|
||||
- Queries **each** authoritative nameserver independently for **all** record
|
||||
types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||
- Each record type is a query of its own. When a nameserver answers some types
|
||||
@@ -245,6 +254,14 @@ 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 startup,
|
||||
before the first check, without a notification: its domain, hostname and
|
||||
certificate entries go, it is taken off each port entry's list of names, and a
|
||||
port entry left with no name goes, so the dashboard and `/api/v1/status` no
|
||||
longer list or count it. The first check's port checks remove the port entries
|
||||
of addresses no configured name has.
|
||||
- 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
|
||||
|
||||
@@ -734,7 +751,8 @@ docker run -d \
|
||||
1. **Startup**: Check that the data directory can be written, and exit with an
|
||||
error naming it if not. Load state from disk. If no state file exists, start
|
||||
with empty state (first check will establish baseline without triggering
|
||||
change notifications).
|
||||
change notifications). Remove from the state the names no longer in
|
||||
`DNSWATCHER_TARGETS` (see State Management).
|
||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks on
|
||||
startup.
|
||||
3. **Periodic checks** (DNS always runs first):
|
||||
|
||||
@@ -19,6 +19,10 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-02: a domain that does not exist has no nameservers, not its parent
|
||||
zone's; no name gets a parent's when its servers did not answer (closes #222).
|
||||
- 2026-10-02: a name removed from `DNSWATCHER_TARGETS` leaves the state, and so
|
||||
the dashboard and API, at startup, before the first check (closes #223).
|
||||
- 2026-10-02: a record type whose query to a nameserver fails keeps its previous
|
||||
records and alerts nothing; the other types are still saved (closes #231).
|
||||
- 2026-10-02: a Port Change notification lists the port's domains on a
|
||||
|
||||
@@ -204,6 +204,11 @@ func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
|
||||
return ips
|
||||
}
|
||||
|
||||
// followDelegation follows referrals from servers, the root servers, to
|
||||
// domain and returns the NS set of domain's delegation. When the servers
|
||||
// of the zone domain is in answer that it has no delegation of its own,
|
||||
// because it is not the zone's apex or does not exist, the set is empty
|
||||
// and there is no error. An error means that no such answer came.
|
||||
func (r *Resolver) followDelegation(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
@@ -234,10 +239,10 @@ func (r *Resolver) followDelegation(
|
||||
// An authoritative reply comes from the servers of the zone
|
||||
// domain is in; it is not a referral, even when its authority
|
||||
// section lists that zone's NS records. Without NS records in
|
||||
// the answer, domain is not the zone's apex and has no
|
||||
// nameservers of its own.
|
||||
// the answer, domain is not the zone's apex, or does not
|
||||
// exist, and has no nameservers of its own.
|
||||
if resp.Authoritative {
|
||||
return nil, ErrNoNameservers
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
authNS := extractNSSet(resp.Ns)
|
||||
@@ -486,7 +491,8 @@ func (r *Resolver) resolveNSIPs(
|
||||
|
||||
// resolveNSIterative queries for NS records using iterative
|
||||
// resolution as a fallback when followDelegation finds no
|
||||
// authoritative answer in the delegation chain.
|
||||
// authoritative answer in the delegation chain. Its result means what
|
||||
// followDelegation's does.
|
||||
func (r *Resolver) resolveNSIterative(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
@@ -516,6 +522,12 @@ func (r *Resolver) resolveNSIterative(
|
||||
return nsNames, nil
|
||||
}
|
||||
|
||||
// As in followDelegation: domain has no nameservers of its
|
||||
// own.
|
||||
if resp.Authoritative {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// Follow delegation.
|
||||
authNS := extractNSSet(resp.Ns)
|
||||
if len(authNS) == 0 {
|
||||
@@ -601,9 +613,10 @@ func (r *Resolver) resolveARecord(
|
||||
// FindAuthoritativeNameservers traces the delegation chain from
|
||||
// root servers to discover all authoritative nameservers for the
|
||||
// given domain, as the delegation from its parent zone's servers lists
|
||||
// them. For a name that is not a zone apex it tries each
|
||||
// parent name in turn, so it returns the nameservers of the zone the
|
||||
// name is in.
|
||||
// them. When the servers asked answer that the name has no delegation
|
||||
// of its own, it tries each parent name in turn, so it returns the
|
||||
// nameservers of the zone the name is in. When they do not answer, it
|
||||
// returns the error and tries no parent name.
|
||||
func (r *Resolver) FindAuthoritativeNameservers(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
@@ -625,16 +638,15 @@ func (r *Resolver) FindAuthoritativeNameservers(
|
||||
nsNames, err := r.followDelegation(
|
||||
ctx, candidate, rootServerList(),
|
||||
)
|
||||
if err == nil && len(nsNames) > 0 {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(nsNames) > 0 {
|
||||
sort.Strings(nsNames)
|
||||
|
||||
return nsNames, nil
|
||||
}
|
||||
|
||||
// The root servers would refuse every parent name too.
|
||||
if errors.Is(err, ErrIntercepted) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrNoNameservers
|
||||
@@ -852,9 +864,7 @@ func readReply(
|
||||
// A reply with no answer that lists other nameservers, from a server
|
||||
// that does not hold the name's zone, is a referral and says nothing
|
||||
// about the name's records. A server named in the delegation that
|
||||
// does not hold the zone may send one, as do a parent zone's servers
|
||||
// when FindAuthoritativeNameservers found no delegation for the
|
||||
// name's zone and moved on to a parent name.
|
||||
// does not hold the zone may send one.
|
||||
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||
len(extractNSSet(msg.Ns)) > 0 {
|
||||
state.gotReferral = true
|
||||
@@ -1022,12 +1032,21 @@ func (r *Resolver) queryEachNS(
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// LookupNS returns the NS record set for a domain.
|
||||
// LookupNS returns the NS record set of a domain, as the delegation from
|
||||
// its parent zone's servers lists it, and never a parent name's. When
|
||||
// they answer that the domain has no delegation of its own, as when it
|
||||
// does not exist, the set is empty and there is no error.
|
||||
func (r *Resolver) LookupNS(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
) ([]string, error) {
|
||||
return r.FindAuthoritativeNameservers(ctx, domain)
|
||||
if checkCtx(ctx) != nil {
|
||||
return nil, ErrContextCanceled
|
||||
}
|
||||
|
||||
return r.followDelegation(
|
||||
ctx, dns.Fqdn(strings.ToLower(domain)), rootServerList(),
|
||||
)
|
||||
}
|
||||
|
||||
// LookupAllRecords performs iterative resolution to find all DNS
|
||||
|
||||
@@ -187,8 +187,8 @@ func liveFindAuthoritative(
|
||||
return out
|
||||
}
|
||||
|
||||
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
|
||||
// point, so that both entry points stay independently exercised.
|
||||
// liveLookupNS looks up the NS record set of domain, a domain that has
|
||||
// one, retrying until the delegation chain can be walked.
|
||||
func liveLookupNS(
|
||||
t *testing.T,
|
||||
r *resolver.Resolver,
|
||||
|
||||
@@ -88,6 +88,26 @@ func TestFindAuthoritativeNameservers_Subdomain(
|
||||
assert.Equal(t, fromZone, fromHost)
|
||||
}
|
||||
|
||||
// TestFindAuthoritativeNameservers_DelegatedSubdomain looks up the
|
||||
// nameservers of www.cs.cmu.edu, a name in cs.cmu.edu, a zone that
|
||||
// cmu.edu delegates to other servers. The servers of cs.cmu.edu answer
|
||||
// that the name has no delegation of its own, so it gets their names,
|
||||
// not those of the cmu.edu servers. Every referral on the way gives the
|
||||
// nameservers' addresses, so the walk sends few queries.
|
||||
func TestFindAuthoritativeNameservers_DelegatedSubdomain(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
r := newTestResolver(t)
|
||||
fromHost := liveFindAuthoritative(t, r, "www.cs.cmu.edu")
|
||||
fromZone := liveLookupNS(t, r, "cs.cmu.edu")
|
||||
fromParent := liveLookupNS(t, r, "cmu.edu")
|
||||
|
||||
assert.Equal(t, fromZone, fromHost)
|
||||
assert.NotEqual(t, fromParent, fromHost)
|
||||
}
|
||||
|
||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -831,8 +851,7 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
|
||||
// nameservers of g.ntpns.org. The org servers delegate its parent zone,
|
||||
// ntpns.org, without the addresses of its nameservers, so the walk has
|
||||
// to look them up to ask them. If it did not, the walk for g.ntpns.org
|
||||
// would fail and LookupNS would return the nameservers of ntpns.org,
|
||||
// which a.ntpns.org is not one of.
|
||||
// would fail.
|
||||
func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -842,6 +861,33 @@ func TestLookupNS_ParentZoneDelegatedWithoutAddresses(t *testing.T) {
|
||||
assert.Contains(t, nameservers, "a.ntpns.org.")
|
||||
}
|
||||
|
||||
// TestLookupNS_DomainThatDoesNotExist looks up the nameservers of a .com
|
||||
// domain that does not exist. The .com servers answer that it does not
|
||||
// exist, so it has none, and does not get theirs.
|
||||
func TestLookupNS_DomainThatDoesNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const domain = "dnswatcher-test-does-not-exist.com"
|
||||
|
||||
r := newTestResolver(t)
|
||||
|
||||
var nameservers []string
|
||||
|
||||
livednstest.Retry(
|
||||
t,
|
||||
"LookupNS("+domain+")",
|
||||
func(ctx context.Context) error {
|
||||
var err error
|
||||
|
||||
nameservers, err = r.LookupNS(ctx, domain)
|
||||
|
||||
return err
|
||||
},
|
||||
)
|
||||
|
||||
assert.Empty(t, nameservers)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// ResolveIPAddresses tests
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -298,6 +298,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,
|
||||
@@ -321,6 +342,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()
|
||||
@@ -383,6 +426,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
|
||||
|
||||
@@ -107,6 +107,11 @@ func (w *Watcher) MaybeSendTestNotification(ctx context.Context) {
|
||||
w.maybeSendTestNotification(ctx)
|
||||
}
|
||||
|
||||
// CleanupRemovedTargets exports cleanupRemovedTargets for testing.
|
||||
func (w *Watcher) CleanupRemovedTargets() {
|
||||
w.cleanupRemovedTargets()
|
||||
}
|
||||
|
||||
// CheckAllPorts exports checkAllPorts for testing.
|
||||
func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
||||
w.checkAllPorts(ctx)
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
// DNSResolver performs iterative DNS resolution.
|
||||
type DNSResolver interface {
|
||||
// LookupNS discovers authoritative nameservers for a domain.
|
||||
// LookupNS returns a domain's NS record set, as its parent zone's
|
||||
// servers delegate it: empty when they answer that it has none.
|
||||
LookupNS(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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 removal that Run does before the first check. 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,
|
||||
)
|
||||
|
||||
// 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.CleanupRemovedTargets()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemovedTargetsLeaveThePortEntries loads a state whose port
|
||||
// entries name a domain and a hostname now removed from the
|
||||
// configuration, and runs the removal that Run does before the first
|
||||
// check. The removed names are off each port entry's list of names, the
|
||||
// entry only they had is gone, the entry that also names configured
|
||||
// names is kept for the port checks, and nothing is notified.
|
||||
func TestRemovedTargetsLeaveThePortEntries(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,
|
||||
)
|
||||
|
||||
// The port 443 entries a check of all four names saves: each name
|
||||
// at ip1, except the removed hostname, at ip2.
|
||||
deps.state.SetPortState(ip1+":443", &state.PortState{
|
||||
Open: true, Hostnames: []string{removedDomain, domain, host},
|
||||
})
|
||||
deps.state.SetPortState(ip2+":443", &state.PortState{
|
||||
Open: true, Hostnames: []string{removedHost},
|
||||
})
|
||||
|
||||
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.CleanupRemovedTargets()
|
||||
|
||||
if sent := deps.notifier.getNotifications(); len(sent) != 0 {
|
||||
t.Errorf("sent %v, want nothing", sent)
|
||||
}
|
||||
|
||||
snap := deps.state.GetSnapshot()
|
||||
|
||||
got := slices.Sorted(maps.Keys(snap.Ports))
|
||||
if want := []string{ip1 + ":443"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("port entries %v, want %v", got, want)
|
||||
}
|
||||
|
||||
got = snap.Ports[ip1+":443"].Hostnames
|
||||
if want := []string{domain, host}; !slices.Equal(got, want) {
|
||||
t.Errorf("names of port entry %s:443 %v, want %v", ip1, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ func (w *Watcher) Run(ctx context.Context) {
|
||||
"tlsInterval", w.config.TLSInterval.String(),
|
||||
)
|
||||
|
||||
w.cleanupRemovedTargets()
|
||||
w.RunOnce(ctx)
|
||||
w.maybeSendTestNotification(ctx)
|
||||
|
||||
@@ -201,6 +202,59 @@ func (w *Watcher) detectFirstRun() {
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupRemovedTargets removes from the loaded state the domain,
|
||||
// hostname and certificate entries of names no longer in the
|
||||
// configuration, which changes only at a restart, and takes those names
|
||||
// off each port entry's list of names, removing a port entry left with
|
||||
// none. Nothing is notified. 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 !w.isDomain(name) {
|
||||
w.state.DeleteDomainState(name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range w.state.GetAllHostnames() {
|
||||
if !w.isConfigured(name) {
|
||||
w.state.DeleteHostnameState(name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range w.state.GetAllCertificateKeys() {
|
||||
if _, hostname := parseCertKey(key); !w.isConfigured(hostname) {
|
||||
w.state.DeleteCertificateState(key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range w.state.GetAllPortKeys() {
|
||||
ps, ok := w.state.GetPortState(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var names []string
|
||||
|
||||
for _, name := range ps.Hostnames {
|
||||
if w.isConfigured(name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
w.state.DeletePortState(key)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
w.state.SetPortState(key, &state.PortState{
|
||||
Open: ps.Open,
|
||||
Hostnames: names,
|
||||
LastChecked: ps.LastChecked,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// runDNSChecks performs DNS resolution for all configured domains
|
||||
// and hostnames, updating state with freshly resolved records.
|
||||
// This must complete before port or TLS checks run so those
|
||||
@@ -861,8 +915,10 @@ 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, and certificate entries for
|
||||
// an address their name no longer has.
|
||||
w.cleanupStalePorts(associations)
|
||||
w.cleanupStaleCertificates()
|
||||
}
|
||||
|
||||
// buildPortAssociations constructs a map from IP:port keys to
|
||||
@@ -946,11 +1002,45 @@ func (w *Watcher) cleanupStalePorts(
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupStaleCertificates removes the certificate entries for an
|
||||
// address their name no longer resolves to. An entry saved for a 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 slices.Contains(w.collectIPs(hostname), ip) ||
|
||||
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 w.isDomain(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