12 Commits

Author SHA1 Message Date
clawbot
619efffb07 fix: 700ms query timeout, iterative resolution for A/NS lookups
All checks were successful
Check / check (pull_request) Successful in 10m18s
Replaces recursive queries to root servers (which don't answer RD=1)
with proper iterative resolution through the delegation chain.
Reduces per-query timeout from 5s to 700ms.

Fixes the root cause of make check hanging: resolveARecord and
resolveNSRecursive were sending recursive queries to root nameservers,
which silently dropped them, causing 5s timeouts × retries × servers.

closes #24
2026-02-28 03:19:38 -08:00
0eb57fc15b Merge pull request 'fix: look up A/AAAA records for apex domains to enable port/TLS checks (closes #19)' (#21) from fix/domain-port-tls-state-lookup into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #21
2026-02-28 12:09:04 +01:00
5739108dc7 Merge branch 'main' into fix/domain-port-tls-state-lookup
Some checks failed
Check / check (pull_request) Failing after 5m41s
2026-02-28 12:08:56 +01:00
54272c2be5 Merge pull request 'fix: deduplicate TLS expiry warnings to prevent notification spam (closes #18)' (#22) from fix/tls-expiry-dedup into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #22
2026-02-28 12:08:46 +01:00
b18d29d586 Merge branch 'main' into fix/domain-port-tls-state-lookup
Some checks failed
Check / check (pull_request) Failing after 5m40s
2026-02-28 12:08:25 +01:00
e63241cc3c Merge branch 'main' into fix/tls-expiry-dedup
Some checks failed
Check / check (pull_request) Failing after 5m40s
2026-02-28 12:08:19 +01:00
5ab217bfd2 Merge pull request 'Reduce DNS query timeout and limit root server fan-out (closes #29)' (#30) from fix/reduce-dns-timeout-and-root-fanout into main
Some checks failed
Check / check (push) Has been cancelled
Reviewed-on: #30
2026-02-28 12:07:20 +01:00
518a2cc42e Merge pull request 'doc: add TESTING.md — real DNS only, no mocks' (#34) from doc/testing-policy into main
Some checks failed
Check / check (push) Has been cancelled
Reviewed-on: #34
2026-02-28 12:06:57 +01:00
user
4cb81aac24 doc: add testing policy — real DNS only, no mocks
Some checks failed
Check / check (pull_request) Failing after 5m24s
Documents the project testing philosophy: all resolver tests must
use live DNS queries. Mocking the DNS client layer is not permitted.
Includes rationale and anti-patterns to avoid.
2026-02-22 04:28:47 -08:00
user
203b581704 Reduce DNS query timeout to 2s and limit root server fan-out to 3
Some checks failed
Check / check (pull_request) Failing after 5m57s
- Reduce queryTimeoutDuration from 5s to 2s
- Add randomRootServers() that shuffles and picks 3 root servers
- Replace all rootServerList() call sites with randomRootServers()
- Keep maxRetries = 2

Closes #29
2026-02-22 03:35:16 -08:00
clawbot
82fd68a41b fix: deduplicate TLS expiry warnings to prevent notification spam (closes #18)
Some checks failed
Check / check (pull_request) Failing after 5m31s
checkTLSExpiry fired every monitoring cycle with no deduplication,
causing notification spam for expiring certificates. Added an
in-memory map tracking the last notification time per domain/IP
pair, suppressing re-notification within the TLS check interval.

Added TestTLSExpiryWarningDedup to verify deduplication works.
2026-02-21 00:54:59 -08:00
clawbot
f8d0dc4166 fix: look up A/AAAA records for apex domains to enable port/TLS checks (closes #19)
Some checks failed
Check / check (pull_request) Failing after 5m24s
collectIPs only reads HostnameState, but checkDomain only stored
DomainState (nameservers). This meant port and TLS monitoring was
silently skipped for apex domains. Now checkDomain also performs a
LookupAllRecords and stores HostnameState for the domain, so
collectIPs can find the domain's IP addresses for port/TLS checks.

Added TestDomainPortAndTLSChecks to verify the fix.
2026-02-21 00:53:42 -08:00
5 changed files with 360 additions and 594 deletions

34
TESTING.md Normal file
View File

@@ -0,0 +1,34 @@
# Testing Policy
## DNS Resolution Tests
All resolver tests **MUST** use live queries against real DNS servers.
No mocking of the DNS client layer is permitted.
### Rationale
The resolver performs iterative resolution from root nameservers through
the full delegation chain. Mocked responses cannot faithfully represent
the variety of real-world DNS behavior (truncation, referrals, glue
records, DNSSEC, varied response times, EDNS, etc.). Testing against
real servers ensures the resolver works correctly in production.
### Constraints
- Tests hit real DNS infrastructure and require network access
- Test duration depends on network conditions; timeout tuning keeps
the suite within the 30-second target
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
plus processing margin
- Root server fan-out is limited to reduce parallel query load
- Flaky failures from transient network issues are acceptable and
should be investigated as potential resolver bugs, not papered over
with mocks or skip flags
### What NOT to do
- **Do not mock `DNSClient`** for resolver tests (the mock constructor
exists for unit-testing other packages that consume the resolver)
- **Do not add `-short` flags** to skip slow tests
- **Do not increase `-timeout`** to hide hanging queries
- **Do not modify linter configuration** to suppress findings

View File

@@ -13,7 +13,7 @@ import (
) )
const ( const (
queryTimeoutDuration = 5 * time.Second queryTimeoutDuration = 2 * time.Second
maxRetries = 2 maxRetries = 2
maxDelegation = 20 maxDelegation = 20
timeoutMultiplier = 2 timeoutMultiplier = 2
@@ -227,7 +227,7 @@ func (r *Resolver) followDelegation(
authNS := extractNSSet(resp.Ns) authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 { if len(authNS) == 0 {
return r.resolveNSRecursive(ctx, domain) return r.resolveNSIterative(ctx, domain)
} }
glue := extractGlue(resp.Extra) glue := extractGlue(resp.Extra)
@@ -291,60 +291,84 @@ func (r *Resolver) resolveNSIPs(
return ips return ips
} }
// resolveNSRecursive queries for NS records using recursive // resolveNSIterative queries for NS records using iterative
// resolution as a fallback for intercepted environments. // resolution as a fallback when followDelegation finds no
func (r *Resolver) resolveNSRecursive( // authoritative answer in the delegation chain.
func (r *Resolver) resolveNSIterative(
ctx context.Context, ctx context.Context,
domain string, domain string,
) ([]string, error) { ) ([]string, error) {
domain = dns.Fqdn(domain)
msg := new(dns.Msg)
msg.SetQuestion(domain, dns.TypeNS)
msg.RecursionDesired = true
for _, ip := range rootServerList()[:3] {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
addr := net.JoinHostPort(ip, "53") domain = dns.Fqdn(domain)
servers := rootServerList()
resp, _, err := r.client.ExchangeContext(ctx, msg, addr) for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
resp, err := r.queryServers(
ctx, servers, domain, dns.TypeNS,
)
if err != nil { if err != nil {
continue return nil, err
} }
nsNames := extractNSSet(resp.Answer) nsNames := extractNSSet(resp.Answer)
if len(nsNames) > 0 { if len(nsNames) > 0 {
return nsNames, nil return nsNames, nil
} }
// Follow delegation.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
break
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
break
}
servers = nextServers
} }
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// resolveARecord resolves a hostname to IPv4 addresses. // resolveARecord resolves a hostname to IPv4 addresses using
// iterative resolution through the delegation chain.
func (r *Resolver) resolveARecord( func (r *Resolver) resolveARecord(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) ([]string, error) { ) ([]string, error) {
hostname = dns.Fqdn(hostname)
msg := new(dns.Msg)
msg.SetQuestion(hostname, dns.TypeA)
msg.RecursionDesired = true
for _, ip := range rootServerList()[:3] {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
addr := net.JoinHostPort(ip, "53") hostname = dns.Fqdn(hostname)
servers := rootServerList()
resp, _, err := r.client.ExchangeContext(ctx, msg, addr) for range maxDelegation {
if err != nil { if checkCtx(ctx) != nil {
continue return nil, ErrContextCanceled
} }
resp, err := r.queryServers(
ctx, servers, hostname, dns.TypeA,
)
if err != nil {
return nil, fmt.Errorf(
"resolving %s: %w", hostname, err,
)
}
// Check for A records in the answer section.
var ips []string var ips []string
for _, rr := range resp.Answer { for _, rr := range resp.Answer {
@@ -356,6 +380,24 @@ func (r *Resolver) resolveARecord(
if len(ips) > 0 { if len(ips) > 0 {
return ips, nil return ips, nil
} }
// Follow delegation if present.
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
break
}
glue := extractGlue(resp.Extra)
nextServers := glueIPs(authNS, glue)
if len(nextServers) == 0 {
// Resolve NS IPs iteratively — but guard
// against infinite recursion by using only
// already-resolved servers.
break
}
servers = nextServers
} }
return nil, fmt.Errorf( return nil, fmt.Errorf(

View File

@@ -2,7 +2,6 @@ package resolver_test
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
@@ -11,504 +10,12 @@ 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"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
) )
// ----------------------------------------------------------------
// Mock DNS client
// ----------------------------------------------------------------
// mockDNSClient implements resolver.DNSClient with canned responses.
type mockDNSClient struct {
handlers map[string]func(msg *dns.Msg) *dns.Msg
}
func newMockClient() *mockDNSClient {
return &mockDNSClient{
handlers: make(map[string]func(msg *dns.Msg) *dns.Msg),
}
}
func (m *mockDNSClient) ExchangeContext(
ctx context.Context,
msg *dns.Msg,
addr string,
) (*dns.Msg, time.Duration, error) {
err := ctx.Err()
if err != nil {
return nil, 0, err
}
host, _, _ := net.SplitHostPort(addr)
if host == "" {
host = addr
}
qname := msg.Question[0].Name
qtype := dns.TypeToString[msg.Question[0].Qtype]
resp := m.findHandler(host, qname, qtype, msg)
return resp, time.Millisecond, nil
}
func (m *mockDNSClient) findHandler(
host, qname, qtype string,
msg *dns.Msg,
) *dns.Msg {
key := fmt.Sprintf(
"%s|%s|%s", host, strings.ToLower(qname), qtype,
)
if h, ok := m.handlers[key]; ok {
return h(msg)
}
wildKey := fmt.Sprintf(
"*|%s|%s", strings.ToLower(qname), qtype,
)
if h, ok := m.handlers[wildKey]; ok {
return h(msg)
}
resp := new(dns.Msg)
resp.SetReply(msg)
return resp
}
func (m *mockDNSClient) on(
server, qname, qtype string,
handler func(msg *dns.Msg) *dns.Msg,
) {
key := fmt.Sprintf(
"%s|%s|%s",
server, dns.Fqdn(strings.ToLower(qname)), qtype,
)
m.handlers[key] = handler
}
// ----------------------------------------------------------------
// Response builders
// ----------------------------------------------------------------
func referralResponse(
msg *dns.Msg,
nsNames []string,
glue map[string]string,
) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
for _, ns := range nsNames {
resp.Ns = append(resp.Ns, &dns.NS{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: 3600,
},
Ns: dns.Fqdn(ns),
})
}
for name, ip := range glue {
resp.Extra = append(resp.Extra, &dns.A{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name),
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 3600,
},
A: net.ParseIP(ip),
})
}
return resp
}
func nsAnswerResponse(
msg *dns.Msg, nsNames []string,
) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
for _, ns := range nsNames {
resp.Answer = append(resp.Answer, &dns.NS{
Hdr: dns.RR_Header{
Name: msg.Question[0].Name,
Rrtype: dns.TypeNS,
Class: dns.ClassINET,
Ttl: 3600,
},
Ns: dns.Fqdn(ns),
})
}
return resp
}
func nxdomainResponse(msg *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Rcode = dns.RcodeNameError
return resp
}
func aResponse(
msg *dns.Msg, name string, ip string,
) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Answer = append(resp.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name), Rrtype: dns.TypeA,
Class: dns.ClassINET, Ttl: 300,
},
A: net.ParseIP(ip),
})
return resp
}
func aaaaResponse(
msg *dns.Msg, name string, ip string,
) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Answer = append(resp.Answer, &dns.AAAA{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name), Rrtype: dns.TypeAAAA,
Class: dns.ClassINET, Ttl: 300,
},
AAAA: net.ParseIP(ip),
})
return resp
}
func emptyResponse(msg *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
return resp
}
// ----------------------------------------------------------------
// Mock DNS hierarchy setup
// ----------------------------------------------------------------
// mockData holds all test DNS hierarchy configuration.
type mockData struct {
tldNS []string
tldGlue map[string]string
exNS []string
exGlue map[string]string
cfNS []string
cfGlue map[string]string
}
func newMockData() mockData {
return mockData{
tldNS: []string{"ns1.tld.com", "ns2.tld.com"},
tldGlue: map[string]string{
"ns1.tld.com": "10.0.0.1",
"ns2.tld.com": "10.0.0.2",
},
exNS: []string{
"ns1.example.com", "ns2.example.com",
"ns3.example.com",
},
exGlue: map[string]string{
"ns1.example.com": "10.1.0.1",
"ns2.example.com": "10.1.0.2",
"ns3.example.com": "10.1.0.3",
},
cfNS: []string{
"ns1.cloudflare.com", "ns2.cloudflare.com",
},
cfGlue: map[string]string{
"ns1.cloudflare.com": "10.2.0.1",
"ns2.cloudflare.com": "10.2.0.2",
},
}
}
func rootIPList() []string {
return []string{
"198.41.0.4", "170.247.170.2", "192.33.4.12",
"199.7.91.13", "192.203.230.10", "192.5.5.241",
"192.112.36.4", "198.97.190.53", "192.36.148.17",
"192.58.128.30", "193.0.14.129", "199.7.83.42",
"202.12.27.33",
}
}
func allQueryTypes() []string {
return []string{
"NS", "A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA",
}
}
func setupRootDelegations(
m *mockDNSClient,
tNS []string,
tGlue map[string]string,
) {
domains := []string{
"example.com.", "www.example.com.",
"this-surely-does-not-exist-xyz.example.com.",
"cloudflare.com.",
}
for _, rootIP := range rootIPList() {
for _, domain := range domains {
for _, qtype := range allQueryTypes() {
m.on(rootIP, domain, qtype,
func(msg *dns.Msg) *dns.Msg {
return referralResponse(
msg, tNS, tGlue,
)
},
)
}
}
}
}
func setupRootARecords(m *mockDNSClient) {
nsIPs := map[string]string{
"ns1.example.com.": "10.1.0.1",
"ns2.example.com.": "10.1.0.2",
"ns3.example.com.": "10.1.0.3",
"ns1.cloudflare.com.": "10.2.0.1",
"ns2.cloudflare.com.": "10.2.0.2",
}
for _, rootIP := range rootIPList() {
for nsName, nsIP := range nsIPs {
ip := nsIP
name := nsName
m.on(rootIP, name, "A",
func(msg *dns.Msg) *dns.Msg {
return aResponse(msg, name, ip)
},
)
}
}
}
func setupTLDDelegations(
m *mockDNSClient,
exNS []string,
exGlue map[string]string,
cfNS []string,
cfGlue map[string]string,
) {
tldIPs := []string{"10.0.0.1", "10.0.0.2"}
exDomains := []string{
"example.com.", "www.example.com.",
"this-surely-does-not-exist-xyz.example.com.",
}
for _, tldIP := range tldIPs {
for _, domain := range exDomains {
for _, qtype := range allQueryTypes() {
m.on(tldIP, domain, qtype,
func(msg *dns.Msg) *dns.Msg {
return referralResponse(
msg, exNS, exGlue,
)
},
)
}
}
for _, qtype := range allQueryTypes() {
m.on(tldIP, "cloudflare.com.", qtype,
func(msg *dns.Msg) *dns.Msg {
return referralResponse(
msg, cfNS, cfGlue,
)
},
)
}
}
}
func setupExampleNSAndA(
m *mockDNSClient, exNS []string,
) {
exIPs := []string{"10.1.0.1", "10.1.0.2", "10.1.0.3"}
for _, authIP := range exIPs {
m.on(authIP, "example.com.", "NS",
func(msg *dns.Msg) *dns.Msg {
return nsAnswerResponse(msg, exNS)
},
)
m.on(authIP, "example.com.", "A",
func(msg *dns.Msg) *dns.Msg {
return aResponse(
msg, "example.com.", "93.184.216.34",
)
},
)
m.on(authIP, "example.com.", "AAAA",
func(msg *dns.Msg) *dns.Msg {
return aaaaResponse(
msg, "example.com.",
"2606:2800:220:1:248:1893:25c8:1946",
)
},
)
}
}
func setupExampleMXAndTXT(m *mockDNSClient) {
exIPs := []string{"10.1.0.1", "10.1.0.2", "10.1.0.3"}
for _, authIP := range exIPs {
m.on(authIP, "example.com.", "MX",
func(msg *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Answer = append(resp.Answer,
&dns.MX{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeMX,
Class: dns.ClassINET,
Ttl: 300,
},
Preference: 10,
Mx: "mail.example.com.",
},
&dns.MX{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeMX,
Class: dns.ClassINET,
Ttl: 300,
},
Preference: 20,
Mx: "mail2.example.com.",
},
)
return resp
},
)
m.on(authIP, "example.com.", "TXT",
func(msg *dns.Msg) *dns.Msg {
resp := new(dns.Msg)
resp.SetReply(msg)
resp.Answer = append(resp.Answer, &dns.TXT{
Hdr: dns.RR_Header{
Name: "example.com.",
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: 300,
},
Txt: []string{
"v=spf1 include:_spf.example.com ~all",
},
})
return resp
},
)
}
}
func setupExampleSubdomains(
m *mockDNSClient, exNS []string,
) {
exIPs := []string{"10.1.0.1", "10.1.0.2", "10.1.0.3"}
for _, authIP := range exIPs {
m.on(authIP, "www.example.com.", "NS",
func(msg *dns.Msg) *dns.Msg {
return nsAnswerResponse(msg, exNS)
},
)
m.on(authIP, "www.example.com.", "A",
func(msg *dns.Msg) *dns.Msg {
return aResponse(
msg, "www.example.com.", "93.184.216.34",
)
},
)
nxName := "this-surely-does-not-exist-xyz.example.com."
for _, qtype := range allQueryTypes() {
m.on(authIP, nxName, qtype, nxdomainResponse)
}
}
}
func setupCloudflareAuthRecords(
m *mockDNSClient, cfNS []string,
) {
cfIPs := []string{"10.2.0.1", "10.2.0.2"}
for _, authIP := range cfIPs {
m.on(authIP, "cloudflare.com.", "NS",
func(msg *dns.Msg) *dns.Msg {
return nsAnswerResponse(msg, cfNS)
},
)
m.on(authIP, "cloudflare.com.", "A",
func(msg *dns.Msg) *dns.Msg {
return aResponse(
msg, "cloudflare.com.", "104.16.132.229",
)
},
)
m.on(authIP, "cloudflare.com.", "AAAA",
func(msg *dns.Msg) *dns.Msg {
return aaaaResponse(
msg, "cloudflare.com.",
"2606:4700::6810:84e5",
)
},
)
m.on(authIP, "cloudflare.com.", "MX", emptyResponse)
m.on(authIP, "cloudflare.com.", "TXT", emptyResponse)
}
}
func setupMockDNS() *mockDNSClient {
m := newMockClient()
d := newMockData()
setupRootDelegations(m, d.tldNS, d.tldGlue)
setupRootARecords(m)
setupTLDDelegations(m, d.exNS, d.exGlue, d.cfNS, d.cfGlue)
setupExampleNSAndA(m, d.exNS)
setupExampleMXAndTXT(m)
setupExampleSubdomains(m, d.exNS)
setupCloudflareAuthRecords(m, d.cfNS)
return m
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Test helpers // Test helpers
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -521,14 +28,14 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
&slog.HandlerOptions{Level: slog.LevelDebug}, &slog.HandlerOptions{Level: slog.LevelDebug},
)) ))
return resolver.NewFromLoggerWithClient(log, setupMockDNS()) return resolver.NewFromLogger(log)
} }
func testContext(t *testing.T) context.Context { func testContext(t *testing.T) context.Context {
t.Helper() t.Helper()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second, context.Background(), 60*time.Second,
) )
t.Cleanup(cancel) t.Cleanup(cancel)
@@ -565,23 +72,23 @@ func TestFindAuthoritativeNameservers_ValidDomain(
ctx := testContext(t) ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers( nameservers, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, nameservers) require.NotEmpty(t, nameservers)
hasExampleNS := false hasGoogleNS := false
for _, ns := range nameservers { for _, ns := range nameservers {
if strings.Contains(ns, "example") { if strings.Contains(ns, "google") {
hasExampleNS = true hasGoogleNS = true
break break
} }
} }
assert.True(t, hasExampleNS, assert.True(t, hasGoogleNS,
"expected example nameservers, got: %v", nameservers, "expected google nameservers, got: %v", nameservers,
) )
} }
@@ -594,7 +101,7 @@ func TestFindAuthoritativeNameservers_Subdomain(
ctx := testContext(t) ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers( nameservers, err := r.FindAuthoritativeNameservers(
ctx, "www.example.com", ctx, "www.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, nameservers) require.NotEmpty(t, nameservers)
@@ -609,7 +116,7 @@ func TestFindAuthoritativeNameservers_ReturnsSorted(
ctx := testContext(t) ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers( nameservers, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -629,12 +136,12 @@ func TestFindAuthoritativeNameservers_Deterministic(
ctx := testContext(t) ctx := testContext(t)
first, err := r.FindAuthoritativeNameservers( first, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
second, err := r.FindAuthoritativeNameservers( second, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -650,12 +157,12 @@ func TestFindAuthoritativeNameservers_TrailingDot(
ctx := testContext(t) ctx := testContext(t)
ns1, err := r.FindAuthoritativeNameservers( ns1, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
ns2, err := r.FindAuthoritativeNameservers( ns2, err := r.FindAuthoritativeNameservers(
ctx, "example.com.", ctx, "google.com.",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -692,10 +199,10 @@ func TestQueryNameserver_BasicA(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, "www.example.com", ctx, ns, "www.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, resp) require.NotNil(t, resp)
@@ -706,7 +213,7 @@ func TestQueryNameserver_BasicA(t *testing.T) {
hasRecords := len(resp.Records["A"]) > 0 || hasRecords := len(resp.Records["A"]) > 0 ||
len(resp.Records["CNAME"]) > 0 len(resp.Records["CNAME"]) > 0
assert.True(t, hasRecords, assert.True(t, hasRecords,
"expected A or CNAME records for www.example.com", "expected A or CNAME records for www.google.com",
) )
} }
@@ -740,16 +247,16 @@ func TestQueryNameserver_MX(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, "example.com", ctx, ns, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
mxRecords := resp.Records["MX"] mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords, require.NotEmpty(t, mxRecords,
"example.com should have MX records", "google.com should have MX records",
) )
} }
@@ -758,16 +265,16 @@ func TestQueryNameserver_TXT(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, "example.com", ctx, ns, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
txtRecords := resp.Records["TXT"] txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords, require.NotEmpty(t, txtRecords,
"example.com should have TXT records", "google.com should have TXT records",
) )
hasSPF := false hasSPF := false
@@ -781,7 +288,7 @@ func TestQueryNameserver_TXT(t *testing.T) {
} }
assert.True(t, hasSPF, assert.True(t, hasSPF,
"example.com should have SPF TXT record", "google.com should have SPF TXT record",
) )
} }
@@ -790,11 +297,11 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, ctx, ns,
"this-surely-does-not-exist-xyz.example.com", "this-surely-does-not-exist-xyz.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -806,10 +313,10 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, "example.com", ctx, ns, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -846,11 +353,11 @@ func TestQueryNameserver_EmptyRecordsOnNXDomain(
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver( resp, err := r.QueryNameserver(
ctx, ns, ctx, ns,
"this-surely-does-not-exist-xyz.example.com", "this-surely-does-not-exist-xyz.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -867,15 +374,15 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com") ns := findOneNSForDomain(t, r, ctx, "google.com")
resp1, err := r.QueryNameserver( resp1, err := r.QueryNameserver(
ctx, ns, "example.com", ctx, ns, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
resp2, err := r.QueryNameserver( resp2, err := r.QueryNameserver(
ctx, ns, "example.com.", ctx, ns, "google.com.",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -893,7 +400,7 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
ctx := testContext(t) ctx := testContext(t)
results, err := r.QueryAllNameservers( results, err := r.QueryAllNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, results) require.NotEmpty(t, results)
@@ -912,7 +419,7 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
ctx := testContext(t) ctx := testContext(t)
results, err := r.QueryAllNameservers( results, err := r.QueryAllNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -934,7 +441,7 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
results, err := r.QueryAllNameservers( results, err := r.QueryAllNameservers(
ctx, ctx,
"this-surely-does-not-exist-xyz.example.com", "this-surely-does-not-exist-xyz.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -956,7 +463,7 @@ func TestLookupNS_ValidDomain(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "example.com") nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, nameservers) require.NotEmpty(t, nameservers)
@@ -973,7 +480,7 @@ func TestLookupNS_Sorted(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "example.com") nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(nameservers)) assert.True(t, sort.StringsAreSorted(nameservers))
@@ -985,11 +492,11 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
fromLookup, err := r.LookupNS(ctx, "example.com") fromLookup, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
fromFind, err := r.FindAuthoritativeNameservers( fromFind, err := r.FindAuthoritativeNameservers(
ctx, "example.com", ctx, "google.com",
) )
require.NoError(t, err) require.NoError(t, err)
@@ -1006,7 +513,7 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com") ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
require.NotEmpty(t, ips) require.NotEmpty(t, ips)
@@ -1024,7 +531,7 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com") ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
seen := make(map[string]bool) seen := make(map[string]bool)
@@ -1041,7 +548,7 @@ func TestResolveIPAddresses_Sorted(t *testing.T) {
r := newTestResolver(t) r := newTestResolver(t)
ctx := testContext(t) ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com") ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err) require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(ips)) assert.True(t, sort.StringsAreSorted(ips))
@@ -1057,7 +564,7 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty(
ips, err := r.ResolveIPAddresses( ips, err := r.ResolveIPAddresses(
ctx, ctx,
"this-surely-does-not-exist-xyz.example.com", "this-surely-does-not-exist-xyz.google.com",
) )
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, ips) assert.Empty(t, ips)
@@ -1087,9 +594,7 @@ func TestFindAuthoritativeNameservers_ContextCanceled(
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := r.FindAuthoritativeNameservers( _, err := r.FindAuthoritativeNameservers(ctx, "google.com")
ctx, "example.com",
)
assert.Error(t, err) assert.Error(t, err)
} }
@@ -1101,7 +606,7 @@ func TestQueryNameserver_ContextCanceled(t *testing.T) {
cancel() cancel()
_, err := r.QueryNameserver( _, err := r.QueryNameserver(
ctx, "ns1.example.com.", "example.com", ctx, "ns1.google.com.", "google.com",
) )
assert.Error(t, err) assert.Error(t, err)
} }
@@ -1113,7 +618,7 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := r.QueryAllNameservers(ctx, "example.com") _, err := r.QueryAllNameservers(ctx, "google.com")
assert.Error(t, err) assert.Error(t, err)
} }
@@ -1124,6 +629,6 @@ func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := r.ResolveIPAddresses(ctx, "example.com") _, err := r.ResolveIPAddresses(ctx, "google.com")
assert.Error(t, err) assert.Error(t, err)
} }

View File

@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"sort" "sort"
"strings" "strings"
"sync"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -49,6 +50,8 @@ type Watcher struct {
notify Notifier notify Notifier
cancel context.CancelFunc cancel context.CancelFunc
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.
@@ -65,6 +68,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{
@@ -108,6 +112,7 @@ func NewForTest(
tlsCheck: tc, tlsCheck: tc,
notify: n, notify: n,
firstRun: true, firstRun: true,
expiryNotified: make(map[string]time.Time),
} }
} }
@@ -206,6 +211,28 @@ func (w *Watcher) checkDomain(
Nameservers: nameservers, Nameservers: nameservers,
LastChecked: now, LastChecked: now,
}) })
// Also look up A/AAAA records for the apex domain so that
// port and TLS checks (which read HostnameState) can find
// the domain's IP addresses.
records, err := w.resolver.LookupAllRecords(ctx, domain)
if err != nil {
w.log.Error(
"failed to lookup records for domain",
"domain", domain,
"error", err,
)
return
}
prevHS, hasPrevHS := w.state.GetHostnameState(domain)
if hasPrevHS && !w.firstRun {
w.detectHostnameChanges(ctx, domain, prevHS, records)
}
newState := buildHostnameState(records, now)
w.state.SetHostnameState(domain, newState)
} }
func (w *Watcher) detectNSChanges( func (w *Watcher) detectNSChanges(
@@ -691,6 +718,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)",

View File

@@ -273,6 +273,10 @@ func setupBaselineMocks(deps *testDeps) {
"ns1.example.com.", "ns1.example.com.",
"ns2.example.com.", "ns2.example.com.",
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}},
"ns2.example.com.": {"A": {"93.184.216.34"}},
}
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}}, "ns1.example.com.": {"A": {"93.184.216.34"}},
"ns2.example.com.": {"A": {"93.184.216.34"}}, "ns2.example.com.": {"A": {"93.184.216.34"}},
@@ -290,6 +294,14 @@ func setupBaselineMocks(deps *testDeps) {
"www.example.com", "www.example.com",
}, },
} }
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: "example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
"example.com",
},
}
} }
func assertNoNotifications( func assertNoNotifications(
@@ -322,14 +334,74 @@ func assertStatePopulated(
) )
} }
if len(snap.Hostnames) != 1 { // Hostnames includes both explicit hostnames and domains
// (domains now also get hostname state for port/TLS checks).
if len(snap.Hostnames) < 1 {
t.Errorf( t.Errorf(
"expected 1 hostname in state, got %d", "expected at least 1 hostname in state, got %d",
len(snap.Hostnames), len(snap.Hostnames),
) )
} }
} }
func TestDomainPortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{"example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
}
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}},
}
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: "example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
"example.com",
},
}
w.RunOnce(t.Context())
snap := deps.state.GetSnapshot()
// Domain should have port state populated
if len(snap.Ports) == 0 {
t.Error("expected port state for domain, got none")
}
// Domain should have certificate state populated
if len(snap.Certificates) == 0 {
t.Error("expected certificate state for domain, got none")
}
// Verify port checker was actually called
deps.portChecker.mu.Lock()
calls := deps.portChecker.calls
deps.portChecker.mu.Unlock()
if calls == 0 {
t.Error("expected port checker to be called for domain")
}
// Verify TLS checker was actually called
deps.tlsChecker.mu.Lock()
tlsCalls := deps.tlsChecker.calls
deps.tlsChecker.mu.Unlock()
if tlsCalls == 0 {
t.Error("expected TLS checker to be called for domain")
}
}
func TestNSChangeDetection(t *testing.T) { func TestNSChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
@@ -342,6 +414,12 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.example.com.", "ns1.example.com.",
"ns2.example.com.", "ns2.example.com.",
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
"ns2.example.com.": {"A": {"1.2.3.4"}},
}
deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false
ctx := t.Context() ctx := t.Context()
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -351,6 +429,10 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.example.com.", "ns1.example.com.",
"ns3.example.com.", "ns3.example.com.",
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
"ns3.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -506,6 +588,61 @@ func TestTLSExpiryWarning(t *testing.T) {
} }
} }
func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{"www.example.com"}
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.ipAddresses["www.example.com"] = []string{
"1.2.3.4",
}
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: "www.example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{
"www.example.com",
},
}
ctx := t.Context()
// 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(
"expected exactly 1 expiry warning (dedup), got %d",
expiryCount,
)
}
}
func TestGracefulShutdown(t *testing.T) { func TestGracefulShutdown(t *testing.T) {
t.Parallel() t.Parallel()
@@ -519,6 +656,11 @@ func TestGracefulShutdown(t *testing.T) {
deps.resolver.nsRecords["example.com"] = []string{ deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.", "ns1.example.com.",
} }
deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
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())