22 Commits

Author SHA1 Message Date
aa5907c248 Merge branch 'main' into fix/iterative-resolution-timeout
All checks were successful
check / check (push) Successful in 1m58s
2026-03-01 16:44:41 +01:00
02ca796085 Merge pull request 'Simplify CI: docker build instead of manual toolchain setup' (#38) from fix/simplify-ci into main
Some checks failed
check / check (push) Failing after 40s
Reviewed-on: #38
2026-02-28 13:04:31 +01:00
clawbot
2e3526986f simplify CI to docker build, pin all image refs by SHA
Some checks failed
check / check (push) Failing after 1m23s
- Replace convoluted CI workflow (setup-go, install golangci-lint, install
  goimports, make check) with simple 'docker build .' per repo policy
- Pin Docker base images by SHA256 hash instead of mutable tags
- Pin golangci-lint and goimports by commit hash instead of @latest
- Add binutils-gold for linker compatibility on alpine
- Run on all pushes, not just main/PR branches
2026-02-28 03:54:11 -08:00
84d63f749f Merge branch 'main' into fix/iterative-resolution-timeout
All checks were successful
Check / check (pull_request) Successful in 10m22s
2026-02-28 12:38:35 +01:00
55c6c21b5a Merge pull request 'fix: distinguish timeout from negative DNS responses (closes #35)' (#37) from fix/timeout-vs-negative-response into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #37
2026-02-28 12:38:18 +01:00
clawbot
2993911883 fix: distinguish timeout from negative DNS responses (closes #35)
Some checks failed
Check / check (pull_request) Failing after 5m41s
2026-02-28 03:35:54 -08:00
70fac87254 Merge pull request 'fix: remove ErrNotImplemented stub — all checks fully implemented (closes #16)' (#23) from fix/remove-unimplemented-stubs into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #23
2026-02-28 12:26:27 +01:00
clawbot
48bedaf579 fix: proper iterative resolution for A/NS lookups (closes #24)
All checks were successful
Check / check (pull_request) Successful in 10m23s
2026-02-28 03:19:53 -08:00
940f7c89da Merge branch 'main' into fix/remove-unimplemented-stubs
Some checks failed
Check / check (pull_request) Failing after 5m45s
2026-02-28 12:09:24 +01: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
7d380aafa4 Merge branch 'main' into fix/remove-unimplemented-stubs
Some checks failed
Check / check (pull_request) Failing after 5m42s
2026-02-28 12:08:35 +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
d0220e5814 fix: remove ErrNotImplemented stub — resolver, port, and TLS checks are fully implemented (closes #16)
Some checks failed
Check / check (pull_request) Failing after 5m27s
The ErrNotImplemented sentinel error was dead code left over from
initial scaffolding. The resolver performs real iterative DNS
lookups from root servers, PortCheck does TCP connection checks,
and TLSCheck verifies TLS certificates and expiry. Removed the
unused error constant.
2026-02-21 00:55:38 -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
9 changed files with 467 additions and 629 deletions

View File

@@ -1,26 +1,9 @@
name: Check
on:
push:
branches: [main]
pull_request:
branches: [main]
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build .

View File

@@ -1,11 +1,13 @@
# Build stage
FROM golang:1.25-alpine AS builder
# golang 1.25-alpine, 2026-02-28
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# Install golangci-lint v2
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
RUN go install golang.org/x/tools/cmd/goimports@latest
# golangci-lint v2.10.1
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
# goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
WORKDIR /src
COPY go.mod go.sum ./
@@ -20,7 +22,8 @@ RUN make check
RUN make build
# Runtime stage
FROM alpine:3.21
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates tzdata

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

@@ -4,11 +4,6 @@ import "errors"
// Sentinel errors returned by the resolver.
var (
// ErrNotImplemented indicates a method is stubbed out.
ErrNotImplemented = errors.New(
"resolver not yet implemented",
)
// ErrNoNameservers is returned when no authoritative NS
// could be discovered for a domain.
ErrNoNameservers = errors.New(

View File

@@ -13,7 +13,7 @@ import (
)
const (
queryTimeoutDuration = 5 * time.Second
queryTimeoutDuration = 2 * time.Second
maxRetries = 2
maxDelegation = 20
timeoutMultiplier = 2
@@ -227,7 +227,7 @@ func (r *Resolver) followDelegation(
authNS := extractNSSet(resp.Ns)
if len(authNS) == 0 {
return r.resolveNSRecursive(ctx, domain)
return r.resolveNSIterative(ctx, domain)
}
glue := extractGlue(resp.Extra)
@@ -291,60 +291,84 @@ func (r *Resolver) resolveNSIPs(
return ips
}
// resolveNSRecursive queries for NS records using recursive
// resolution as a fallback for intercepted environments.
func (r *Resolver) resolveNSRecursive(
// resolveNSIterative queries for NS records using iterative
// resolution as a fallback when followDelegation finds no
// authoritative answer in the delegation chain.
func (r *Resolver) resolveNSIterative(
ctx context.Context,
domain string,
) ([]string, error) {
domain = dns.Fqdn(domain)
msg := new(dns.Msg)
msg.SetQuestion(domain, dns.TypeNS)
msg.RecursionDesired = true
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
for _, ip := range rootServerList()[:3] {
domain = dns.Fqdn(domain)
servers := rootServerList()
for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
addr := net.JoinHostPort(ip, "53")
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
resp, err := r.queryServers(
ctx, servers, domain, dns.TypeNS,
)
if err != nil {
continue
return nil, err
}
nsNames := extractNSSet(resp.Answer)
if len(nsNames) > 0 {
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
}
// 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(
ctx context.Context,
hostname string,
) ([]string, error) {
hostname = dns.Fqdn(hostname)
msg := new(dns.Msg)
msg.SetQuestion(hostname, dns.TypeA)
msg.RecursionDesired = true
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
for _, ip := range rootServerList()[:3] {
hostname = dns.Fqdn(hostname)
servers := rootServerList()
for range maxDelegation {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
addr := net.JoinHostPort(ip, "53")
resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
resp, err := r.queryServers(
ctx, servers, hostname, dns.TypeA,
)
if err != nil {
continue
return nil, fmt.Errorf(
"resolving %s: %w", hostname, err,
)
}
// Check for A records in the answer section.
var ips []string
for _, rr := range resp.Answer {
@@ -356,6 +380,24 @@ func (r *Resolver) resolveARecord(
if len(ips) > 0 {
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(
@@ -418,6 +460,23 @@ func (r *Resolver) QueryNameserver(
return r.queryAllTypes(ctx, nsHostname, nsIPs[0], hostname)
}
// QueryNameserverIP queries a nameserver by its IP address directly,
// bypassing NS hostname resolution.
func (r *Resolver) QueryNameserverIP(
ctx context.Context,
nsHostname string,
nsIP string,
hostname string,
) (*NameserverResponse, error) {
if checkCtx(ctx) != nil {
return nil, ErrContextCanceled
}
hostname = dns.Fqdn(hostname)
return r.queryAllTypes(ctx, nsHostname, nsIP, hostname)
}
func (r *Resolver) queryAllTypes(
ctx context.Context,
nsHostname string,
@@ -445,6 +504,7 @@ func (r *Resolver) queryAllTypes(
type queryState struct {
gotNXDomain bool
gotSERVFAIL bool
gotTimeout bool
hasRecords bool
}
@@ -482,6 +542,10 @@ func (r *Resolver) querySingleType(
) {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil {
if isTimeout(err) {
state.gotTimeout = true
}
return
}
@@ -519,12 +583,26 @@ func collectAnswerRecords(
}
}
// isTimeout checks whether an error is a network timeout.
func isTimeout(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
return false
}
func classifyResponse(resp *NameserverResponse, state queryState) {
switch {
case state.gotNXDomain && !state.hasRecords:
resp.Status = StatusNXDomain
case state.gotTimeout && !state.hasRecords:
resp.Status = StatusTimeout
resp.Error = "all queries timed out"
case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned SERVFAIL"
case !state.hasRecords && !state.gotNXDomain:
resp.Status = StatusNoData
}

View File

@@ -17,6 +17,7 @@ const (
StatusError = "error"
StatusNXDomain = "nxdomain"
StatusNoData = "nodata"
StatusTimeout = "timeout"
)
// MaxCNAMEDepth is the maximum CNAME chain depth to follow.

View File

@@ -2,7 +2,6 @@ package resolver_test
import (
"context"
"fmt"
"log/slog"
"net"
"os"
@@ -18,497 +17,6 @@ import (
"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
// ----------------------------------------------------------------
@@ -521,14 +29,14 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
&slog.HandlerOptions{Level: slog.LevelDebug},
))
return resolver.NewFromLoggerWithClient(log, setupMockDNS())
return resolver.NewFromLogger(log)
}
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
context.Background(), 60*time.Second,
)
t.Cleanup(cancel)
@@ -565,23 +73,23 @@ func TestFindAuthoritativeNameservers_ValidDomain(
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
hasExampleNS := false
hasGoogleNS := false
for _, ns := range nameservers {
if strings.Contains(ns, "example") {
hasExampleNS = true
if strings.Contains(ns, "google") {
hasGoogleNS = true
break
}
}
assert.True(t, hasExampleNS,
"expected example nameservers, got: %v", nameservers,
assert.True(t, hasGoogleNS,
"expected google nameservers, got: %v", nameservers,
)
}
@@ -594,7 +102,7 @@ func TestFindAuthoritativeNameservers_Subdomain(
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "www.example.com",
ctx, "www.google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
@@ -609,7 +117,7 @@ func TestFindAuthoritativeNameservers_ReturnsSorted(
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
@@ -629,12 +137,12 @@ func TestFindAuthoritativeNameservers_Deterministic(
ctx := testContext(t)
first, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
second, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
@@ -650,12 +158,12 @@ func TestFindAuthoritativeNameservers_TrailingDot(
ctx := testContext(t)
ns1, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
ns2, err := r.FindAuthoritativeNameservers(
ctx, "example.com.",
ctx, "google.com.",
)
require.NoError(t, err)
@@ -692,10 +200,10 @@ func TestQueryNameserver_BasicA(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "www.example.com",
ctx, ns, "www.google.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
@@ -706,7 +214,7 @@ func TestQueryNameserver_BasicA(t *testing.T) {
hasRecords := len(resp.Records["A"]) > 0 ||
len(resp.Records["CNAME"]) > 0
assert.True(t, hasRecords,
"expected A or CNAME records for www.example.com",
"expected A or CNAME records for www.google.com",
)
}
@@ -740,16 +248,16 @@ func TestQueryNameserver_MX(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "example.com",
ctx, ns, "google.com",
)
require.NoError(t, err)
mxRecords := resp.Records["MX"]
require.NotEmpty(t, mxRecords,
"example.com should have MX records",
"google.com should have MX records",
)
}
@@ -758,16 +266,16 @@ func TestQueryNameserver_TXT(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "example.com",
ctx, ns, "google.com",
)
require.NoError(t, err)
txtRecords := resp.Records["TXT"]
require.NotEmpty(t, txtRecords,
"example.com should have TXT records",
"google.com should have TXT records",
)
hasSPF := false
@@ -781,7 +289,7 @@ func TestQueryNameserver_TXT(t *testing.T) {
}
assert.True(t, hasSPF,
"example.com should have SPF TXT record",
"google.com should have SPF TXT record",
)
}
@@ -790,11 +298,11 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns,
"this-surely-does-not-exist-xyz.example.com",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
@@ -806,10 +314,10 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns, "example.com",
ctx, ns, "google.com",
)
require.NoError(t, err)
@@ -846,11 +354,11 @@ func TestQueryNameserver_EmptyRecordsOnNXDomain(
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp, err := r.QueryNameserver(
ctx, ns,
"this-surely-does-not-exist-xyz.example.com",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
@@ -867,15 +375,15 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "example.com")
ns := findOneNSForDomain(t, r, ctx, "google.com")
resp1, err := r.QueryNameserver(
ctx, ns, "example.com",
ctx, ns, "google.com",
)
require.NoError(t, err)
resp2, err := r.QueryNameserver(
ctx, ns, "example.com.",
ctx, ns, "google.com.",
)
require.NoError(t, err)
@@ -893,7 +401,7 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, results)
@@ -912,7 +420,7 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
ctx := testContext(t)
results, err := r.QueryAllNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
@@ -934,7 +442,7 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
results, err := r.QueryAllNameservers(
ctx,
"this-surely-does-not-exist-xyz.example.com",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
@@ -956,7 +464,7 @@ func TestLookupNS_ValidDomain(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "example.com")
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, nameservers)
@@ -973,7 +481,7 @@ func TestLookupNS_Sorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.LookupNS(ctx, "example.com")
nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(nameservers))
@@ -985,11 +493,11 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
fromLookup, err := r.LookupNS(ctx, "example.com")
fromLookup, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
fromFind, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
ctx, "google.com",
)
require.NoError(t, err)
@@ -1006,7 +514,7 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com")
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
@@ -1024,7 +532,7 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com")
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
seen := make(map[string]bool)
@@ -1041,7 +549,7 @@ func TestResolveIPAddresses_Sorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, "example.com")
ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
assert.True(t, sort.StringsAreSorted(ips))
@@ -1057,7 +565,7 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty(
ips, err := r.ResolveIPAddresses(
ctx,
"this-surely-does-not-exist-xyz.example.com",
"this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
assert.Empty(t, ips)
@@ -1087,9 +595,7 @@ func TestFindAuthoritativeNameservers_ContextCanceled(
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
)
_, err := r.FindAuthoritativeNameservers(ctx, "google.com")
assert.Error(t, err)
}
@@ -1101,7 +607,7 @@ func TestQueryNameserver_ContextCanceled(t *testing.T) {
cancel()
_, err := r.QueryNameserver(
ctx, "ns1.example.com.", "example.com",
ctx, "ns1.google.com.", "google.com",
)
assert.Error(t, err)
}
@@ -1113,10 +619,63 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.QueryAllNameservers(ctx, "example.com")
_, err := r.QueryAllNameservers(ctx, "google.com")
assert.Error(t, err)
}
// ----------------------------------------------------------------
// Timeout tests
// ----------------------------------------------------------------
func TestQueryNameserverIP_Timeout(t *testing.T) {
t.Parallel()
log := slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
r := resolver.NewFromLoggerWithClient(
log, &timeoutClient{},
)
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
)
t.Cleanup(cancel)
// Query any IP — the client always returns a timeout error.
resp, err := r.QueryNameserverIP(
ctx, "unreachable.test.", "192.0.2.1",
"example.com",
)
require.NoError(t, err)
assert.Equal(t, resolver.StatusTimeout, resp.Status)
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) {
t.Parallel()
@@ -1124,6 +683,6 @@ func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.ResolveIPAddresses(ctx, "example.com")
_, err := r.ResolveIPAddresses(ctx, "google.com")
assert.Error(t, err)
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"sort"
"strings"
"sync"
"time"
"go.uber.org/fx"
@@ -40,15 +41,17 @@ type Params struct {
// Watcher orchestrates all monitoring checks on a schedule.
type Watcher struct {
log *slog.Logger
config *config.Config
state *state.State
resolver DNSResolver
portCheck PortChecker
tlsCheck TLSChecker
notify Notifier
cancel context.CancelFunc
firstRun bool
log *slog.Logger
config *config.Config
state *state.State
resolver DNSResolver
portCheck PortChecker
tlsCheck TLSChecker
notify Notifier
cancel context.CancelFunc
firstRun bool
expiryNotifiedMu sync.Mutex
expiryNotified map[string]time.Time
}
// New creates a new Watcher instance wired into the fx lifecycle.
@@ -57,14 +60,15 @@ func New(
params Params,
) (*Watcher, error) {
w := &Watcher{
log: params.Logger.Get(),
config: params.Config,
state: params.State,
resolver: params.Resolver,
portCheck: params.PortCheck,
tlsCheck: params.TLSCheck,
notify: params.Notify,
firstRun: true,
log: params.Logger.Get(),
config: params.Config,
state: params.State,
resolver: params.Resolver,
portCheck: params.PortCheck,
tlsCheck: params.TLSCheck,
notify: params.Notify,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
lifecycle.Append(fx.Hook{
@@ -100,14 +104,15 @@ func NewForTest(
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
@@ -206,6 +211,28 @@ func (w *Watcher) checkDomain(
Nameservers: nameservers,
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(
@@ -691,6 +718,22 @@ func (w *Watcher) checkTLSExpiry(
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(
"Host: %s\nIP: %s\nCN: %s\n"+
"Expires: %s (%.0f days)",

View File

@@ -273,6 +273,10 @@ func setupBaselineMocks(deps *testDeps) {
"ns1.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{
"ns1.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",
},
}
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(
@@ -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(
"expected 1 hostname in state, got %d",
"expected at least 1 hostname in state, got %d",
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) {
t.Parallel()
@@ -342,6 +414,12 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.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()
w.RunOnce(ctx)
@@ -351,6 +429,10 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.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()
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) {
t.Parallel()
@@ -519,6 +656,11 @@ func TestGracefulShutdown(t *testing.T) {
deps.resolver.nsRecords["example.com"] = []string{
"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())