1 Commits
Author SHA1 Message Date
clawbot 8ea6191566 watcher: send the inconsistency alert once per disagreement (closes #158)
check / check (push) Successful in 1m19s
detectInconsistencies alerted for neighbouring pairs of nameservers whose
records differed, on every DNS check, for as long as they differed. It now
also takes the previous hostname state, compares every pair of nameservers,
and alerts for a pair only when both were in that state with equal records.
The state loaded at startup is the previous state for the first check, so a
disagreement saved before a restart is not reported again. The choice of
pairs is tested on record data, and the alert itself through the hostname
change detection with the notifier stand-in and no resolver. The README
describes the new behaviour.

Model: opus-5-5
2026-09-28 23:42:56 +00:00
13 changed files with 802 additions and 580 deletions
+6 -11
View File
@@ -74,15 +74,12 @@ rejected.
This is distinct from "responded with no records." This is distinct from "responded with no records."
- **NS recovery**: A previously-unreachable nameserver starts - **NS recovery**: A previously-unreachable nameserver starts
responding again. responding again.
- **Inconsistency detected**: Two nameservers return different record - **Inconsistency detected**: Two nameservers that agreed on the
sets for the same hostname and did not already differ on the previous previous check now return different record sets for the same
check. Every pair of nameservers is compared. The alert is sent once hostname. Every pair of nameservers is compared. The alert is sent
for each such pair, on the check where they start to disagree, and not once for each such pair, on the check where they start to disagree,
again while they keep disagreeing, including after a restart. A and not again while they keep disagreeing, including after a restart.
nameserver that was not in the previous check (newly added, or back If they agree again and later disagree, it is sent again.
after dropping out) and answers differently is reported on the check
where it appears. If a pair agrees again and later disagrees, the
alert is sent again.
### TCP Port Monitoring ### TCP Port Monitoring
@@ -278,8 +275,6 @@ internal/
tlscheck/tlscheck.go TLS certificate inspector tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy) notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler watcher/watcher.go Main monitoring orchestrator and scheduler
livedns/livedns.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
``` ```
### Design Principles ### Design Principles
+4 -12
View File
@@ -2,12 +2,8 @@
## DNS Resolution Tests ## DNS Resolution Tests
DNS is never mocked in this project, not in tests and not anywhere All resolver tests **MUST** use live queries against real DNS servers.
else; see the README section "No DNS mocking. Ever." Every test that No mocking of the DNS client layer is permitted.
looks something up in DNS **MUST** query live DNS servers, never a
stand-in. Logic that works on record data, such as comparing or
formatting records, may be tested on that data directly with no
lookup.
### Rationale ### Rationale
@@ -25,18 +21,14 @@ real servers ensures the resolver works correctly in production.
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms) - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
plus processing margin plus processing margin
- Root server fan-out is limited to reduce parallel query load - Root server fan-out is limited to reduce parallel query load
- Live lookups that expect an answer go through `internal/livedns`,
which limits how many run at once in a test binary and retries a
lookup that got none
- Flaky failures from transient network issues are acceptable and - Flaky failures from transient network issues are acceptable and
should be investigated as potential resolver bugs, not papered over should be investigated as potential resolver bugs, not papered over
with mocks or skip flags with mocks or skip flags
### What NOT to do ### What NOT to do
- **Do not mock, fake or stub DNS** anywhere: no stand-in `DNSClient`, - **Do not mock `DNSClient`** for resolver tests (the mock constructor
no stand-in for the watcher's `DNSResolver`, no fake DNS server, no exists for unit-testing other packages that consume the resolver)
canned responses
- **Do not add `-short` flags** to skip slow tests - **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries - **Do not increase `-timeout`** to hide hanging queries
- **Do not remove `-count=1` from `script/test`** — Go's test cache - **Do not remove `-count=1` from `script/test`** — Go's test cache
+11 -14
View File
@@ -10,7 +10,11 @@
# Status # Status
pre-1.0. No git tags. pre-1.0. No git tags. Core resolver work in flight on feature/resolver
(dirty: internal/resolver/resolver_test.go). Local checkout has diverged
from origin: origin/main is 8 commits ahead (watcher orchestrator,
unified TARGETS) and origin/feature/resolver already contains the full
iterative resolver implementation with hermetic mocked tests.
# Next Step # Next Step
@@ -19,18 +23,10 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps # Completed Steps
- 2026-09-29: nothing stands in for DNS any more. Watcher tests that look
something up in DNS use the real resolver against live DNS servers and test
record and nameserver changes by preparing the saved state a check starts
from; the resolver timeout test queries an address that never answers, and
`NewFromLoggerWithClient`, used only by its stand-in client, is gone. The
live-DNS retry and concurrency limit moved to `internal/livedns`, which both
test packages use. `TESTING.md` states the README's rule (closes #159).
- 2026-09-28: the inconsistency alert is sent once, on the check where two - 2026-09-28: the inconsistency alert is sent once, on the check where two
nameservers start to disagree or where a nameserver that disagrees first nameservers that agreed start to disagree, instead of on every check while
appears, instead of on every check while they disagree, and not again after they disagree, and not again after a restart. Every pair of nameservers is
a restart. Every pair of nameservers is compared, not only neighbours in compared, not only neighbours in sorted order of name (closes #158).
sorted order of name (closes #158).
- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are - 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are
lower-cased, so nameservers that answer in different letter case no longer lower-cased, so nameservers that answer in different letter case no longer
count as inconsistent or as a record change (closes #157). count as inconsistent or as a record change (closes #157).
@@ -264,5 +260,6 @@ Infrastructure notes (from untracked TODO.md):
- Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de - Module path sneak.berlin/go/dnswatcher differs from the git.eeqj.de
remote intentionally; do not "fix" it remote intentionally; do not "fix" it
- Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix - Dependencies: github.com/miekg/dns, golang.org/x/net/publicsuffix
- DNS is never mocked; tests that look something up in DNS query live DNS - Resolver tests originally used live DNS against *.dns.sneak.cloud
servers (README, "No DNS mocking. Ever.") (required records documented in the test file header); origin now has
mocked hermetic tests, keep them hermetic
-121
View File
@@ -1,121 +0,0 @@
// Package livedns runs the live DNS operations of tests. Tests that
// look something up in DNS query live DNS servers, never a stand-in —
// see TESTING.md. Nothing here mocks, fakes, stubs, records or replays
// DNS, and nothing here skips a test: it only changes *how* the live
// queries are issued, so that a single dropped UDP packet or one slow
// authoritative server does not turn correct code into a red build.
//
// Two mechanisms:
//
// 1. Bounded concurrency. Tests run in parallel and the build hosts
// have many cores, so without a limit every test starts its own
// iterative resolution at the same instant and they all hit the
// first root server within a few milliseconds of each other. Root
// servers rate-limit that, which shows up as a different arbitrary
// subset of tests failing on each run. Run caps how many live
// operations are in flight at once in one test binary.
//
// 2. Retry with exponential backoff. Each live operation gets several
// attempts with its own timeout. An attempt is retried when it
// obtained nothing to check, never because of what the test
// asserts about the result, so a wrong result still fails on the
// first attempt. A fault in the code under test that leaves
// nothing to check looks the same as live DNS not answering, and
// fails only after the last attempt.
package livedns
import (
"context"
"errors"
"testing"
"time"
)
const (
// attempts is how many times a live DNS operation is attempted
// before the test fails.
attempts = 3
// AttemptTimeout bounds one attempt. Worst case for an operation
// is attempts * AttemptTimeout plus the backoff — about 26
// seconds, well inside the 90-second `go test -timeout` backstop
// even when several operations exhaust their attempts.
AttemptTimeout = 8 * time.Second
// backoffBase is the delay after the first failed attempt; it is
// multiplied by backoffFactor each time.
backoffBase = 500 * time.Millisecond
// backoffFactor is the exponential backoff multiplier.
backoffFactor = 2
// Concurrency caps how many live operations may be in flight
// across one test binary at once.
Concurrency = 6
)
// gate bounds concurrent live operations. It has to be package scoped:
// the whole point is that it is shared by every parallel test in the
// test binary.
//
//nolint:gochecknoglobals // package-wide live query rate limit
var gate = make(chan struct{}, Concurrency)
// ErrNoAnswer reports that a live operation produced no usable answer,
// which is retried rather than asserted on.
var ErrNoAnswer = errors.New("no answer from live DNS")
// Run executes one attempt of a live operation, holding a slot in gate
// for its duration and bounding it with its own timeout.
func Run(op func(ctx context.Context) error) error {
gate <- struct{}{}
defer func() { <-gate }()
ctx, cancel := context.WithTimeout(
context.Background(), AttemptTimeout,
)
defer cancel()
return op(ctx)
}
// Retry runs op until it reports success, retrying failures with
// exponential backoff, and fails the test if every attempt fails. op
// returns an error only for a failure to obtain an answer — never for
// an answer the test disagrees with, which belongs in an assertion so
// that it fails immediately. op stores whatever it obtained where its
// caller can find it.
func Retry(
t *testing.T,
what string,
op func(ctx context.Context) error,
) {
t.Helper()
var last error
backoff := backoffBase
for attempt := range attempts {
if attempt > 0 {
t.Logf(
"%s: attempt %d of %d failed (%v), "+
"retrying in %s",
what, attempt, attempts, last, backoff,
)
time.Sleep(backoff)
backoff *= backoffFactor
}
last = Run(op)
if last == nil {
return
}
}
t.Fatalf(
"%s: all %d live attempts failed: %v",
what, attempts, last,
)
}
-103
View File
@@ -1,103 +0,0 @@
package livedns_test
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/dnswatcher/internal/livedns"
)
// Tests for the retry and the concurrency limit themselves. They
// perform no DNS resolution of any kind.
func TestRetryRecoversFromTransientFailure(t *testing.T) {
t.Parallel()
const wantAttempts = 2
attempts := 0
livedns.Retry(t, "transient", func(_ context.Context) error {
attempts++
if attempts < wantAttempts {
return livedns.ErrNoAnswer
}
return nil
})
assert.Equal(t, wantAttempts, attempts)
}
func TestRetryGivesEachAttemptADeadline(t *testing.T) {
t.Parallel()
livedns.Retry(t, "deadline", func(ctx context.Context) error {
deadline, ok := ctx.Deadline()
assert.True(t, ok, "attempt should carry a deadline")
remaining := time.Until(deadline)
assert.LessOrEqual(t, remaining, livedns.AttemptTimeout)
// Lower bound too: without one this passes for a
// deadline far shorter than intended, which would
// silently turn every live attempt into an instant
// timeout.
assert.Greater(t, remaining, livedns.AttemptTimeout/2)
return nil
})
}
func TestRunBoundsConcurrency(t *testing.T) {
t.Parallel()
const workers = 24
var (
mu sync.Mutex
wg sync.WaitGroup
inFlight int
maxSeen int
)
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
_ = livedns.Run(func(_ context.Context) error {
mu.Lock()
inFlight++
if inFlight > maxSeen {
maxSeen = inFlight
}
mu.Unlock()
time.Sleep(time.Millisecond)
mu.Lock()
inFlight--
mu.Unlock()
return nil
})
}()
}
wg.Wait()
assert.Positive(t, maxSeen)
assert.LessOrEqual(
t, maxSeen, livedns.Concurrency,
"live queries must stay under the package-wide gate",
)
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/miekg/dns" "github.com/miekg/dns"
) )
// DNSClient sends one DNS message to a nameserver and returns the // DNSClient abstracts DNS wire-protocol exchanges so the resolver
// reply. The resolver holds one for UDP and one for TCP. // can be tested without hitting real nameservers.
type DNSClient interface { type DNSClient interface {
ExchangeContext( ExchangeContext(
ctx context.Context, ctx context.Context,
+94 -2
View File
@@ -1,7 +1,10 @@
package resolver_test package resolver_test
import ( import (
"context"
"sync"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -9,8 +12,9 @@ import (
) )
// Tests for the live-DNS harness in livedns_test.go itself. These // Tests for the live-DNS harness in livedns_test.go itself. These
// exercise pure logic; they perform no DNS resolution of any kind, so // exercise pure logic and the retry/concurrency plumbing; they
// they neither mock DNS nor depend on it. // perform no DNS resolution of any kind, so they neither mock DNS
// nor depend on it.
// Names for the synthetic status maps below. Nothing is ever queried // Names for the synthetic status maps below. Nothing is ever queried
// at them: they are map keys handed to the package's pure counting // at them: they are map keys handed to the package's pure counting
@@ -86,6 +90,47 @@ func TestStatusCountingIgnoresSilentNameservers(t *testing.T) {
) )
} }
func TestRetryLiveRecoversFromTransientFailure(t *testing.T) {
t.Parallel()
const wantAttempts = 2
attempts := 0
retryLive(t, "transient", func(_ context.Context) error {
attempts++
if attempts < wantAttempts {
return errLiveNoAnswer
}
return nil
})
assert.Equal(t, wantAttempts, attempts)
}
func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) {
t.Parallel()
retryLive(t, "deadline", func(ctx context.Context) error {
deadline, ok := ctx.Deadline()
assert.True(t, ok, "attempt should carry a deadline")
remaining := time.Until(deadline)
assert.LessOrEqual(t, remaining, liveAttemptTimeout)
// Lower bound too: without one this passes for a
// deadline far shorter than intended, which would
// silently turn every live attempt into an instant
// timeout.
assert.Greater(t, remaining, liveAttemptTimeout/2)
return nil
})
}
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test // TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
// for the defect this allowlist exists to prevent: a minority of // for the defect this allowlist exists to prevent: a minority of
// nameservers answering WRONGLY while quorum keeps the suite green. // nameservers answering WRONGLY while quorum keeps the suite green.
@@ -190,3 +235,50 @@ func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) {
unsanctionedStatuses(results, allowed...), unsanctionedStatuses(results, allowed...),
) )
} }
func TestRunLiveBoundsConcurrency(t *testing.T) {
t.Parallel()
const workers = 24
var (
mu sync.Mutex
wg sync.WaitGroup
inFlight int
maxSeen int
)
wg.Add(workers)
for range workers {
go func() {
defer wg.Done()
_ = runLive(func(_ context.Context) error {
mu.Lock()
inFlight++
if inFlight > maxSeen {
maxSeen = inFlight
}
mu.Unlock()
time.Sleep(time.Millisecond)
mu.Lock()
inFlight--
mu.Unlock()
return nil
})
}()
}
wg.Wait()
assert.Positive(t, maxSeen)
assert.LessOrEqual(
t, maxSeen, liveConcurrency,
"live queries must stay under the package-wide gate",
)
}
+146 -36
View File
@@ -8,8 +8,8 @@ import (
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time"
"sneak.berlin/go/dnswatcher/internal/livedns"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
) )
@@ -17,34 +17,144 @@ import (
// Live DNS test support // Live DNS test support
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// //
// Tests that look something up in DNS query live DNS servers, never a // Every test in this package resolves against the real, live DNS —
// stand-in; logic that works on record data may be tested on that // see TESTING.md. Nothing here mocks, fakes, stubs, records or
// data with no lookup (see TESTING.md). Each live operation below goes // replays DNS, and nothing here skips or gates a test: the helpers
// through livedns.Retry, which bounds how many resolutions are in // below only change *how* the live queries are issued, so that a
// flight at once and retries an operation that got no answer (see // single dropped UDP packet or one slow authoritative server does
// package livedns). // not turn a correct resolver into a red build.
// //
// Where an assertion spans several independent nameservers, a quorum // Three mechanisms, all test-side:
// is enough: a strict majority answering as expected. A server that
// fails to answer is tolerated, while a server that answers *wrongly*
// still fails the test.
// //
// That tolerance is expressed as an ALLOWLIST of sanctioned statuses, // 1. Bounded concurrency. The package's tests are parallel and the
// never as a blocklist of known-bad ones. A blocklist bans the one // build hosts have many cores, so without a limit every test
// wrong answer its author thought of and silently admits every other // starts its own iterative resolution at the same instant and
// status, including any added to the resolver later; an allowlist // they all hit the first root server in rootServerList() within
// fails on anything nobody explicitly sanctioned. Silence (timeout, // a few milliseconds of each other. Root servers rate-limit
// error) is the only thing quorum exists to tolerate. A *wrong // that, which shows up as a different arbitrary subset of tests
// answer* — nxdomain for a name that exists, ok for one that does // failing on each run. liveGate caps how many resolutions are
// not, nodata for either — is never tolerated at any count. // in flight at once.
//
// 2. Retry with exponential backoff. Each live operation gets
// several attempts with its own timeout. The retry predicate is
// strictly transport-level — "did a nameserver answer at all" —
// never the assertion the test is making. A resolver that
// answers incorrectly still fails on the first attempt.
//
// 3. Quorum. Where an assertion spans several independent
// nameservers, a strict majority answering as expected is
// enough; a server that fails to answer is tolerated, while a
// server that answers *wrongly* still fails the test.
//
// The tolerance in (3) is expressed as an ALLOWLIST of sanctioned
// statuses, never as a blocklist of known-bad ones. A blocklist bans
// the one wrong answer its author thought of and silently admits
// every other status, including any added to the resolver later; an
// allowlist fails on anything nobody explicitly sanctioned. Silence
// (timeout, error) is the only thing quorum exists to tolerate. A
// *wrong answer* — nxdomain for a name that exists, ok for one that
// does not, nodata for either — is never tolerated at any count.
// minNameservers is the smallest nameserver count a well-run zone is const (
// expected to publish. // liveAttempts is how many times a live DNS operation is
const minNameservers = 2 // attempted before the test fails.
liveAttempts = 3
// errLiveNoQuorum reports that too few of a domain's nameservers // liveAttemptTimeout bounds one attempt. Worst case for an
// answered for a quorum assertion to be made. // operation is liveAttempts * liveAttemptTimeout plus the
var errLiveNoQuorum = errors.New("no nameserver quorum") // backoff — about 26 seconds, well inside the 90-second
// `go test -timeout` backstop even when several operations
// exhaust their attempts.
liveAttemptTimeout = 8 * time.Second
// liveBackoffBase is the delay after the first failed
// attempt; it is multiplied by liveBackoffFactor each time.
liveBackoffBase = 500 * time.Millisecond
// liveBackoffFactor is the exponential backoff multiplier.
liveBackoffFactor = 2
// liveConcurrency caps how many live resolutions may be in
// flight across the whole package at once.
liveConcurrency = 6
// minNameservers is the smallest nameserver count a
// well-run zone is expected to publish.
minNameservers = 2
)
// liveGate bounds concurrent live resolutions package-wide. It has
// to be package scoped: the whole point is that it is shared by
// every parallel test in the package.
//
//nolint:gochecknoglobals // package-wide live query rate limit
var liveGate = make(chan struct{}, liveConcurrency)
var (
// errLiveNoAnswer reports that a live operation produced no
// usable answer, which is retried rather than asserted on.
errLiveNoAnswer = errors.New("no answer from live DNS")
// errLiveNoQuorum reports that too few of a domain's
// nameservers answered for a quorum assertion to be made.
errLiveNoQuorum = errors.New("no nameserver quorum")
)
// runLive executes one attempt of a live operation, holding a slot
// in liveGate for its duration and bounding it with its own
// timeout.
func runLive(op func(ctx context.Context) error) error {
liveGate <- struct{}{}
defer func() { <-liveGate }()
ctx, cancel := context.WithTimeout(
context.Background(), liveAttemptTimeout,
)
defer cancel()
return op(ctx)
}
// retryLive runs op until it reports success, retrying transport
// failures with exponential backoff, and fails the test if every
// attempt fails. op returns an error only for a failure to obtain
// an answer — never for an answer the test disagrees with, which
// belongs in an assertion so that it fails immediately. op stores
// whatever it obtained where its caller can find it.
func retryLive(
t *testing.T,
what string,
op func(ctx context.Context) error,
) {
t.Helper()
var last error
backoff := liveBackoffBase
for attempt := range liveAttempts {
if attempt > 0 {
t.Logf(
"%s: attempt %d of %d failed (%v), "+
"retrying in %s",
what, attempt, liveAttempts, last, backoff,
)
time.Sleep(backoff)
backoff *= liveBackoffFactor
}
last = runLive(op)
if last == nil {
return
}
}
t.Fatalf(
"%s: no answer after %d live attempts: %v",
what, liveAttempts, last,
)
}
// liveQuorum is how many of total nameservers must agree for a // liveQuorum is how many of total nameservers must agree for a
// multi-nameserver assertion to hold: a strict majority. // multi-nameserver assertion to hold: a strict majority.
@@ -162,7 +272,7 @@ func liveFindAuthoritative(
var out []string var out []string
livedns.Retry( retryLive(
t, t,
"FindAuthoritativeNameservers("+domain+")", "FindAuthoritativeNameservers("+domain+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -174,7 +284,7 @@ func liveFindAuthoritative(
if len(ns) == 0 { if len(ns) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: %s has no nameservers", "%w: %s has no nameservers",
livedns.ErrNoAnswer, domain, errLiveNoAnswer, domain,
) )
} }
@@ -198,7 +308,7 @@ func liveLookupNS(
var out []string var out []string
livedns.Retry( retryLive(
t, t,
"LookupNS("+domain+")", "LookupNS("+domain+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -210,7 +320,7 @@ func liveLookupNS(
if len(ns) == 0 { if len(ns) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: %s has no nameservers", "%w: %s has no nameservers",
livedns.ErrNoAnswer, domain, errLiveNoAnswer, domain,
) )
} }
@@ -240,7 +350,7 @@ func liveQueryNameserver(
var out *resolver.NameserverResponse var out *resolver.NameserverResponse
livedns.Retry( retryLive(
t, t,
what, what,
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -255,7 +365,7 @@ func liveQueryNameserver(
resp.Status == resolver.StatusError { resp.Status == resolver.StatusError {
return fmt.Errorf( return fmt.Errorf(
"%w: %s returned %s: %s", "%w: %s returned %s: %s",
livedns.ErrNoAnswer, nameserver, errLiveNoAnswer, nameserver,
resp.Status, resp.Error, resp.Status, resp.Error,
) )
} }
@@ -282,7 +392,7 @@ func liveQueryAllNameservers(
var out map[string]*resolver.NameserverResponse var out map[string]*resolver.NameserverResponse
livedns.Retry( retryLive(
t, t,
"QueryAllNameservers("+hostname+")", "QueryAllNameservers("+hostname+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -294,7 +404,7 @@ func liveQueryAllNameservers(
if len(results) == 0 { if len(results) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: no nameservers queried for %s", "%w: no nameservers queried for %s",
livedns.ErrNoAnswer, hostname, errLiveNoAnswer, hostname,
) )
} }
@@ -327,7 +437,7 @@ func liveResolveIPs(
var out []string var out []string
livedns.Retry( retryLive(
t, t,
"ResolveIPAddresses("+hostname+")", "ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -339,7 +449,7 @@ func liveResolveIPs(
if len(ips) == 0 { if len(ips) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: no addresses for %s", "%w: no addresses for %s",
livedns.ErrNoAnswer, hostname, errLiveNoAnswer, hostname,
) )
} }
@@ -366,7 +476,7 @@ func liveResolveIPsAllowingEmpty(
var out []string var out []string
livedns.Retry( retryLive(
t, t,
"ResolveIPAddresses("+hostname+")", "ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error { func(ctx context.Context) error {
+13
View File
@@ -67,4 +67,17 @@ func NewFromLogger(log *slog.Logger) *Resolver {
} }
} }
// NewFromLoggerWithClient creates a Resolver with a custom DNS
// client, useful for testing with mock DNS responses.
func NewFromLoggerWithClient(
log *slog.Logger,
client DNSClient,
) *Resolver {
return &Resolver{
log: log,
client: client,
tcp: client,
}
}
// Method implementations are in iterative.go. // Method implementations are in iterative.go.
+34 -9
View File
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -32,8 +33,8 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
} }
// findOneNSForDomain picks one authoritative nameserver to aim a // findOneNSForDomain picks one authoritative nameserver to aim a
// test at. Quorum handling lives in livedns_test.go, and the live-DNS // test at. Live-DNS retry, concurrency and quorum handling live in
// retry and concurrency limit in package livedns. // livedns_test.go.
func findOneNSForDomain( func findOneNSForDomain(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
@@ -525,18 +526,21 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
func TestQueryNameserverIP_Timeout(t *testing.T) { func TestQueryNameserverIP_Timeout(t *testing.T) {
t.Parallel() t.Parallel()
r := newTestResolver(t) log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
// Nothing answers at 192.0.2.1, a documentation address. The
// resolver tries each query twice, and the first try gives up
// after two seconds. A deadline that ends during the first try
// makes the status vary from run to run between nodata and
// timeout, so the deadline must outlast the first try.
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second, context.Background(), 10*time.Second,
) )
t.Cleanup(cancel) t.Cleanup(cancel)
// Query any IP — the client always returns a timeout error.
resp, err := r.QueryNameserverIP( resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1", ctx, "unreachable.test.", "192.0.2.1",
"example.com", "example.com",
@@ -547,6 +551,27 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
assert.NotEmpty(t, resp.Error) assert.NotEmpty(t, resp.Error)
} }
// timeoutClient simulates DNS timeout errors for testing.
type timeoutClient struct{}
func (c *timeoutClient) ExchangeContext(
_ context.Context,
_ *dns.Msg,
_ string,
) (*dns.Msg, time.Duration, error) {
return nil, 0, &net.OpError{
Op: "read",
Net: "udp",
Err: &timeoutError{},
}
}
type timeoutError struct{}
func (e *timeoutError) Error() string { return "i/o timeout" }
func (e *timeoutError) Timeout() bool { return true }
func (e *timeoutError) Temporary() bool { return true }
func TestResolveIPAddresses_ContextCanceled(t *testing.T) { func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel() t.Parallel()
+3 -9
View File
@@ -80,10 +80,10 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
want: [][][2]string{nil, nil}, want: [][][2]string{nil, nil},
}, },
{ {
name: "nameserver new on the first check and disagreeing alerts once", name: "nameserver new on this check does not alert",
loaded: onlyA, loaded: onlyA,
checks: []map[string]map[string][]string{disagree, disagree}, checks: []map[string]map[string][]string{disagree},
want: [][][2]string{alert, nil}, want: [][][2]string{nil},
}, },
{ {
name: "disagreement after agreeing again alerts again", name: "disagreement after agreeing again alerts again",
@@ -123,7 +123,6 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
func TestInconsistencyAlert(t *testing.T) { func TestInconsistencyAlert(t *testing.T) {
t.Parallel() t.Parallel()
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}} agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}} disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
@@ -144,11 +143,6 @@ func TestInconsistencyAlert(t *testing.T) {
loaded: disagree, loaded: disagree,
want: 0, want: 0,
}, },
{
name: "nameserver new on the first check and disagreeing alerts once",
loaded: onlyA,
want: 1,
},
} }
for _, tt := range tests { for _, tt := range tests {
+3 -4
View File
@@ -471,9 +471,8 @@ func (w *Watcher) detectInconsistencies(
} }
// newlyDisagreeingPairs returns every pair of nameservers whose records // newlyDisagreeingPairs returns every pair of nameservers whose records
// differ in current, in sorted order of name, except pairs where both // differ in current but were equal in prev, in sorted order of name.
// nameservers were in prev and already differed there. A nameserver // A pair with a nameserver missing from prev is not returned.
// missing from prev is paired with every nameserver it differs from.
func newlyDisagreeingPairs( func newlyDisagreeingPairs(
prev *state.HostnameState, prev *state.HostnameState,
current map[string]map[string][]string, current map[string]map[string][]string,
@@ -496,7 +495,7 @@ func newlyDisagreeingPairs(
prev1, ok1 := prev.RecordsByNameserver[ns1] prev1, ok1 := prev.RecordsByNameserver[ns1]
prev2, ok2 := prev.RecordsByNameserver[ns2] prev2, ok2 := prev.RecordsByNameserver[ns2]
if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) { if !ok1 || !ok2 || !recordsEqual(prev1.Records, prev2.Records) {
continue continue
} }
+486 -257
View File
@@ -2,77 +2,151 @@ package watcher_test
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog"
"slices"
"sync" "sync"
"testing" "testing"
"time" "time"
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/livedns"
"sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/tlscheck"
"sneak.berlin/go/dnswatcher/internal/watcher" "sneak.berlin/go/dnswatcher/internal/watcher"
) )
// The watcher looks these names up in live DNS with the real resolver, // errNotFound is returned when mock data is missing.
// so tests assert on what the watcher does with the answers, never on var errNotFound = errors.New("not found")
// the records these zones publish. testHost's nameservers and addresses
// stay the same from one check to the next, which the tests that check // Fixture values shared across tests.
// it twice rely on.
const ( const (
testDomain = "google.com" testDomain = "example.com"
testHost = "cloudflare.com" testHost = "www.example.com"
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
testIssuer = "DigiCert" testIssuer = "DigiCert"
) )
// Saved-state values that live DNS never returns: nameserver names // --- Mock implementations ---
// under .invalid and a documentation address.
const (
oldNS1 = "ns1.example.invalid."
oldNS2 = "ns2.example.invalid."
oldIP = "192.0.2.1"
)
// --- Stand-ins for the port checker, TLS checker and notifier --- type mockResolver struct {
// mu sync.Mutex
// DNS has none: the watchers built here use the real resolver (see nsRecords map[string][]string
// TESTING.md). allRecords map[string]map[string]map[string][]string
ipAddresses map[string][]string
lookupNSErr error
allRecordsErr error
resolveIPErr error
lookupNSCalls int
allRecordCalls int
}
func (m *mockResolver) LookupNS(
_ context.Context,
domain string,
) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.lookupNSCalls++
if m.lookupNSErr != nil {
return nil, m.lookupNSErr
}
ns, ok := m.nsRecords[domain]
if !ok {
return nil, fmt.Errorf(
"%w: NS for %s", errNotFound, domain,
)
}
return ns, nil
}
func (m *mockResolver) LookupAllRecords(
_ context.Context,
hostname string,
) (map[string]map[string][]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.allRecordCalls++
if m.allRecordsErr != nil {
return nil, m.allRecordsErr
}
recs, ok := m.allRecords[hostname]
if !ok {
return nil, fmt.Errorf(
"%w: records for %s", errNotFound, hostname,
)
}
return recs, nil
}
func (m *mockResolver) ResolveIPAddresses(
_ context.Context,
hostname string,
) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.resolveIPErr != nil {
return nil, m.resolveIPErr
}
ips, ok := m.ipAddresses[hostname]
if !ok {
return nil, fmt.Errorf(
"%w: IPs for %s", errNotFound, hostname,
)
}
return ips, nil
}
// mockPortChecker reports every port open until closed is set.
type mockPortChecker struct { type mockPortChecker struct {
mu sync.Mutex mu sync.Mutex
closed bool results map[string]bool
calls int err error
calls int
} }
func (m *mockPortChecker) CheckPort( func (m *mockPortChecker) CheckPort(
_ context.Context, _ context.Context,
_ string, address string,
_ int, port int,
) (*portcheck.PortResult, error) { ) (*portcheck.PortResult, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
m.calls++ m.calls++
return &portcheck.PortResult{Open: !m.closed}, nil if m.err != nil {
return nil, m.err
}
key := fmt.Sprintf("%s:%d", address, port)
open := m.results[key]
return &portcheck.PortResult{Open: open}, nil
} }
// mockTLSChecker returns a certificate for the requested hostname that
// expires at notAfter.
type mockTLSChecker struct { type mockTLSChecker struct {
mu sync.Mutex mu sync.Mutex
notAfter time.Time certs map[string]*tlscheck.CertificateInfo
calls int err error
calls int
} }
func (m *mockTLSChecker) CheckCertificate( func (m *mockTLSChecker) CheckCertificate(
_ context.Context, _ context.Context,
_ string, ip string,
hostname string, hostname string,
) (*tlscheck.CertificateInfo, error) { ) (*tlscheck.CertificateInfo, error) {
m.mu.Lock() m.mu.Lock()
@@ -80,12 +154,20 @@ func (m *mockTLSChecker) CheckCertificate(
m.calls++ m.calls++
return &tlscheck.CertificateInfo{ if m.err != nil {
CommonName: hostname, return nil, m.err
Issuer: testIssuer, }
NotAfter: m.notAfter,
SubjectAlternativeNames: []string{hostname}, key := fmt.Sprintf("%s:%s", ip, hostname)
}, nil cert, ok := m.certs[key]
if !ok {
return nil, fmt.Errorf(
"%w: cert for %s", errNotFound, key,
)
}
return cert, nil
} }
type notification struct { type notification struct {
@@ -123,9 +205,10 @@ func (m *mockNotifier) getNotifications() []notification {
return result return result
} }
// --- Helpers to build a Watcher and run its checks against live DNS --- // --- Helper to build a Watcher for testing ---
type testDeps struct { type testDeps struct {
resolver *mockResolver
portChecker *mockPortChecker portChecker *mockPortChecker
tlsChecker *mockTLSChecker tlsChecker *mockTLSChecker
notifier *mockNotifier notifier *mockNotifier
@@ -140,9 +223,16 @@ func newTestWatcher(
t.Helper() t.Helper()
deps := &testDeps{ deps := &testDeps{
portChecker: &mockPortChecker{}, resolver: &mockResolver{
nsRecords: make(map[string][]string),
allRecords: make(map[string]map[string]map[string][]string),
ipAddresses: make(map[string][]string),
},
portChecker: &mockPortChecker{
results: make(map[string]bool),
},
tlsChecker: &mockTLSChecker{ tlsChecker: &mockTLSChecker{
notAfter: time.Now().Add(90 * 24 * time.Hour), certs: make(map[string]*tlscheck.CertificateInfo),
}, },
notifier: &mockNotifier{}, notifier: &mockNotifier{},
config: cfg, config: cfg,
@@ -153,7 +243,7 @@ func newTestWatcher(
w := watcher.NewForTest( w := watcher.NewForTest(
deps.config, deps.config,
deps.state, deps.state,
resolver.NewFromLogger(slog.Default()), deps.resolver,
deps.portChecker, deps.portChecker,
deps.tlsChecker, deps.tlsChecker,
deps.notifier, deps.notifier,
@@ -173,122 +263,6 @@ func defaultTestConfig(t *testing.T) *config.Config {
} }
} }
// checkOnce runs the watcher's checks once and returns an error when a
// configured name has no hostname state saved by this check, or that
// state holds no address. Either live DNS gave no answer for the name,
// or the watcher saved no fresh result for it.
func checkOnce(
ctx context.Context,
w *watcher.Watcher,
deps *testDeps,
) error {
started := time.Now()
w.RunOnce(ctx)
names := slices.Concat(deps.config.Domains, deps.config.Hostnames)
for _, name := range names {
hs, ok := deps.state.GetHostnameState(name)
if !ok || hs.LastChecked.Before(started) ||
len(addresses(hs)) == 0 {
return fmt.Errorf(
"%s: %w, or the watcher saved no fresh "+
"result for it",
name, livedns.ErrNoAnswer,
)
}
}
return nil
}
// runChecks builds a watcher, lets prepare set up the saved state and
// stand-ins it starts from, and runs its checks once against live DNS.
// If change is not nil, change then alters the saved state or stand-ins
// and the checks run a second time. When either check finds no fresh
// address for a name (see checkOnce), the watcher is thrown away and
// all of this runs again on a new one, so a failed attempt leaves
// nothing behind in the saved state, the stand-ins or the notifications.
func runChecks(
t *testing.T,
cfg *config.Config,
prepare, change func(deps *testDeps),
) *testDeps {
t.Helper()
var deps *testDeps
livedns.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)
if prepare != nil {
prepare(deps)
}
err := checkOnce(ctx, w, deps)
if err != nil || change == nil {
return err
}
change(deps)
return checkOnce(ctx, w, deps)
})
return deps
}
// addresses returns the A and AAAA values saved for a hostname.
func addresses(hs *state.HostnameState) []string {
var ips []string
for _, nsState := range hs.RecordsByNameserver {
ips = append(ips, nsState.Records["A"]...)
ips = append(ips, nsState.Records["AAAA"]...)
}
return ips
}
// assertNotified checks that a notification with this title and
// priority was sent.
func assertNotified(
t *testing.T,
deps *testDeps,
title, priority string,
) {
t.Helper()
notifications := deps.notifier.getNotifications()
for _, n := range notifications {
if n.Title == title && n.Priority == priority {
return
}
}
t.Errorf(
"expected %s notification %q, got: %v",
priority, title, notifications,
)
}
// countNotifications counts the notifications sent with this title.
func countNotifications(deps *testDeps, title string) int {
count := 0
for _, n := range deps.notifier.getNotifications() {
if n.Title == title {
count++
}
}
return count
}
func TestFirstRunBaseline(t *testing.T) { func TestFirstRunBaseline(t *testing.T) {
t.Parallel() t.Parallel()
@@ -296,12 +270,51 @@ func TestFirstRunBaseline(t *testing.T) {
cfg.Domains = []string{testDomain} cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
deps := runChecks(t, cfg, nil, nil) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
w.RunOnce(t.Context())
assertNoNotifications(t, deps) assertNoNotifications(t, deps)
assertStatePopulated(t, deps) assertStatePopulated(t, deps)
} }
func setupBaselineMocks(deps *testDeps) {
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
testNS2,
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
testNS2: {"A": {testIPv4}},
}
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
testNS2: {"A": {testIPv4}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIPv4,
}
deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
},
}
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: testDomain,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testDomain,
},
}
}
func assertNoNotifications( func assertNoNotifications(
t *testing.T, t *testing.T,
deps *testDeps, deps *testDeps,
@@ -348,7 +361,26 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{testDomain}
deps := runChecks(t, cfg, nil, nil) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
}
deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: testDomain,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testDomain,
},
}
w.RunOnce(t.Context())
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
@@ -387,18 +419,50 @@ func TestNSChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{testDomain}
// The saved state lists nameservers that live DNS does not. w, deps := newTestWatcher(t, cfg)
deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetDomainState(testDomain, &state.DomainState{
Nameservers: []string{oldNS1, oldNS2},
})
}, nil)
assertNotified(t, deps, "NS Change: "+testDomain, "warning") deps.resolver.nsRecords[testDomain] = []string{
testNS1,
testNS2,
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
testNS2: {"A": {testIP}},
}
deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false
ds, _ := deps.state.GetDomainState(testDomain) ctx := t.Context()
if slices.Contains(ds.Nameservers, oldNS1) { w.RunOnce(ctx)
t.Errorf("saved nameservers not updated: %v", ds.Nameservers)
deps.resolver.mu.Lock()
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
"ns3.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
"ns3.example.com.": {"A": {testIP}},
}
deps.resolver.mu.Unlock()
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) == 0 {
t.Error("expected notification for NS change")
}
found := false
for _, n := range notifications {
if n.Priority == "warning" {
found = true
}
}
if !found {
t.Error("expected warning-priority NS change notification")
} }
} }
@@ -408,18 +472,40 @@ func TestRecordChangeDetection(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, save for every nameserver an address live DNS w, deps := newTestWatcher(t, cfg)
// never returns.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
hs, _ := deps.state.GetHostnameState(testHost)
for _, nsState := range hs.RecordsByNameserver {
nsState.Records = map[string][]string{"A": {oldIP}}
}
deps.state.SetHostnameState(testHost, hs) deps.resolver.allRecords[testHost] = map[string]map[string][]string{
}) testNS1: {"A": {testIPv4}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIPv4,
}
deps.portChecker.results["93.184.216.34:80"] = false
deps.portChecker.results["93.184.216.34:443"] = false
assertNotified(t, deps, "Record Change: "+testHost, "warning") ctx := t.Context()
w.RunOnce(ctx)
deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {"93.184.216.35"}},
}
deps.resolver.ipAddresses[testHost] = []string{
"93.184.216.35",
}
deps.resolver.mu.Unlock()
deps.portChecker.mu.Lock()
deps.portChecker.results["93.184.216.35:80"] = false
deps.portChecker.results["93.184.216.35:443"] = false
deps.portChecker.mu.Unlock()
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) == 0 {
t.Error("expected notification for record change")
}
} }
func TestPortStateChange(t *testing.T) { func TestPortStateChange(t *testing.T) {
@@ -428,23 +514,38 @@ func TestPortStateChange(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, every port closes. w, deps := newTestWatcher(t, cfg)
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
deps.portChecker.mu.Lock()
deps.portChecker.closed = true
deps.portChecker.mu.Unlock()
})
hs, _ := deps.state.GetHostnameState(testHost) deps.resolver.allRecords[testHost] = map[string]map[string][]string{
assertNotified( testNS1: {"A": {testIP}},
t, deps, "Port Change: "+addresses(hs)[0]+":443", "warning", }
) deps.resolver.ipAddresses[testHost] = []string{
} testIP,
}
deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
},
}
// expiresInThreeDays makes the TLS checker return certificates that ctx := t.Context()
// expire within the seven-day warning period. w.RunOnce(ctx)
func expiresInThreeDays(deps *testDeps) {
deps.tlsChecker.notAfter = time.Now().Add(3 * 24 * time.Hour) deps.portChecker.mu.Lock()
deps.portChecker.results["1.2.3.4:443"] = false
deps.portChecker.mu.Unlock()
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) == 0 {
t.Error("expected notification for port state change")
}
} }
func TestTLSExpiryWarning(t *testing.T) { func TestTLSExpiryWarning(t *testing.T) {
@@ -453,9 +554,49 @@ func TestTLSExpiryWarning(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
deps := runChecks(t, cfg, expiresInThreeDays, nil) w, deps := newTestWatcher(t, cfg)
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning") deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIP,
}
deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost,
Issuer: testIssuer,
NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
},
}
ctx := t.Context()
// First run = baseline
w.RunOnce(ctx)
// Second run should warn about expiry
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
found := false
for _, n := range notifications {
if n.Priority == "warning" {
found = true
}
}
if !found {
t.Errorf(
"expected expiry warning, got: %v",
notifications,
)
}
} }
func TestTLSExpiryWarningDedup(t *testing.T) { func TestTLSExpiryWarningDedup(t *testing.T) {
@@ -465,25 +606,50 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
title := "TLS Expiry Warning: " + testHost w, deps := newTestWatcher(t, cfg)
// The second check comes within the TLS interval of the first, deps.resolver.allRecords[testHost] = map[string]map[string][]string{
// so it must not warn again. testNS1: {"A": {testIP}},
var warnings int }
deps.resolver.ipAddresses[testHost] = []string{
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) { testIP,
warnings = countNotifications(deps, title) }
}) deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true
if warnings == 0 { deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
t.Fatal("expected expiry warnings from the first check") CommonName: testHost,
Issuer: testIssuer,
NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
},
} }
got := countNotifications(deps, title) ctx := t.Context()
if got != warnings {
// First run = baseline, no notifications
w.RunOnce(ctx)
// Second run should fire one expiry warning
w.RunOnce(ctx)
// Third run should NOT fire another warning (dedup)
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
expiryCount := 0
for _, n := range notifications {
if n.Title == "TLS Expiry Warning: www.example.com" {
expiryCount++
}
}
if expiryCount != 1 {
t.Errorf( t.Errorf(
"expected %d expiry warnings (dedup), got %d", "expected exactly 1 expiry warning (dedup), got %d",
warnings, got, expiryCount,
) )
} }
} }
@@ -491,12 +657,21 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
func TestGracefulShutdown(t *testing.T) { func TestGracefulShutdown(t *testing.T) {
t.Parallel() t.Parallel()
// No domains or hostnames: stopping does not involve DNS.
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.DNSInterval = 100 * time.Millisecond cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond cfg.TLSInterval = 100 * time.Millisecond
w, _ := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
}
deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
@@ -518,52 +693,90 @@ func TestGracefulShutdown(t *testing.T) {
} }
} }
func setupHostnameIP(
deps *testDeps,
hostname, ip string,
) {
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}},
}
deps.portChecker.results[ip+":80"] = true
deps.portChecker.results[ip+":443"] = true
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname},
}
}
func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.resolver.mu.Lock()
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}},
}
deps.resolver.mu.Unlock()
deps.portChecker.mu.Lock()
deps.portChecker.results[ip+":80"] = true
deps.portChecker.results[ip+":443"] = true
deps.portChecker.mu.Unlock()
deps.tlsChecker.mu.Lock()
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: testIssuer,
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname},
}
deps.tlsChecker.mu.Unlock()
}
func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) { func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// The saved state says the last check found testHost at oldIP. w, deps := newTestWatcher(t, cfg)
deps := runChecks(t, cfg, func(deps *testDeps) {
deps.state.SetHostnameState(testHost, &state.HostnameState{ setupHostnameIP(deps, testHost, "10.0.0.1")
RecordsByNameserver: map[string]*state.NameserverRecordState{
oldNS1: { ctx := t.Context()
Records: map[string][]string{"A": {oldIP}}, w.RunOnce(ctx)
Status: "ok",
},
},
})
}, nil)
snap := deps.state.GetSnapshot() snap := deps.state.GetSnapshot()
if _, ok := snap.Ports["10.0.0.1:80"]; !ok {
if _, ok := snap.Ports[oldIP+":80"]; ok { t.Fatal("expected port state for 10.0.0.1:80")
t.Error("port check used stale DNS: found " + oldIP + ":80")
} }
// Port and TLS checks must use the addresses this check found. // DNS changes to a new IP; port and TLS must pick it up.
for _, ip := range addresses(snap.Hostnames[testHost]) { updateHostnameIP(deps, testHost, "10.0.0.2")
if _, ok := snap.Ports[ip+":80"]; !ok {
t.Error("port check used stale DNS: missing " + ip + ":80")
}
certKey := ip + ":443:" + testHost w.RunOnce(ctx)
if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey) snap = deps.state.GetSnapshot()
}
if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
t.Error("port check used stale DNS: missing 10.0.0.2:80")
}
certKey := "10.0.0.2:443:www.example.com"
if _, ok := snap.Certificates[certKey]; !ok {
t.Error("TLS check used stale DNS: missing " + certKey)
} }
} }
func TestSendTestNotification_Enabled(t *testing.T) { func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel() t.Parallel()
// No domains or hostnames: the startup notification does not
// involve DNS.
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
w.RunOnce(t.Context()) w.RunOnce(t.Context())
@@ -584,11 +797,14 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = true cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
@@ -628,11 +844,14 @@ func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.SendTestNotification = false cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
@@ -665,22 +884,32 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, save every nameserver the first check found w, deps := newTestWatcher(t, cfg)
// as failed, and add, as answering, one that live DNS does not list.
deps := runChecks(t, cfg, nil, func(deps *testDeps) {
hs, _ := deps.state.GetHostnameState(testHost)
for _, nsState := range hs.RecordsByNameserver {
nsState.Status = "error"
}
hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ deps.resolver.allRecords[testHost] = map[string]map[string][]string{
Records: map[string][]string{"A": {oldIP}}, testNS1: {"A": {testIP}},
Status: "ok", testNS2: {"A": {testIP}},
} }
deps.resolver.ipAddresses[testHost] = []string{
testIP,
}
deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false
deps.state.SetHostnameState(testHost, hs) ctx := t.Context()
})
assertNotified(t, deps, "NS Failure: "+testHost, "error") w.RunOnce(ctx)
assertNotified(t, deps, "NS Recovery: "+testHost, "success")
deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
}
deps.resolver.mu.Unlock()
w.RunOnce(ctx)
notifications := deps.notifier.getNotifications()
if len(notifications) == 0 {
t.Error("expected notification for NS disappearance")
}
} }