Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd0b186cf2 |
@@ -78,9 +78,8 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
|
answer NXDOMAIN, that it does not exist, has no nameservers and is shown as
|
||||||
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
|
not existing (see Web Dashboard and HTTP API). A domain that exists but has no
|
||||||
delegation of its own, such as `octocat.github.io`, has no nameservers either.
|
delegation of its own, such as `octocat.github.io`, has no nameservers either.
|
||||||
When the parent zone's servers do not answer, or the lookup of the domain's
|
When the parent zone's servers do not answer, the check fails and the set from
|
||||||
own records (below) fails, the check fails and the set from the previous check
|
the previous check is kept.
|
||||||
is kept.
|
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- NS added to or removed from that set. A domain that had nameservers on the
|
- 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.
|
previous check and no longer exists gets one with all of them removed.
|
||||||
@@ -100,16 +99,11 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
records, stored per nameserver. Their changes are notified as a hostname's
|
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
|
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
|
address change, in a message that starts `Domain:` where a hostname's starts
|
||||||
`Hostname:`. A nameserver removed from the domain's delegation gets only the
|
`Hostname:`. A domain listed in `DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no
|
||||||
NS change notification above. A domain listed in
|
record change or inconsistency notification. A domain with no delegation of
|
||||||
`DNSWATCHER_SKIP_RECORD_NOTIFICATIONS` gets no record change or inconsistency
|
its own has these records asked at the servers of the zone it is in, as a
|
||||||
notification. A domain with no delegation of its own has these records asked
|
hostname has. A domain that does not exist has none: they are not asked for,
|
||||||
at the servers of the zone it is in, as a hostname has. A nameserver the
|
and those saved by an earlier check are removed without a notification.
|
||||||
domain's records were asked at on the previous check but not on this one gets
|
|
||||||
a hostname's NS change notification (see below), unless it was in the domain's
|
|
||||||
delegation on the previous check, as when that delegation is withdrawn. A
|
|
||||||
domain that does not exist has none: they are not asked for, and those saved
|
|
||||||
by an earlier check are removed without a notification.
|
|
||||||
|
|
||||||
### DNS Hostname Monitoring (Subdomains)
|
### DNS Hostname Monitoring (Subdomains)
|
||||||
|
|
||||||
@@ -162,10 +156,6 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- **NS recovery**: A previously-unreachable nameserver starts responding
|
- **NS recovery**: A previously-unreachable nameserver starts responding
|
||||||
again. Its records are not compared with those from before it failed, so a
|
again. Its records are not compared with those from before it failed, so a
|
||||||
change made while it was failing is not reported as a record change.
|
change made while it was failing is not reported as a record change.
|
||||||
- **NS change**: A nameserver of the zone the hostname is in on the previous
|
|
||||||
check is not one now, whether or not it answered. One notification lists
|
|
||||||
every such nameserver on a `Removed:` line. Nothing is sent for a
|
|
||||||
nameserver that joins the zone.
|
|
||||||
- **Inconsistency detected**: Two nameservers return different record sets
|
- **Inconsistency detected**: Two nameservers return different record sets
|
||||||
for the same hostname and did not already differ on the previous check.
|
for the same hostname and did not already differ on the previous check.
|
||||||
Every pair of nameservers is compared. The alert is sent once for each
|
Every pair of nameservers is compared. The alert is sent once for each
|
||||||
@@ -223,14 +213,8 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
- Certificate CN, issuer, or SANs changed (replacement detected, reports old
|
||||||
and new CN and issuer).
|
and new CN and issuer).
|
||||||
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
- TLS connection failure to a previously-reachable IP:443 (handshake error,
|
||||||
timeout, or connection refused when port 443 closed after the port check
|
timeout, connection refused after previously succeeding).
|
||||||
that found it open).
|
|
||||||
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
|
- TLS recovery: a previously-failing IP:443 now completes a handshake again.
|
||||||
- A port check that finds port 443 closed removes the certificate entries for
|
|
||||||
that IP address, without a notification of its own: the port change
|
|
||||||
notification reports the closed port. When the port opens again, the next TLS
|
|
||||||
check saves the certificate as new, without a certificate change or recovery
|
|
||||||
notification.
|
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
@@ -253,9 +237,7 @@ includes:
|
|||||||
|
|
||||||
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
- **DNS record changes**: Which hostname or domain, which nameserver, what
|
||||||
record type, old values, new values.
|
record type, old values, new values.
|
||||||
- **DNS NS changes**: Which domain, which nameservers were added to or removed
|
- **DNS NS changes**: Which domain, which nameservers were added/removed.
|
||||||
from its delegation. For a hostname, or a domain with no delegation of its
|
|
||||||
own, which nameservers left the zone it is in.
|
|
||||||
- **NS address changes**: Which domain, which nameserver, its old and new
|
- **NS address changes**: Which domain, which nameserver, its old and new
|
||||||
addresses.
|
addresses.
|
||||||
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
- **CNAME address changes**: Which hostname or domain, the old and new addresses
|
||||||
@@ -300,10 +282,8 @@ clears them.
|
|||||||
port entry left with no name goes, so the dashboard and `/api/v1/status` no
|
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
|
longer list or count it. The first check's port checks remove the port entries
|
||||||
of addresses no configured name has.
|
of addresses no configured name has.
|
||||||
- Each port check also removes, without a notification, the certificate entries
|
- Each port check also removes the certificate entries for an address their name
|
||||||
for an address whose port 443 it found closed, and those for an address their
|
no longer resolves to, except while none of the name's nameservers answer.
|
||||||
name no longer resolves to, except while none of the name's nameservers
|
|
||||||
answer.
|
|
||||||
|
|
||||||
### Web Dashboard
|
### Web Dashboard
|
||||||
|
|
||||||
@@ -666,20 +646,10 @@ for A, AAAA and CNAME, the previous check's list is kept, or `null` when no
|
|||||||
earlier check saved one. A state file without it loads, and the first check
|
earlier check saved one. A state file without it loads, and the first check
|
||||||
after that saves it without a notification.
|
after that saves it without a notification.
|
||||||
|
|
||||||
A port entry's key is its address and port, and a certificate entry's key its
|
|
||||||
address, port 443 and hostname, joined by colons. An IPv6 address is written in
|
|
||||||
brackets in these keys, as in `[2001:db8::1]:443` and
|
|
||||||
`[2001:db8::1]:443:www.example.com`. `/api/v1/status` and the dashboard show the
|
|
||||||
same keys.
|
|
||||||
|
|
||||||
A port entry's `hostnames` lists every name that resolves to its address,
|
A port entry's `hostnames` lists every name that resolves to its address,
|
||||||
domains included. A port entry in the older format, with one `hostname` instead
|
domains included. A port entry in the older format, with one `hostname` instead
|
||||||
of the `hostnames` list, loads as a list of that one name.
|
of the `hostnames` list, loads as a list of that one name.
|
||||||
|
|
||||||
A state file that leaves out any of `domains`, `hostnames`, `ports` and
|
|
||||||
`certificates`, or holds `null` for one, loads with that section empty. A file
|
|
||||||
holding `{}` loads as empty state.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|||||||
@@ -10,27 +10,15 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. Work lands on `next` by PR. Open work for 1.0 is tracked on the 1.0
|
pre-1.0. No git tags. Work lands on `next` by PR. Open work for 1.0 is tracked
|
||||||
milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
1.0 tracking issue: https://git.eeqj.de/sneak/dnswatcher/issues/66
|
trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-13: an IPv6 address is written in brackets before its port, as in
|
|
||||||
`[2001:db8::1]:443`, in keys and Port Change notifications (closes #270).
|
|
||||||
- 2026-10-13: `TODO.md` names only open issues as next and future steps, and its
|
|
||||||
Status says nothing about git tags (closes #281).
|
|
||||||
- 2026-10-13: a port check that finds port 443 closed removes that address's
|
|
||||||
certificate entries, so none is left shown as `ok` (closes #265).
|
|
||||||
- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with
|
|
||||||
that section empty; the first check no longer crashes on it (closes #267).
|
|
||||||
- 2026-10-13: a stop before the first scan completes sends no startup
|
|
||||||
notification; a scan that ran to the end still sends it (closes #271).
|
|
||||||
- 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change;
|
|
||||||
one leaving a hostname's zone sends NS Change, not NS Failure (closes #262).
|
|
||||||
- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without
|
- 2026-10-13: `DNSWATCHER_METRICS_USERNAME` set without
|
||||||
`DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268).
|
`DNSWATCHER_METRICS_PASSWORD` stops startup (closes #268).
|
||||||
- 2026-10-13: a save syncs the state file to disk before renaming it into place,
|
- 2026-10-13: a save syncs the state file to disk before renaming it into place,
|
||||||
@@ -205,4 +193,6 @@ milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- DNSSEC validation, after 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/59
|
- 1.0 readiness: run it with a real config and read the logs:
|
||||||
|
https://git.eeqj.de/sneak/dnswatcher/issues/66
|
||||||
|
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
|
||||||
|
|||||||
@@ -216,24 +216,6 @@ func (s *State) Load() error {
|
|||||||
return fmt.Errorf("parsing state file: %w", err)
|
return fmt.Errorf("parsing state file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A section the file leaves out, or holds as null, loads empty: the
|
|
||||||
// setters write into these maps, and writing into a nil map panics.
|
|
||||||
if snapshot.Domains == nil {
|
|
||||||
snapshot.Domains = make(map[string]*DomainState)
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot.Hostnames == nil {
|
|
||||||
snapshot.Hostnames = make(map[string]*HostnameState)
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot.Ports == nil {
|
|
||||||
snapshot.Ports = make(map[string]*PortState)
|
|
||||||
}
|
|
||||||
|
|
||||||
if snapshot.Certificates == nil {
|
|
||||||
snapshot.Certificates = make(map[string]*CertificateState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A state file saved before each record value was stored once can
|
// A state file saved before each record value was stored once can
|
||||||
// hold a hostname's CNAME once for every record type asked for.
|
// hold a hostname's CNAME once for every record type asked for.
|
||||||
// Each value is kept once, so the first check does not see a
|
// Each value is kept once, so the first check does not see a
|
||||||
|
|||||||
@@ -695,58 +695,6 @@ func TestLoadMissingFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLoadFileWithoutSections loads a state file holding {}, and files
|
|
||||||
// that each leave out one section. Every section loads as an empty map.
|
|
||||||
// TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests
|
|
||||||
// runs the first check after such a load.
|
|
||||||
func TestLoadFileWithoutSections(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
file string
|
|
||||||
}{
|
|
||||||
{"no sections", `{}`},
|
|
||||||
{"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`},
|
|
||||||
{"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`},
|
|
||||||
{"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`},
|
|
||||||
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
err := os.WriteFile(
|
|
||||||
filepath.Join(dir, "state.json"), []byte(tt.file), 0o600,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing state file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s := state.NewForTestWithDataDir(dir)
|
|
||||||
|
|
||||||
err = s.Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load() error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := state.Snapshot{
|
|
||||||
Domains: map[string]*state.DomainState{},
|
|
||||||
Hostnames: map[string]*state.HostnameState{},
|
|
||||||
Ports: map[string]*state.PortState{},
|
|
||||||
Certificates: map[string]*state.CertificateState{},
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("loaded %#v, want %#v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoadCorruptFile verifies that a corrupt state file returns an error.
|
// TestLoadCorruptFile verifies that a corrupt state file returns an error.
|
||||||
func TestLoadCorruptFile(t *testing.T) {
|
func TestLoadCorruptFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -83,30 +83,6 @@ func TestCancelledCheckSavesNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopBeforeFirstScanSendsNoStartupNotification runs a watcher with
|
|
||||||
// the startup notification on and its context already cancelled, so the
|
|
||||||
// first scan is cut short before its lookup is sent. The scan did not
|
|
||||||
// complete, so no startup notification may be sent.
|
|
||||||
func TestStopBeforeFirstScanSendsNoStartupNotification(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
|
||||||
cfg.Hostnames = []string{testHost}
|
|
||||||
cfg.SendTestNotification = true
|
|
||||||
|
|
||||||
w, deps := newTestWatcher(t, cfg)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
w.Run(ctx)
|
|
||||||
|
|
||||||
notifications := deps.notifier.getNotifications()
|
|
||||||
if len(notifications) != 0 {
|
|
||||||
t.Errorf("sent %v, want no notifications", notifications)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLoggingWatcher returns a watcher for a domain and a hostname, with
|
// newLoggingWatcher returns a watcher for a domain and a hostname, with
|
||||||
// the real resolver, that writes what it logs at warning level or above
|
// the real resolver, that writes what it logs at warning level or above
|
||||||
// into the returned buffer.
|
// into the returned buffer.
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package watcher_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIPv6AddressInBrackets runs the port checks, not the watcher's
|
|
||||||
// first, on hostname state built here for a hostname at an IPv6 address
|
|
||||||
// whose port 443 the previous check found closed. It is open now. Then
|
|
||||||
// it runs the TLS checks and the port checks again. The port and
|
|
||||||
// certificate keys and the Port Change notification write the address
|
|
||||||
// in brackets, so its last group cannot be read as the port, and the
|
|
||||||
// second port checks keep the certificate entry, whose address they read
|
|
||||||
// back from its key. When a last port check finds port 443 closed, the
|
|
||||||
// certificate entry is removed. Nothing is looked up.
|
|
||||||
func TestIPv6AddressInBrackets(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const aaaaType = "AAAA"
|
|
||||||
|
|
||||||
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, saved(
|
|
||||||
map[string]*state.NameserverRecordState{
|
|
||||||
nsA: answered(map[string][]string{aaaaType: {"2001:db8::1"}}),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
deps.state.SetPortState("[2001:db8::1]:443", &state.PortState{
|
|
||||||
Hostnames: []string{host},
|
|
||||||
})
|
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
w.RunTLSChecks(t.Context())
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
|
|
||||||
snap := deps.state.GetSnapshot()
|
|
||||||
|
|
||||||
gotPorts := slices.Sorted(maps.Keys(snap.Ports))
|
|
||||||
wantPorts := []string{"[2001:db8::1]:443", "[2001:db8::1]:80"}
|
|
||||||
|
|
||||||
if !slices.Equal(gotPorts, wantPorts) {
|
|
||||||
t.Errorf("port entries %v, want %v", gotPorts, wantPorts)
|
|
||||||
}
|
|
||||||
|
|
||||||
gotCerts := slices.Sorted(maps.Keys(snap.Certificates))
|
|
||||||
wantCerts := []string{"[2001:db8::1]:443:www.example.net"}
|
|
||||||
|
|
||||||
if !slices.Equal(gotCerts, wantCerts) {
|
|
||||||
t.Errorf("certificate entries %v, want %v", gotCerts, wantCerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "Port Change: [2001:db8::1]:443"
|
|
||||||
want := `Hostnames: www.example.net
|
|
||||||
Address: [2001:db8::1]:443
|
|
||||||
Port now open`
|
|
||||||
|
|
||||||
got := deps.notifier.getNotifications()
|
|
||||||
if len(got) != 1 || got[0].Title != title || got[0].Message != want {
|
|
||||||
t.Errorf("sent %v, want one %q with message:\n%s", got, title, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.portChecker.closed = true
|
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
|
|
||||||
gotCerts = slices.Sorted(maps.Keys(deps.state.GetSnapshot().Certificates))
|
|
||||||
if len(gotCerts) != 0 {
|
|
||||||
t.Errorf("certificate entries %v with port 443 closed, want none", gotCerts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/config"
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||||
)
|
)
|
||||||
@@ -73,19 +74,21 @@ b.ns.example.net.: 192.0.2.2`,
|
|||||||
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
notifier := &mockNotifier{}
|
||||||
cfg.Domains = []string{domain}
|
w := watcher.NewForTest(
|
||||||
|
&config.Config{Domains: []string{domain}},
|
||||||
deps := newTestDeps(t, cfg)
|
nil, nil, nil, nil, notifier,
|
||||||
w := watcher.NewForTest(cfg, deps.state, nil, nil, nil, deps.notifier)
|
)
|
||||||
|
|
||||||
// nsA's address changes, which also makes it differ from nsC; nsB
|
// nsA's address changes, which also makes it differ from nsC; nsB
|
||||||
// fails; nsC answers again.
|
// fails; nsC answers again; nsD is gone.
|
||||||
|
nsD := "d.ns.example.net."
|
||||||
w.DetectHostnameChanges(t.Context(), domain,
|
w.DetectHostnameChanges(t.Context(), domain,
|
||||||
saved(map[string]*state.NameserverRecordState{
|
saved(map[string]*state.NameserverRecordState{
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
nsA: answered(map[string][]string{"A": {ip1}}),
|
||||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
nsB: answered(map[string][]string{"A": {ip1}}),
|
||||||
nsC: failed(),
|
nsC: failed(),
|
||||||
|
nsD: answered(map[string][]string{"A": {ip1}}),
|
||||||
}),
|
}),
|
||||||
saved(map[string]*state.NameserverRecordState{
|
saved(map[string]*state.NameserverRecordState{
|
||||||
nsA: answered(map[string][]string{"A": {ip2}}),
|
nsA: answered(map[string][]string{"A": {ip2}}),
|
||||||
@@ -99,17 +102,18 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
|||||||
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
t.Context(), domain, cnameState(ip1), cnameState(ip2),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NS Failure is sent for nsB failing and for nsD being gone.
|
||||||
want := map[string]int{
|
want := map[string]int{
|
||||||
"Record Change": 1,
|
"Record Change": 1,
|
||||||
"Inconsistency": 1,
|
"Inconsistency": 1,
|
||||||
"NS Failure": 1,
|
"NS Failure": 2,
|
||||||
"NS Recovery": 1,
|
"NS Recovery": 1,
|
||||||
"CNAME Address Change": 1,
|
"CNAME Address Change": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
sent := make(map[string]int)
|
sent := make(map[string]int)
|
||||||
|
|
||||||
for _, n := range deps.notifier.getNotifications() {
|
for _, n := range notifier.getNotifications() {
|
||||||
kind, _, _ := strings.Cut(n.Title, ":")
|
kind, _, _ := strings.Cut(n.Title, ":")
|
||||||
sent[kind]++
|
sent[kind]++
|
||||||
|
|
||||||
@@ -124,89 +128,6 @@ func TestDomainRecordNotificationsNameTheDomain(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nsB, which answered, and nsC, which did not, are no longer among the
|
|
||||||
// nameservers. One NS Change lists both, for the hostname and for the
|
|
||||||
// configured apex domain with no delegation of its own. Nothing is sent
|
|
||||||
// for the domain whose delegation on the previous check listed them:
|
|
||||||
// the check of its delegation sends its NS Change.
|
|
||||||
func TestRemovedNameserversAreAnNSChange(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
target string
|
|
||||||
// The domain's nameservers saved by the previous check.
|
|
||||||
delegation []string
|
|
||||||
// The NS Change sent, or "" for none.
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "hostname",
|
|
||||||
target: host,
|
|
||||||
want: `Hostname: www.example.net
|
|
||||||
Removed: b.ns.example.net., c.ns.example.net.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "domain with no delegation of its own",
|
|
||||||
target: domain,
|
|
||||||
delegation: []string{},
|
|
||||||
want: `Domain: example.net
|
|
||||||
Removed: b.ns.example.net., c.ns.example.net.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "domain whose delegation listed them",
|
|
||||||
target: domain,
|
|
||||||
delegation: []string{nsA, nsB, nsC},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
|
||||||
cfg.Domains = []string{domain}
|
|
||||||
|
|
||||||
deps := newTestDeps(t, cfg)
|
|
||||||
w := watcher.NewForTest(
|
|
||||||
cfg, deps.state, nil, nil, nil, deps.notifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
deps.state.SetDomainState(domain, &state.DomainState{
|
|
||||||
Nameservers: tt.delegation,
|
|
||||||
})
|
|
||||||
|
|
||||||
prev := saved(map[string]*state.NameserverRecordState{
|
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
nsB: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
nsC: failed(),
|
|
||||||
})
|
|
||||||
current := saved(map[string]*state.NameserverRecordState{
|
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
})
|
|
||||||
|
|
||||||
w.DetectHostnameChanges(t.Context(), tt.target, prev, current)
|
|
||||||
|
|
||||||
got := deps.notifier.getNotifications()
|
|
||||||
|
|
||||||
if tt.want == "" {
|
|
||||||
if len(got) != 0 {
|
|
||||||
t.Errorf("sent %v, want nothing", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
title := "NS Change: " + tt.target
|
|
||||||
if len(got) != 1 || got[0].Title != title ||
|
|
||||||
got[0].Message != tt.want {
|
|
||||||
t.Errorf("sent %v, want one %q with message:\n%s",
|
|
||||||
got, title, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The startup notification counts the configured domains and hostnames,
|
// The startup notification counts the configured domains and hostnames,
|
||||||
// although the state's hostnames also hold the apex domain's own
|
// although the state's hostnames also hold the apex domain's own
|
||||||
// records. Nothing is looked up: the watcher has no resolver.
|
// records. Nothing is looked up: the watcher has no resolver.
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ package watcher_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
@@ -174,7 +172,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
"A": {ip1}, "AAAA": {ip6},
|
"A": {ip1}, "AAAA": {ip6},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
[]string{ip1 + ":443:" + host, "[" + ip6 + "]:443:" + host},
|
[]string{ip1 + ":443:" + host, ip6 + ":443:" + host},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"no nameserver answered",
|
"no nameserver answered",
|
||||||
@@ -184,7 +182,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
[]string{
|
[]string{
|
||||||
ip1 + ":443:" + host,
|
ip1 + ":443:" + host,
|
||||||
ip2 + ":443:" + host,
|
ip2 + ":443:" + host,
|
||||||
"[" + ip6 + "]:443:" + host,
|
ip6 + ":443:" + host,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -207,8 +205,7 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
|
|
||||||
for _, ip := range []string{ip1, ip2, ip6} {
|
for _, ip := range []string{ip1, ip2, ip6} {
|
||||||
deps.state.SetCertificateState(
|
deps.state.SetCertificateState(
|
||||||
net.JoinHostPort(ip, "443")+":"+host,
|
ip+":443:"+host, &state.CertificateState{Status: "ok"},
|
||||||
&state.CertificateState{Status: "ok"},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,67 +222,3 @@ func TestCertificateStateForAnAddressGone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCertificateStateForAPortClosed runs the port checks on hostname
|
|
||||||
// and port state built here for a configured hostname at ip1, whose
|
|
||||||
// port 443 the previous check found open and whose certificate entry
|
|
||||||
// is ok. The port is closed now: the certificate entry is removed, and
|
|
||||||
// only the Port Change is notified. When the port opens again, the next
|
|
||||||
// TLS check saves the certificate as new and notifies nothing about it.
|
|
||||||
// Nothing is looked up.
|
|
||||||
func TestCertificateStateForAPortClosed(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)
|
|
||||||
|
|
||||||
certKey := ip1 + ":443:" + host
|
|
||||||
|
|
||||||
deps.state.SetHostnameState(host, saved(
|
|
||||||
map[string]*state.NameserverRecordState{
|
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
deps.state.SetPortState(ip1+":443", &state.PortState{
|
|
||||||
Open: true, Hostnames: []string{host},
|
|
||||||
})
|
|
||||||
deps.state.SetCertificateState(certKey, &state.CertificateState{
|
|
||||||
CommonName: host, Status: "ok",
|
|
||||||
})
|
|
||||||
deps.portChecker.closed = true
|
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
|
|
||||||
if _, ok := deps.state.GetCertificateState(certKey); ok {
|
|
||||||
t.Errorf("certificate entry %s kept with port 443 closed", certKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
sent := deps.notifier.getNotifications()
|
|
||||||
if title := "Port Change: " + ip1 + ":443"; len(sent) != 1 ||
|
|
||||||
sent[0].Title != title {
|
|
||||||
t.Errorf("sent %v, want one %q", sent, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.portChecker.closed = false
|
|
||||||
|
|
||||||
w.CheckAllPorts(t.Context())
|
|
||||||
w.RunTLSChecks(t.Context())
|
|
||||||
|
|
||||||
cert, ok := deps.state.GetCertificateState(certKey)
|
|
||||||
if !ok || cert.Status != "ok" {
|
|
||||||
t.Errorf("certificate entry %s after port 443 opened: %+v", certKey, cert)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, n := range deps.notifier.getNotifications() {
|
|
||||||
if strings.HasPrefix(n.Title, "TLS") {
|
|
||||||
t.Errorf("sent %v, want no TLS notification", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+47
-90
@@ -6,10 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"net"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -135,12 +133,7 @@ func (w *Watcher) Run(ctx context.Context) {
|
|||||||
|
|
||||||
w.cleanupRemovedTargets()
|
w.cleanupRemovedTargets()
|
||||||
w.RunOnce(ctx)
|
w.RunOnce(ctx)
|
||||||
|
w.maybeSendTestNotification(ctx)
|
||||||
// RunOnce returns as usual when shutdown cuts the scan short, and
|
|
||||||
// the startup notification says the scan completed.
|
|
||||||
if ctx.Err() == nil {
|
|
||||||
w.maybeSendTestNotification(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
dnsTicker := time.NewTicker(w.config.DNSInterval)
|
||||||
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
tlsTicker := time.NewTicker(w.config.TLSInterval)
|
||||||
@@ -329,20 +322,6 @@ func (w *Watcher) checkDomain(
|
|||||||
ctx, nameservers, prevAddresses,
|
ctx, nameservers, prevAddresses,
|
||||||
)
|
)
|
||||||
|
|
||||||
// A domain that does not exist has no records of its own: none are
|
|
||||||
// asked for, and those saved by an earlier check are removed.
|
|
||||||
// Otherwise the apex domain's records are also checked and saved as
|
|
||||||
// a hostname's, so that the port and TLS checks find its addresses.
|
|
||||||
// Notifications about them name it as a domain (see nameLine). When
|
|
||||||
// their lookup fails, the check fails as when the NS lookup does, so
|
|
||||||
// the saved delegation and the saved records come from the same
|
|
||||||
// check, as detectNSDisappearances needs.
|
|
||||||
if nxdomain {
|
|
||||||
w.state.DeleteHostnameState(domain)
|
|
||||||
} else if !w.checkHostname(ctx, domain) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasPrev && !w.firstRun {
|
if hasPrev && !w.firstRun {
|
||||||
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
|
w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
|
||||||
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
|
w.detectNSAddressChanges(ctx, domain, prevAddresses, addresses)
|
||||||
@@ -354,6 +333,19 @@ func (w *Watcher) checkDomain(
|
|||||||
NXDomain: nxdomain,
|
NXDomain: nxdomain,
|
||||||
LastChecked: now,
|
LastChecked: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A domain that does not exist has no records of its own: none are
|
||||||
|
// asked for, and those saved by an earlier check are removed.
|
||||||
|
if nxdomain {
|
||||||
|
w.state.DeleteHostnameState(domain)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The apex domain's records are also checked and saved as a
|
||||||
|
// hostname's, so that the port and TLS checks find its addresses.
|
||||||
|
// Notifications about them name it as a domain (see nameLine).
|
||||||
|
w.checkHostname(ctx, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Watcher) detectNSChanges(
|
func (w *Watcher) detectNSChanges(
|
||||||
@@ -464,12 +456,10 @@ func (w *Watcher) detectNSAddressChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkHostname checks hostname's records and reports whether it saved
|
|
||||||
// them; it does not when their lookup fails.
|
|
||||||
func (w *Watcher) checkHostname(
|
func (w *Watcher) checkHostname(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
) bool {
|
) {
|
||||||
results, err := w.resolver.LookupAllRecords(ctx, hostname)
|
results, err := w.resolver.LookupAllRecords(ctx, hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.logFailedLookup(
|
w.logFailedLookup(
|
||||||
@@ -479,7 +469,7 @@ func (w *Watcher) checkHostname(
|
|||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prev, _ := w.state.GetHostnameState(hostname)
|
prev, _ := w.state.GetHostnameState(hostname)
|
||||||
@@ -487,8 +477,6 @@ func (w *Watcher) checkHostname(
|
|||||||
w.updateHostnameState(
|
w.updateHostnameState(
|
||||||
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
ctx, hostname, buildHostnameState(results, prev, time.Now().UTC()),
|
||||||
)
|
)
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateHostnameState finishes a check of hostname from newState, built
|
// updateHostnameState finishes a check of hostname from newState, built
|
||||||
@@ -784,53 +772,28 @@ func (w *Watcher) detectRecordChanges(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectNSDisappearances sends one NS Change listing the nameservers of
|
|
||||||
// the previous check that are no longer those of the zone hostname is
|
|
||||||
// in. For a configured apex domain it leaves out those of the domain's
|
|
||||||
// saved delegation, which detectNSChanges notifies when they are removed
|
|
||||||
// from it. checkDomain saves a new delegation only after this runs, and
|
|
||||||
// only together with the records, so the saved delegation and the
|
|
||||||
// records in prev come from the same check.
|
|
||||||
func (w *Watcher) detectNSDisappearances(
|
func (w *Watcher) detectNSDisappearances(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hostname string,
|
hostname string,
|
||||||
prev, current *state.HostnameState,
|
prev, current *state.HostnameState,
|
||||||
) {
|
) {
|
||||||
var delegation []string
|
for ns, prevNS := range prev.RecordsByNameserver {
|
||||||
|
if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
|
||||||
if w.isDomain(hostname) {
|
continue
|
||||||
if domainState, ok := w.state.GetDomainState(hostname); ok {
|
|
||||||
delegation = domainState.Nameservers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"%s\nNameserver: %s disappeared",
|
||||||
|
w.nameLine(hostname), ns,
|
||||||
|
)
|
||||||
|
|
||||||
|
w.notify.SendNotification(
|
||||||
|
ctx,
|
||||||
|
"NS Failure: "+hostname,
|
||||||
|
msg,
|
||||||
|
"error",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var removed []string
|
|
||||||
|
|
||||||
for ns := range prev.RecordsByNameserver {
|
|
||||||
_, ok := current.RecordsByNameserver[ns]
|
|
||||||
if !ok && !slices.Contains(delegation, ns) {
|
|
||||||
removed = append(removed, ns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(removed) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(removed)
|
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
|
||||||
"%s\nRemoved: %s",
|
|
||||||
w.nameLine(hostname),
|
|
||||||
strings.Join(removed, ", "),
|
|
||||||
)
|
|
||||||
|
|
||||||
w.notify.SendNotification(
|
|
||||||
ctx,
|
|
||||||
"NS Change: "+hostname,
|
|
||||||
msg,
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectNSFailures notifies when a nameserver that answered on the
|
// detectNSFailures notifies when a nameserver that answered on the
|
||||||
@@ -980,8 +943,7 @@ func (w *Watcher) checkAllPorts(ctx context.Context) {
|
|||||||
|
|
||||||
// Phase 3: Remove port state entries that no longer have
|
// Phase 3: Remove port state entries that no longer have
|
||||||
// any hostname referencing them, and certificate entries for
|
// any hostname referencing them, and certificate entries for
|
||||||
// an address whose port 443 is closed or that their name no
|
// an address their name no longer has.
|
||||||
// longer has.
|
|
||||||
w.cleanupStalePorts(associations)
|
w.cleanupStalePorts(associations)
|
||||||
w.cleanupStaleCertificates()
|
w.cleanupStaleCertificates()
|
||||||
}
|
}
|
||||||
@@ -1002,7 +964,7 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
|||||||
ips := w.collectIPs(name)
|
ips := w.collectIPs(name)
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
for _, port := range monitoredPorts {
|
for _, port := range monitoredPorts {
|
||||||
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
key := fmt.Sprintf("%s:%d", ip, port)
|
||||||
if assoc[key] == nil {
|
if assoc[key] == nil {
|
||||||
assoc[key] = make(map[string]bool)
|
assoc[key] = make(map[string]bool)
|
||||||
}
|
}
|
||||||
@@ -1027,15 +989,18 @@ func (w *Watcher) buildPortAssociations() map[string][]string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// parsePortKey splits an "ip:port" key, written by net.JoinHostPort,
|
// parsePortKey splits an "ip:port" key into its components.
|
||||||
// into its components.
|
|
||||||
func parsePortKey(key string) (string, int) {
|
func parsePortKey(key string) (string, int) {
|
||||||
ip, port, err := net.SplitHostPort(key)
|
lastColon := strings.LastIndex(key, ":")
|
||||||
if err != nil {
|
if lastColon < 0 {
|
||||||
return key, 0
|
return key, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
p, err := strconv.Atoi(port)
|
ip := key[:lastColon]
|
||||||
|
|
||||||
|
var p int
|
||||||
|
|
||||||
|
_, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ip, 0
|
return ip, 0
|
||||||
}
|
}
|
||||||
@@ -1065,21 +1030,13 @@ func (w *Watcher) cleanupStalePorts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// cleanupStaleCertificates removes the certificate entries for an
|
// cleanupStaleCertificates removes the certificate entries for an
|
||||||
// address whose port 443 was found closed, and for an address their
|
// address their name no longer resolves to. An entry saved for a name
|
||||||
// name no longer resolves to. An entry saved for a name none of whose
|
// none of whose nameservers answered is kept: that name's addresses are
|
||||||
// nameservers answered is kept unless its port is closed: that name's
|
// not known, not gone.
|
||||||
// addresses are not known, not gone.
|
|
||||||
func (w *Watcher) cleanupStaleCertificates() {
|
func (w *Watcher) cleanupStaleCertificates() {
|
||||||
for _, key := range w.state.GetAllCertificateKeys() {
|
for _, key := range w.state.GetAllCertificateKeys() {
|
||||||
ip, hostname := parseCertKey(key)
|
ip, hostname := parseCertKey(key)
|
||||||
|
|
||||||
ps, ok := w.state.GetPortState(net.JoinHostPort(ip, strconv.Itoa(tlsPort)))
|
|
||||||
if ok && !ps.Open {
|
|
||||||
w.state.DeleteCertificateState(key)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(w.collectIPs(hostname), ip) ||
|
if slices.Contains(w.collectIPs(hostname), ip) ||
|
||||||
w.noNameserverAnswered(hostname) {
|
w.noNameserverAnswered(hostname) {
|
||||||
continue
|
continue
|
||||||
@@ -1188,7 +1145,7 @@ func (w *Watcher) checkSinglePort(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
key := net.JoinHostPort(ip, strconv.Itoa(port))
|
key := fmt.Sprintf("%s:%d", ip, port)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetPortState(key)
|
prev, hasPrev := w.state.GetPortState(key)
|
||||||
|
|
||||||
@@ -1250,7 +1207,7 @@ func (w *Watcher) checkTLSForHostname(
|
|||||||
ips := w.collectIPs(hostname)
|
ips := w.collectIPs(hostname)
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
portKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort))
|
portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
|
||||||
|
|
||||||
ps, ok := w.state.GetPortState(portKey)
|
ps, ok := w.state.GetPortState(portKey)
|
||||||
if !ok || !ps.Open {
|
if !ok || !ps.Open {
|
||||||
@@ -1274,7 +1231,7 @@ func (w *Watcher) checkTLSCert(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certKey := net.JoinHostPort(ip, strconv.Itoa(tlsPort)) + ":" + hostname
|
certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
prev, hasPrev := w.state.GetCertificateState(certKey)
|
prev, hasPrev := w.state.GetCertificateState(certKey)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -140,22 +139,6 @@ func (m *mockNotifier) getNotifications() []notification {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// cancelOnNotify is a mockNotifier that calls cancel at each
|
|
||||||
// notification, as if shutdown cut the check short there.
|
|
||||||
type cancelOnNotify struct {
|
|
||||||
*mockNotifier
|
|
||||||
|
|
||||||
cancel context.CancelFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n cancelOnNotify) SendNotification(
|
|
||||||
ctx context.Context,
|
|
||||||
title, message, priority string,
|
|
||||||
) {
|
|
||||||
n.mockNotifier.SendNotification(ctx, title, message, priority)
|
|
||||||
n.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers to build a Watcher and run its checks against live DNS ---
|
// --- Helpers to build a Watcher and run its checks against live DNS ---
|
||||||
|
|
||||||
type testDeps struct {
|
type testDeps struct {
|
||||||
@@ -478,54 +461,6 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFirstCheckAfterLoadingFileWithoutSections loads a state file
|
|
||||||
// holding {}, and one that leaves out the certificates section, and runs
|
|
||||||
// the first check. It saves domain, hostname, port and certificate
|
|
||||||
// entries; a section loaded as nil made it panic.
|
|
||||||
func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
file string
|
|
||||||
}{
|
|
||||||
{"no sections", `{}`},
|
|
||||||
{"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
|
||||||
cfg.Domains = []string{testSmallDomain}
|
|
||||||
|
|
||||||
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
|
||||||
err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing the state file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = deps.state.Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("loading the state file: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
snap := deps.state.GetSnapshot()
|
|
||||||
if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 ||
|
|
||||||
len(snap.Ports) == 0 || len(snap.Certificates) == 0 {
|
|
||||||
t.Errorf(
|
|
||||||
"saved %d domain, %d hostname, %d port and %d "+
|
|
||||||
"certificate entries, want at least one of each",
|
|
||||||
len(snap.Domains), len(snap.Hostnames),
|
|
||||||
len(snap.Ports), len(snap.Certificates),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNSChangeDetection(t *testing.T) {
|
func TestNSChangeDetection(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -623,11 +558,6 @@ func TestDomainThatDoesNotExist(t *testing.T) {
|
|||||||
// nameservers and without nxdomain, and its records, asked at the
|
// nameservers and without nxdomain, and its records, asked at the
|
||||||
// codeberg.page servers, are saved. Those are testSmallDomain's two
|
// codeberg.page servers, are saved. Those are testSmallDomain's two
|
||||||
// nameservers; github.io, the zone of the README's example, has eight.
|
// nameservers; github.io, the zone of the README's example, has eight.
|
||||||
// The previous check saved a delegation to oldNS1, now withdrawn, and
|
|
||||||
// records asked at oldNS1. A first check is cut short at its first
|
|
||||||
// notification, as shutdown would, and a second runs in full: across
|
|
||||||
// them the NS Change of its delegation removes oldNS1, and no other NS
|
|
||||||
// Change lists it again.
|
|
||||||
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -639,30 +569,9 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
|||||||
var deps *testDeps
|
var deps *testDeps
|
||||||
|
|
||||||
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
|
||||||
deps = newTestDeps(t, cfg)
|
var w *watcher.Watcher
|
||||||
|
|
||||||
firstCtx, cancel := context.WithCancel(ctx)
|
w, deps = newTestWatcher(t, cfg)
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
w := watcher.NewForTest(
|
|
||||||
cfg,
|
|
||||||
deps.state,
|
|
||||||
resolver.NewFromLogger(slog.Default()),
|
|
||||||
deps.portChecker,
|
|
||||||
deps.tlsChecker,
|
|
||||||
cancelOnNotify{mockNotifier: deps.notifier, cancel: cancel},
|
|
||||||
)
|
|
||||||
|
|
||||||
deps.state.SetDomainState(domain, &state.DomainState{
|
|
||||||
Nameservers: []string{oldNS1},
|
|
||||||
})
|
|
||||||
deps.state.SetHostnameState(domain, saved(
|
|
||||||
map[string]*state.NameserverRecordState{
|
|
||||||
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
w.RunOnce(firstCtx)
|
|
||||||
|
|
||||||
err := checkOnce(ctx, w, deps)
|
err := checkOnce(ctx, w, deps)
|
||||||
|
|
||||||
@@ -684,22 +593,6 @@ func TestDomainWithNoDelegationOfItsOwn(t *testing.T) {
|
|||||||
if _, ok := deps.state.GetHostnameState(domain); !ok {
|
if _, ok := deps.state.GetHostnameState(domain); !ok {
|
||||||
t.Errorf("no records saved for %s", domain)
|
t.Errorf("no records saved for %s", domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
title := "NS Change: " + domain
|
|
||||||
want := "Domain: " + domain + "\nAdded: \nRemoved: " + oldNS1
|
|
||||||
|
|
||||||
var changes []notification
|
|
||||||
|
|
||||||
for _, n := range deps.notifier.getNotifications() {
|
|
||||||
if n.Title == title {
|
|
||||||
changes = append(changes, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(changes) != 1 || changes[0].Message != want {
|
|
||||||
t.Errorf("sent %v, want one %q with message:\n%s",
|
|
||||||
changes, title, want)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNSAddressChangeDetection(t *testing.T) {
|
func TestNSAddressChangeDetection(t *testing.T) {
|
||||||
@@ -825,8 +718,7 @@ func TestPortStateChange(t *testing.T) {
|
|||||||
|
|
||||||
hs, _ := deps.state.GetHostnameState(testHost)
|
hs, _ := deps.state.GetHostnameState(testHost)
|
||||||
assertNotified(
|
assertNotified(
|
||||||
t, deps, "Port Change: "+net.JoinHostPort(addresses(hs)[0], "443"),
|
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning",
|
||||||
"warning",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,12 +925,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
|
|||||||
|
|
||||||
// Port and TLS checks must use the addresses this check found.
|
// Port and TLS checks must use the addresses this check found.
|
||||||
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
for _, ip := range addresses(snap.Hostnames[testHost]) {
|
||||||
portKey := net.JoinHostPort(ip, "80")
|
if _, ok := snap.Ports[ip+":80"]; !ok {
|
||||||
if _, ok := snap.Ports[portKey]; !ok {
|
t.Error("port check used stale DNS: missing " + ip + ":80")
|
||||||
t.Error("port check used stale DNS: missing " + portKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
certKey := net.JoinHostPort(ip, "443") + ":" + testHost
|
certKey := ip + ":443:" + testHost
|
||||||
if _, ok := snap.Certificates[certKey]; !ok {
|
if _, ok := snap.Certificates[certKey]; !ok {
|
||||||
t.Error("TLS check used stale DNS: missing " + certKey)
|
t.Error("TLS check used stale DNS: missing " + certKey)
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1049,7 @@ func TestSendTestNotification_Disabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNSChangeAndRecovery(t *testing.T) {
|
func TestNSFailureAndRecovery(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
@@ -1168,7 +1059,7 @@ func TestNSChangeAndRecovery(t *testing.T) {
|
|||||||
|
|
||||||
// The saved state has every nameserver live DNS lists as one that
|
// The saved state has every nameserver live DNS lists as one that
|
||||||
// did not answer, and, as answering, one that live DNS does not
|
// did not answer, and, as answering, one that live DNS does not
|
||||||
// list, which the check then finds removed.
|
// list, which then disappears.
|
||||||
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
_, deps := runChecks(t, cfg, func(deps *testDeps) {
|
||||||
byNameserver := map[string]*state.NameserverRecordState{
|
byNameserver := map[string]*state.NameserverRecordState{
|
||||||
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
oldNS1: answered(map[string][]string{"A": {oldIP}}),
|
||||||
@@ -1180,7 +1071,7 @@ func TestNSChangeAndRecovery(t *testing.T) {
|
|||||||
deps.state.SetHostnameState(testHost, saved(byNameserver))
|
deps.state.SetHostnameState(testHost, saved(byNameserver))
|
||||||
})
|
})
|
||||||
|
|
||||||
assertNotified(t, deps, "NS Change: "+testHost, "warning")
|
assertNotified(t, deps, "NS Failure: "+testHost, "error")
|
||||||
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
|
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
|
||||||
|
|
||||||
// A nameserver that did not answer has no records to compare, so
|
// A nameserver that did not answer has no records to compare, so
|
||||||
|
|||||||
Reference in New Issue
Block a user