Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd00caed48 |
@@ -72,8 +72,9 @@ notification endpoint set, changes show only on the dashboard; see
|
|||||||
- Every **1 hour** by default, performs a full iterative trace from root servers
|
- Every **1 hour** by default, performs a full iterative trace from root servers
|
||||||
to discover all authoritative nameservers (NS records) for each domain.
|
to discover all authoritative nameservers (NS records) for each domain.
|
||||||
- Queries **every** discovered authoritative nameserver independently.
|
- Queries **every** discovered authoritative nameserver independently.
|
||||||
- Stores the domain's NS record set, as its parent zone's servers delegate it,
|
- Stores the domain's NS record set, as the first of its own nameservers to
|
||||||
and the IPv4 and IPv6 addresses each nameserver's name resolves to.
|
answer returns it, and the IPv4 and IPv6 addresses each nameserver's name
|
||||||
|
resolves to.
|
||||||
- Any change triggers a notification:
|
- Any change triggers a notification:
|
||||||
- NS added to or removed from that set.
|
- NS added to or removed from that set.
|
||||||
- NS address change: a nameserver that stays in the set resolves to
|
- NS address change: a nameserver that stays in the set resolves to
|
||||||
@@ -401,8 +402,8 @@ it watches. Instead, it performs full iterative resolution:
|
|||||||
built into the binary; the list is not refreshed.
|
built into the binary; the list is not refreshed.
|
||||||
2. **TLD delegation**: Queries root servers for the TLD NS records.
|
2. **TLD delegation**: Queries root servers for the TLD NS records.
|
||||||
3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
|
3. **Domain delegation**: Queries TLD nameservers for the domain's NS records.
|
||||||
The delegation they give, from the domain's parent zone, is the domain's NS
|
They refer it to the domain's own nameservers, and the first of those to
|
||||||
record set.
|
answer gives the domain's NS record set.
|
||||||
4. **Authoritative query**: Queries all discovered authoritative nameservers
|
4. **Authoritative query**: Queries all discovered authoritative nameservers
|
||||||
directly for the requested records.
|
directly for the requested records.
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
|
|||||||
|
|
||||||
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
|
- 2026-10-01: README checked against the code and corrected: metrics, CORS,
|
||||||
notification retries, CNAMEs, state file fields, Design tree (closes #108).
|
notification retries, CNAMEs, state file fields, Design tree (closes #108).
|
||||||
- 2026-10-01: a certificate within the expiry warning period is warned about on
|
|
||||||
every TLS check, where some checks used to skip it at random (closes #204).
|
|
||||||
- 2026-10-01: a domain's NS set is its delegation from the parent zone's
|
|
||||||
servers, not whichever of its own servers answered first (closes #200).
|
|
||||||
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
- 2026-10-01: README has Getting Started, Rationale and TODO sections, and its
|
||||||
Architecture section is now Design, in the order policy sets (closes #173).
|
Architecture section is now Design, in the order policy sets (closes #173).
|
||||||
- 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no
|
- 2026-10-01: a zone's server that answers SERVFAIL or a referral leading no
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ func UsableReply(resp *dns.Msg, zone string, name string) bool {
|
|||||||
return usableReply(resp, zone, name)
|
return usableReply(resp, zone, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NSSetFrom exports nsSetFrom for testing.
|
|
||||||
func NSSetFrom(resp *dns.Msg, domain string) []string {
|
|
||||||
return nsSetFrom(resp, domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectIPs exports collectIPs for testing.
|
// CollectIPs exports collectIPs for testing.
|
||||||
func CollectIPs(
|
func CollectIPs(
|
||||||
results map[string]*NameserverResponse,
|
results map[string]*NameserverResponse,
|
||||||
|
|||||||
@@ -222,9 +222,9 @@ func (r *Resolver) followDelegation(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsSet := nsSetFrom(resp, domain)
|
ansNS := extractNSSet(resp.Answer)
|
||||||
if len(nsSet) > 0 {
|
if len(ansNS) > 0 {
|
||||||
return nsSet, nil
|
return ansNS, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// An authoritative reply comes from the servers of the zone
|
// An authoritative reply comes from the servers of the zone
|
||||||
@@ -325,21 +325,6 @@ func referralZone(resp *dns.Msg) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// nsSetFrom returns the NS set of domain that resp, a reply to a query
|
|
||||||
// for domain's NS records, gives: the delegation in a referral to domain
|
|
||||||
// itself, or else the NS records in the answer; empty when it gives
|
|
||||||
// neither. A referral to domain comes from its parent zone's servers,
|
|
||||||
// which all hold the same delegation, so the set does not depend on
|
|
||||||
// which of them answered. domain's own servers, which can disagree about
|
|
||||||
// their NS records, are then not asked.
|
|
||||||
func nsSetFrom(resp *dns.Msg, domain string) []string {
|
|
||||||
if referralZone(resp) == domain {
|
|
||||||
return extractNSSet(resp.Ns)
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractNSSet(resp.Answer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Resolver) resolveNSIPs(
|
func (r *Resolver) resolveNSIPs(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
nsNames []string,
|
nsNames []string,
|
||||||
@@ -387,7 +372,7 @@ func (r *Resolver) resolveNSIterative(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsNames := nsSetFrom(resp, domain)
|
nsNames := extractNSSet(resp.Answer)
|
||||||
if len(nsNames) > 0 {
|
if len(nsNames) > 0 {
|
||||||
return nsNames, nil
|
return nsNames, nil
|
||||||
}
|
}
|
||||||
@@ -480,8 +465,7 @@ func (r *Resolver) resolveARecord(
|
|||||||
|
|
||||||
// FindAuthoritativeNameservers traces the delegation chain from
|
// FindAuthoritativeNameservers traces the delegation chain from
|
||||||
// root servers to discover all authoritative nameservers for the
|
// root servers to discover all authoritative nameservers for the
|
||||||
// given domain, as the delegation from its parent zone's servers lists
|
// given domain. For a name that is not a zone apex it tries each
|
||||||
// them. For a name that is not a zone apex it tries each
|
|
||||||
// parent name in turn, so it returns the nameservers of the zone the
|
// parent name in turn, so it returns the nameservers of the zone the
|
||||||
// name is in.
|
// name is in.
|
||||||
func (r *Resolver) FindAuthoritativeNameservers(
|
func (r *Resolver) FindAuthoritativeNameservers(
|
||||||
@@ -647,10 +631,9 @@ func (r *Resolver) querySingleType(
|
|||||||
|
|
||||||
// A reply with no answer that lists other nameservers, from a server
|
// A reply with no answer that lists other nameservers, from a server
|
||||||
// that does not hold the name's zone, is a referral and says nothing
|
// that does not hold the name's zone, is a referral and says nothing
|
||||||
// about the name's records. A server named in the delegation that
|
// about the name's records. A parent zone's servers send one when
|
||||||
// does not hold the zone may send one, as do a parent zone's servers
|
// every server of the name's own zone failed and
|
||||||
// when FindAuthoritativeNameservers found no delegation for the
|
// FindAuthoritativeNameservers moved on to the parent name.
|
||||||
// name's zone and moved on to a parent name.
|
|
||||||
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
if !msg.Authoritative && len(msg.Answer) == 0 &&
|
||||||
len(extractNSSet(msg.Ns)) > 0 {
|
len(extractNSSet(msg.Ns)) > 0 {
|
||||||
state.gotReferral = true
|
state.gotReferral = true
|
||||||
|
|||||||
@@ -41,15 +41,8 @@ func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
|
|||||||
assert.Empty(t, ips)
|
assert.Empty(t, ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// exampleCom is the zone most cases of TestUsableReply are about.
|
||||||
// exampleCom is the zone most cases of TestUsableReply and
|
const exampleCom = "example.com."
|
||||||
// TestNSSetFrom are about, and wwwExampleCom a name in it.
|
|
||||||
exampleCom = "example.com."
|
|
||||||
wwwExampleCom = "www.example.com."
|
|
||||||
|
|
||||||
// exampleNS is the server the NS records nsRecord builds name.
|
|
||||||
exampleNS = "ns1.example.net."
|
|
||||||
)
|
|
||||||
|
|
||||||
// nsRecord builds an NS record that names a server of zone.
|
// nsRecord builds an NS record that names a server of zone.
|
||||||
func nsRecord(zone string) *dns.NS {
|
func nsRecord(zone string) *dns.NS {
|
||||||
@@ -57,7 +50,7 @@ func nsRecord(zone string) *dns.NS {
|
|||||||
Hdr: dns.RR_Header{
|
Hdr: dns.RR_Header{
|
||||||
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
|
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
|
||||||
},
|
},
|
||||||
Ns: exampleNS,
|
Ns: "ns1.example.net.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +105,7 @@ func TestUsableReply(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "com refers to example.com", resp: referralTo(exampleCom),
|
name: "com refers to example.com", resp: referralTo(exampleCom),
|
||||||
zone: "com.", query: wwwExampleCom, want: true,
|
zone: "com.", query: "www.example.com.", want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "referral back to the zone", resp: referralTo(exampleCom),
|
name: "referral back to the zone", resp: referralTo(exampleCom),
|
||||||
@@ -139,51 +132,6 @@ func TestUsableReply(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNSSetFrom checks which NS set a reply gives for a domain; a set
|
|
||||||
// that is not empty ends the walk. The referral to example.com that
|
|
||||||
// com's servers all send alike gives its delegation, so the set is the
|
|
||||||
// same whichever of them answered, and example.com's own servers, which
|
|
||||||
// can disagree, are not asked.
|
|
||||||
func TestNSSetFrom(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
answer := new(dns.Msg)
|
|
||||||
answer.Authoritative = true
|
|
||||||
answer.Answer = []dns.RR{nsRecord(exampleCom)}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
resp *dns.Msg
|
|
||||||
domain string
|
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "com refers to example.com", resp: referralTo(exampleCom),
|
|
||||||
domain: exampleCom, want: []string{exampleNS},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "com refers on, for www.example.com",
|
|
||||||
resp: referralTo(exampleCom), domain: wwwExampleCom,
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "answer from a server that holds example.com",
|
|
||||||
resp: answer, domain: exampleCom,
|
|
||||||
want: []string{exampleNS},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, tt.want,
|
|
||||||
resolver.NSSetFrom(tt.resp, tt.domain),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ func NewForTest(
|
|||||||
tlsCheck: tc,
|
tlsCheck: tc,
|
||||||
notify: n,
|
notify: n,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +72,6 @@ func (w *Watcher) CheckAllPorts(ctx context.Context) {
|
|||||||
w.checkAllPorts(ctx)
|
w.checkAllPorts(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunTLSChecks exports runTLSChecks for testing.
|
|
||||||
func (w *Watcher) RunTLSChecks(ctx context.Context) {
|
|
||||||
w.runTLSChecks(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildHostnameState exports buildHostnameState for testing.
|
// BuildHostnameState exports buildHostnameState for testing.
|
||||||
func BuildHostnameState(
|
func BuildHostnameState(
|
||||||
results map[string]*resolver.NameserverResponse,
|
results map[string]*resolver.NameserverResponse,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -58,6 +59,8 @@ type Watcher struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{} // closed when Run returns
|
done chan struct{} // closed when Run returns
|
||||||
firstRun bool
|
firstRun bool
|
||||||
|
expiryNotifiedMu sync.Mutex
|
||||||
|
expiryNotified map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Watcher instance wired into the fx lifecycle.
|
// New creates a new Watcher instance wired into the fx lifecycle.
|
||||||
@@ -74,6 +77,7 @@ func New(
|
|||||||
tlsCheck: params.TLSCheck,
|
tlsCheck: params.TLSCheck,
|
||||||
notify: params.Notify,
|
notify: params.Notify,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
|
expiryNotified: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
@@ -1024,6 +1028,22 @@ func (w *Watcher) checkTLSExpiry(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate expiry warnings: don't re-notify for the same
|
||||||
|
// hostname within the TLS check interval.
|
||||||
|
dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
|
||||||
|
|
||||||
|
w.expiryNotifiedMu.Lock()
|
||||||
|
|
||||||
|
lastNotified, seen := w.expiryNotified[dedupKey]
|
||||||
|
if seen && time.Since(lastNotified) < w.config.TLSInterval {
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.expiryNotified[dedupKey] = time.Now()
|
||||||
|
w.expiryNotifiedMu.Unlock()
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Host: %s\nIP: %s\nCN: %s\n"+
|
"Host: %s\nIP: %s\nCN: %s\n"+
|
||||||
"Expires: %s (%.0f days)",
|
"Expires: %s (%.0f days)",
|
||||||
|
|||||||
@@ -615,56 +615,35 @@ func TestTLSExpiryWarning(t *testing.T) {
|
|||||||
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
assertNotified(t, deps, "TLS Expiry Warning: "+testHost, "warning")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTLSExpiryWarningEachCheck runs the TLS checks three times in a
|
func TestTLSExpiryWarningDedup(t *testing.T) {
|
||||||
// row on hostname and port state built here, for a certificate that
|
|
||||||
// expires within the warning period. Each check warns once, whether the
|
|
||||||
// TLS interval is a nanosecond, shorter than the time between two
|
|
||||||
// checks, or a day, longer than it.
|
|
||||||
func TestTLSExpiryWarningEachCheck(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
title := "TLS Expiry Warning: " + host
|
|
||||||
|
|
||||||
for _, interval := range []time.Duration{time.Nanosecond, 24 * time.Hour} {
|
|
||||||
t.Run(interval.String(), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cfg := defaultTestConfig(t)
|
cfg := defaultTestConfig(t)
|
||||||
cfg.Hostnames = []string{host}
|
cfg.Hostnames = []string{testHost}
|
||||||
cfg.TLSInterval = interval
|
cfg.TLSInterval = 24 * time.Hour
|
||||||
|
|
||||||
// The TLS checks read the saved hostname and port state and
|
title := "TLS Expiry Warning: " + testHost
|
||||||
// look nothing up, so the watcher has no resolver.
|
|
||||||
deps := newTestDeps(t, cfg)
|
|
||||||
w := watcher.NewForTest(
|
|
||||||
cfg, deps.state, nil,
|
|
||||||
deps.portChecker, deps.tlsChecker, deps.notifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
expiresInThreeDays(deps)
|
// The second check comes within the TLS interval of the first,
|
||||||
deps.state.SetHostnameState(host, saved(
|
// so it must not warn again.
|
||||||
map[string]*state.NameserverRecordState{
|
var warnings int
|
||||||
nsA: answered(map[string][]string{"A": {ip1}}),
|
|
||||||
},
|
deps := runChecks(t, cfg, expiresInThreeDays, func(deps *testDeps) {
|
||||||
))
|
warnings = countNotifications(deps, title)
|
||||||
deps.state.SetPortState(ip1+":443", &state.PortState{
|
|
||||||
Open: true, Hostnames: []string{host},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
for check := 1; check <= 3; check++ {
|
if warnings == 0 {
|
||||||
w.RunTLSChecks(t.Context())
|
t.Fatal("expected expiry warnings from the first check")
|
||||||
|
}
|
||||||
|
|
||||||
got := countNotifications(deps, title)
|
got := countNotifications(deps, title)
|
||||||
if got != check {
|
if got != warnings {
|
||||||
t.Fatalf(
|
t.Errorf(
|
||||||
"after check %d: %d expiry warnings, want %d",
|
"expected %d expiry warnings (dedup), got %d",
|
||||||
check, got, check,
|
warnings, got,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGracefulShutdown(t *testing.T) {
|
func TestGracefulShutdown(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
Reference in New Issue
Block a user