2 Commits

Author SHA1 Message Date
da3c831a26 Merge remote-tracking branch 'origin/main' into feature/resolver
All checks were successful
check / check (push) Successful in 46s
# Conflicts:
#	internal/resolver/errors.go
#	internal/resolver/resolver.go
#	internal/resolver/resolver_test.go
2026-07-23 08:37:54 +07:00
483bed68a1 Add resolver API definition and comprehensive live-DNS test suite
The test suite defines the full resolver contract using live DNS
queries against controlled records in the sneak.cloud zone
(Cloudflare). Covers FindAuthoritativeNameservers, QueryNameserver,
QueryAllNameservers, LookupNS, and ResolveIPAddresses, including
sorting/determinism guarantees, trailing-dot handling, per-NS
response status model, lame-delegation detection, NXDOMAIN
semantics, CNAME following, and context cancellation.

Also adds DNSSEC validation to planned future features in README.
2026-07-07 03:06:49 +02:00
13 changed files with 691 additions and 419 deletions

View File

@@ -12,38 +12,21 @@ linters:
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.12.2, 2026-08-07
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
# 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

View File

@@ -17,26 +17,6 @@ without requiring an external database.
---
## No DNS mocking. Ever.
**DNS is never mocked in this project — not in tests, not anywhere else.**
No mock resolvers, no fake DNS servers, no stubbed lookups.
dnswatcher's entire purpose is correct behavior against the real DNS.
Tests exercise real iterative resolution against live nameservers by
design; a test suite that passes against a mock proves nothing about the
one thing this program exists to do.
When live tests are flaky, that is a robustness problem, and it gets
fixed with robustness: retries with backoff, querying multiple
independent nameservers, longer timeouts — or explicit opt-in gating
decided by the project owner. Never with mocks.
Contributions that introduce mocked, faked, or stubbed DNS will be
rejected.
---
## Features
### DNS Domain Monitoring (Apex Domains)

View File

@@ -25,12 +25,6 @@ confirm make check still passes.
# Completed Steps
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` migrated to
the v2 schema (owner-authorized; becomes the new org canonical), so
the lll/funlen/cyclop/dupl thresholds now apply; deprecated
`gomodguard` disabled in favor of `gomodguard_v2`; fixed the
resulting `goconst`, `dupl`, and `lll` findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic

View File

@@ -17,33 +17,13 @@ func TestClassifyDNSName(t *testing.T) {
}{
{name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{
name: "apex domain multi-part TLD",
input: "example.co.uk",
want: config.DNSNameTypeDomain,
},
{
name: "hostname multi-part TLD",
input: "api.example.co.uk",
want: config.DNSNameTypeHostname,
},
{name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain},
{name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname},
{name: "public suffix itself", input: "co.uk", wantErr: true},
{name: "empty string", input: "", wantErr: true},
{
name: "deeply nested hostname",
input: "a.b.c.example.com",
want: config.DNSNameTypeHostname,
},
{
name: "trailing dot stripped",
input: "example.com.",
want: config.DNSNameTypeDomain,
},
{
name: "uppercase normalized",
input: "WWW.Example.COM",
want: config.DNSNameTypeHostname,
},
{name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname},
{name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain},
{name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname},
}
for _, tt := range tests {

View File

@@ -25,20 +25,6 @@ const (
colorDefault = "#6c757d"
)
// Priority strings used across multiple tests.
const (
prioError = "error"
prioWarning = "warning"
prioSuccess = "success"
prioInfo = "info"
prioUnknown = "unknown"
prioDefault = "default"
prioUrgent = "urgent"
)
// testHost is the hostname used in request construction tests.
const testHost = "example.com"
// errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure")
@@ -115,13 +101,13 @@ func TestNtfyPriority(t *testing.T) {
input string
want string
}{
{prioError, prioUrgent},
{prioWarning, "high"},
{prioSuccess, prioDefault},
{prioInfo, "low"},
{"", prioDefault},
{prioUnknown, prioDefault},
{"critical", prioDefault},
{"error", "urgent"},
{"warning", "high"},
{"success", "default"},
{"info", "low"},
{"", "default"},
{"unknown", "default"},
{"critical", "default"},
}
for _, tc := range cases {
@@ -148,12 +134,12 @@ func TestSlackColor(t *testing.T) {
input string
want string
}{
{prioError, colorError},
{prioWarning, colorWarning},
{prioSuccess, colorSuccess},
{prioInfo, colorInfo},
{"error", colorError},
{"warning", colorWarning},
{"success", colorSuccess},
{"info", colorInfo},
{"", colorDefault},
{prioUnknown, colorDefault},
{"unknown", colorDefault},
{"critical", colorDefault},
}
@@ -179,7 +165,7 @@ func TestNewRequest(t *testing.T) {
target := &url.URL{
Scheme: "https",
Host: testHost,
Host: "example.com",
Path: "/webhook",
}
body := bytes.NewBufferString("hello")
@@ -201,9 +187,9 @@ func TestNewRequest(t *testing.T) {
)
}
if req.Host != testHost {
if req.Host != "example.com" {
t.Errorf(
"Host = %q, want %q", req.Host, testHost,
"Host = %q, want %q", req.Host, "example.com",
)
}
@@ -231,7 +217,7 @@ func TestNewRequestPreservesContext(t *testing.T) {
ctxKey("k"),
"v",
)
target := &url.URL{Scheme: "https", Host: testHost}
target := &url.URL{Scheme: "https", Host: "example.com"}
req := notify.NewRequestForTest(
ctx, http.MethodGet, target, http.NoBody,
@@ -303,10 +289,10 @@ func TestSendNtfyHeaders(t *testing.T) {
)
}
if captured.priority != prioUrgent {
if captured.priority != "urgent" {
t.Errorf(
"Priority header = %q, want %q",
captured.priority, prioUrgent,
captured.priority, "urgent",
)
}
@@ -325,10 +311,10 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string
want string
}{
{prioError, prioUrgent},
{prioWarning, "high"},
{prioSuccess, prioDefault},
{prioInfo, "low"},
{"error", "urgent"},
{"warning", "high"},
{"success", "default"},
{"info", "low"},
}
for _, tc := range priorities {
@@ -370,69 +356,56 @@ func TestSendNtfyAllPriorities(t *testing.T) {
}
}
// assertSendStatusError verifies that send returns an error
// wrapping wantErr when the server responds with status.
func assertSendStatusError(
t *testing.T,
status int,
wantErr error,
send func(*notify.Service, *url.URL) error,
) {
t.Helper()
func TestSendNtfyClientError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
w.WriteHeader(http.StatusForbidden)
}),
)
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
target, _ := url.Parse(srv.URL)
topicURL, _ := url.Parse(srv.URL)
err := send(svc, target)
err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil {
t.Fatalf("expected error for %d response", status)
t.Fatal("expected error for 403 response")
}
if !errors.Is(err, wantErr) {
t.Errorf("error = %v, want %v", err, wantErr)
if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want ErrNtfyFailed", err)
}
}
func sendNtfyInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendNtfy(
context.Background(), target, "t", "m", prioInfo,
)
}
func sendSlackInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendSlack(
context.Background(), target, "t", "m", prioInfo,
)
}
func TestSendNtfyClientError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusForbidden,
notify.ErrNtfyFailed, sendNtfyInfo,
)
}
func TestSendNtfyServerError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusInternalServerError,
notify.ErrNtfyFailed, sendNtfyInfo,
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}),
)
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL)
err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 500 response")
}
if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want ErrNtfyFailed", err)
}
}
func TestSendNtfySuccess(t *testing.T) {
@@ -577,11 +550,11 @@ func TestSendSlackAllColors(t *testing.T) {
priority string
want string
}{
{prioError, colorError},
{prioWarning, colorWarning},
{prioSuccess, colorSuccess},
{prioInfo, colorInfo},
{prioUnknown, colorDefault},
{"error", colorError},
{"warning", colorWarning},
{"success", colorSuccess},
{"info", colorInfo},
{"unknown", colorDefault},
}
for _, tc := range colors {
@@ -633,19 +606,53 @@ func TestSendSlackAllColors(t *testing.T) {
func TestSendSlackClientError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusBadRequest,
notify.ErrSlackFailed, sendSlackInfo,
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}),
)
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 400 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
}
func TestSendSlackServerError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusBadGateway,
notify.ErrSlackFailed, sendSlackInfo,
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
)
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "error",
)
if err == nil {
t.Fatal("expected error for 502 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
}
func TestSendSlackNetworkError(t *testing.T) {
@@ -970,62 +977,74 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
}
}
// assertSendNotificationTolerates verifies SendNotification
// neither panics nor blocks when the endpoint configured by
// setURL responds with status.
func assertSendNotificationTolerates(
t *testing.T,
status int,
priority string,
setURL func(*notify.Service, *url.URL),
) {
t.Helper()
func TestSendNotificationNtfyError(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
w.WriteHeader(http.StatusInternalServerError)
}),
)
defer srv.Close()
target, _ := url.Parse(srv.URL)
ntfyURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
setURL(svc, target)
svc.SetNtfyURL(ntfyURL)
// Should not panic or block.
svc.SendNotification(
context.Background(), "t", "m", priority,
context.Background(), "t", "m", "error",
)
time.Sleep(100 * time.Millisecond)
}
func TestSendNotificationNtfyError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusInternalServerError, prioError,
(*notify.Service).SetNtfyURL,
)
}
func TestSendNotificationSlackError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusForbidden, prioError,
(*notify.Service).SetSlackWebhookURL,
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}),
)
defer srv.Close()
slackURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetSlackWebhookURL(slackURL)
svc.SendNotification(
context.Background(), "t", "m", "error",
)
time.Sleep(100 * time.Millisecond)
}
func TestSendNotificationMattermostError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusBadGateway, prioWarning,
(*notify.Service).SetMattermostWebhookURL,
srv := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
)
defer srv.Close()
mmURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetMattermostWebhookURL(mmURL)
svc.SendNotification(
context.Background(), "t", "m", "warning",
)
time.Sleep(100 * time.Millisecond)
}
// ── SlackPayload JSON marshaling ──────────────────────────

View File

@@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) {
Timestamp: now.Add(-2 * time.Minute),
Title: "first",
Message: "msg1",
Priority: prioInfo,
Priority: "info",
})
h.Add(notify.AlertEntry{
Timestamp: now.Add(-1 * time.Minute),
Title: "second",
Message: "msg2",
Priority: prioWarning,
Priority: "warning",
})
entries := h.Recent()

View File

@@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration {
lo := raw * (1 - jitterFraction)
hi := raw * (1 + jitterFraction)
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
return time.Duration(jittered)
}

View File

@@ -1,3 +1,10 @@
// Package resolver_test verifies the iterative resolver against
// live DNS. Per TESTING.md, all resolver tests use real queries
// against real nameservers — no mocking of the DNS client layer.
// Tests target long-lived public infrastructure (root servers,
// gTLD servers, IANA, Google, Cloudflare, GitHub) to minimize
// fixture churn, plus dedicated fixture records in the
// sneak.cloud zone.
package resolver_test
import (
@@ -10,13 +17,36 @@ import (
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
// ----------------------------------------------------------------
// Test fixtures
// ----------------------------------------------------------------
// Fixture hostnames in the sneak.cloud zone (hosted on
// Cloudflare). These records must exist in the sneak.cloud zone:
//
// basic.dns.sneak.cloud A 192.0.2.1
// multi.dns.sneak.cloud A 192.0.2.1
// multi.dns.sneak.cloud A 192.0.2.2
// ipv6.dns.sneak.cloud AAAA 2001:db8::1
// dual.dns.sneak.cloud A 192.0.2.1
// dual.dns.sneak.cloud AAAA 2001:db8::1
// cname-target.dns.sneak.cloud A 198.51.100.1
// cname.dns.sneak.cloud CNAME cname-target.dns.sneak.cloud
const (
testDomain = "sneak.cloud"
testHostBasic = "basic.dns.sneak.cloud"
testHostMultiA = "multi.dns.sneak.cloud"
testHostIPv6 = "ipv6.dns.sneak.cloud"
testHostDualStack = "dual.dns.sneak.cloud"
testHostCNAME = "cname.dns.sneak.cloud"
)
// ----------------------------------------------------------------
// Test helpers
// ----------------------------------------------------------------
@@ -108,6 +138,35 @@ func TestFindAuthoritativeNameservers_Subdomain(
require.NotEmpty(t, nameservers)
}
func TestFindAuthoritativeNameservers_TLD(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers,
"should find TLD nameservers",
)
hasGTLD := false
for _, ns := range nameservers {
if strings.Contains(ns, "gtld-servers.net") {
hasGTLD = true
break
}
}
assert.True(t, hasGTLD,
"expected gtld-servers for com, got: %v", nameservers,
)
}
func TestFindAuthoritativeNameservers_ReturnsSorted(
t *testing.T,
) {
@@ -191,6 +250,68 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
}
}
func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively(
t *testing.T,
) {
// Every NS returned by FindAuthoritativeNameservers must
// actually serve the zone. A lame NS would return REFUSED,
// SERVFAIL, or a non-authoritative answer.
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
for _, ns := range nameservers {
resp, queryErr := r.QueryNameserver(
ctx, ns, "www.google.com",
)
require.NoError(t, queryErr,
"NS %s should be queryable", ns,
)
assert.Equal(
t, resolver.StatusOK, resp.Status,
"NS %s should respond authoritatively, "+
"status=%s error=%s",
ns, resp.Status, resp.Error,
)
hasRecords := len(resp.Records["A"]) > 0 ||
len(resp.Records["CNAME"]) > 0
assert.True(t, hasRecords,
"NS %s should return records for www.google.com",
ns,
)
}
}
func TestFindAuthoritativeNameservers_IsIterative(
t *testing.T,
) {
// Verify that resolution works for well-known domains,
// proving we trace from root rather than relying on a
// system stub resolver that might not be configured.
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
// Resolve a well-known domain to prove root->TLD->domain
// tracing works.
nameservers, err := r.FindAuthoritativeNameservers(
ctx, "example.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
t.Logf("example.com NS: %v", nameservers)
}
// ----------------------------------------------------------------
// QueryNameserver tests
// ----------------------------------------------------------------
@@ -218,6 +339,28 @@ func TestQueryNameserver_BasicA(t *testing.T) {
)
}
func TestQueryNameserver_MultipleA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostMultiA)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
aRecords := resp.Records["A"]
require.Len(
t, aRecords, 2,
"multi.dns should have exactly 2 A records",
)
sort.Strings(aRecords)
assert.Equal(t, []string{"192.0.2.1", "192.0.2.2"}, aRecords)
}
func TestQueryNameserver_AAAA(t *testing.T) {
t.Parallel()
@@ -243,6 +386,44 @@ func TestQueryNameserver_AAAA(t *testing.T) {
}
}
func TestQueryNameserver_DualStack(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostDualStack)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Contains(t, resp.Records["A"], "192.0.2.1")
assert.Contains(t, resp.Records["AAAA"], "2001:db8::1")
}
func TestQueryNameserver_CNAME(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostCNAME)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
cnameRecords := resp.Records["CNAME"]
require.NotEmpty(
t, cnameRecords,
"cname.dns should have CNAME records",
)
assert.Contains(
t, cnameRecords, "cname-target.dns.sneak.cloud.",
)
}
func TestQueryNameserver_MX(t *testing.T) {
t.Parallel()
@@ -390,6 +571,116 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
assert.Equal(t, resp1.Status, resp2.Status)
}
func TestQueryNameserver_NSRecordsForZoneApex(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
// Querying a zone apex should return NS records.
resp, err := r.QueryNameserver(
ctx, ns, "cloudflare.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
nsRecords := resp.Records["NS"]
require.NotEmpty(t, nsRecords,
"zone apex should have NS records",
)
hasCloudflare := false
for _, nsRecord := range nsRecords {
if strings.Contains(nsRecord, "cloudflare") {
hasCloudflare = true
break
}
}
assert.True(t, hasCloudflare,
"NS records should include cloudflare, got: %v",
nsRecords,
)
}
func TestQueryNameserver_NoNSRecordsForLeafHostname(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ns := findOneNSForDomain(t, r, ctx, "google.com")
// A leaf hostname (not a zone cut) has no NS records.
resp, err := r.QueryNameserver(
ctx, ns, "www.google.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Empty(t, resp.Records["NS"],
"leaf hostname should not have NS records, got: %v",
resp.Records["NS"],
)
}
func TestQueryNameserver_LameNameserver(t *testing.T) {
// Querying a nameserver that does not serve a zone should
// return a non-ok status, not silently return empty results
// with status "ok". a.iana-servers.net serves example.com,
// not google.com.
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
resp, err := r.QueryNameserver(
ctx, "a.iana-servers.net.", "www.google.com",
)
require.NoError(t, err, "should not return Go-level error")
require.NotNil(t, resp)
assert.NotEqual(
t, resolver.StatusOK, resp.Status,
"lame NS should not return status ok; got status=%s "+
"records=%v",
resp.Status, resp.Records,
)
}
// ----------------------------------------------------------------
// QueryNameserverIP tests
// ----------------------------------------------------------------
func TestQueryNameserverIP_BasicA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
// a.iana-servers.net (199.43.135.53) is authoritative for
// example.com; query it directly by IP.
resp, err := r.QueryNameserverIP(
ctx, "a.iana-servers.net.", "199.43.135.53",
"example.com",
)
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Equal(t, "a.iana-servers.net.", resp.Nameserver)
assert.NotEmpty(t, resp.Records["A"],
"example.com should have A records",
)
}
// ----------------------------------------------------------------
// QueryAllNameservers tests
// ----------------------------------------------------------------
@@ -432,6 +723,39 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
}
}
func TestQueryAllNameservers_Consistent(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
results, err := r.QueryAllNameservers(ctx, testHostBasic)
require.NoError(t, err)
require.NotEmpty(t, results)
// All NSes should return the same A records for a
// well-configured hostname.
var referenceRecords map[string][]string
for ns, resp := range results {
require.Equal(
t, resolver.StatusOK, resp.Status,
"NS %s should return OK status", ns,
)
if referenceRecords == nil {
referenceRecords = resp.Records
continue
}
assert.Equal(
t, referenceRecords["A"], resp.Records["A"],
"NS %s A records should match", ns,
)
}
}
func TestQueryAllNameservers_NXDomainFromAllNS(
t *testing.T,
) {
@@ -526,6 +850,80 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
}
}
func TestResolveIPAddresses_BasicA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, testHostBasic)
require.NoError(t, err)
require.NotEmpty(t, ips)
assert.Contains(t, ips, "192.0.2.1")
}
func TestResolveIPAddresses_MultipleA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, testHostMultiA)
require.NoError(t, err)
sort.Strings(ips)
assert.Contains(t, ips, "192.0.2.1")
assert.Contains(t, ips, "192.0.2.2")
}
func TestResolveIPAddresses_IPv6Only(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, testHostIPv6)
require.NoError(t, err)
require.NotEmpty(t, ips)
assert.Contains(t, ips, "2001:db8::1")
// Should not contain any IPv4
for _, ip := range ips {
parsed := net.ParseIP(ip)
require.NotNil(t, parsed, "should be valid IP: %s", ip)
assert.Nil(
t, parsed.To4(),
"ipv6-only host should not return IPv4: %s", ip,
)
}
}
func TestResolveIPAddresses_DualStack(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
ips, err := r.ResolveIPAddresses(ctx, testHostDualStack)
require.NoError(t, err)
assert.Contains(t, ips, "192.0.2.1")
assert.Contains(t, ips, "2001:db8::1")
}
func TestResolveIPAddresses_FollowsCNAME(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
// www.github.com is a long-standing CNAME to github.com;
// resolution must follow the chain to A records.
ips, err := r.ResolveIPAddresses(ctx, "www.github.com")
require.NoError(t, err)
require.NotEmpty(t, ips)
}
func TestResolveIPAddresses_Deduplicated(t *testing.T) {
t.Parallel()
@@ -623,59 +1021,6 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
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()

View File

@@ -13,16 +13,6 @@ import (
const testHostname = "www.example.com"
// Shared fixture values used across tests.
const (
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testAltNS1 = "ns1.test.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
statusError = "error"
)
// populateState fills a State with representative test data across all categories.
func populateState(t *testing.T, s *state.State) {
t.Helper()
@@ -30,7 +20,7 @@ func populateState(t *testing.T, s *state.State) {
now := time.Now().UTC().Truncate(time.Second)
s.SetDomainState("example.com", &state.DomainState{
Nameservers: []string{testNS1, testNS2},
Nameservers: []string{"ns1.example.com.", "ns2.example.com."},
LastChecked: now,
})
@@ -41,17 +31,17 @@ func populateState(t *testing.T, s *state.State) {
s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
"ns1.example.com.": {
Records: map[string][]string{
"A": {testIPv4},
"A": {"93.184.216.34"},
"AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
},
Status: "ok",
LastChecked: now,
},
testNS2: {
"ns2.example.com.": {
Records: map[string][]string{
"A": {testIPv4},
"A": {"93.184.216.34"},
},
Status: "ok",
LastChecked: now,
@@ -162,13 +152,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
t.Helper()
ns1, ok := hn.RecordsByNameserver[testNS1]
ns1, ok := hn.RecordsByNameserver["ns1.example.com."]
if !ok {
t.Fatal("missing nameserver ns1.example.com.")
}
aRecords := ns1.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIPv4 {
if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" {
t.Errorf("ns1 A records: got %v", aRecords)
}
@@ -223,8 +213,7 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) {
}
}
// TestSaveLoadRoundTrip_Certificates verifies certificate data
// survives a save/load cycle.
// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle.
func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
t.Parallel()
@@ -664,7 +653,7 @@ func TestDomainState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
ds := &state.DomainState{
Nameservers: []string{testAltNS1},
Nameservers: []string{"ns1.test.com."},
LastChecked: now,
}
@@ -675,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) {
t.Fatal("expected true for existing domain")
}
if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 {
if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." {
t.Errorf("nameservers: got %v", got.Nameservers)
}
@@ -685,7 +674,7 @@ func TestDomainState_GetSet(t *testing.T) {
// Overwrite.
ds2 := &state.DomainState{
Nameservers: []string{testAltNS1, "ns2.test.com."},
Nameservers: []string{"ns1.test.com.", "ns2.test.com."},
LastChecked: now.Add(time.Hour),
}
@@ -715,8 +704,8 @@ func TestHostnameState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
Records: map[string][]string{"A": {testIP}},
"ns1.example.com.": {
Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok",
LastChecked: now,
},
@@ -731,7 +720,7 @@ func TestHostnameState_GetSet(t *testing.T) {
t.Fatal("expected true for existing hostname")
}
nsState, ok := got.RecordsByNameserver[testNS1]
nsState, ok := got.RecordsByNameserver["ns1.example.com."]
if !ok {
t.Fatal("missing nameserver entry")
}
@@ -741,7 +730,7 @@ func TestHostnameState_GetSet(t *testing.T) {
}
aRecords := nsState.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIP {
if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" {
t.Errorf("A records: got %v", aRecords)
}
}
@@ -880,7 +869,7 @@ func TestCertificateState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
cs := &state.CertificateState{
Status: statusError,
Status: "error",
Error: "connection refused",
LastChecked: now,
}
@@ -904,8 +893,8 @@ func TestCertificateState_ErrorField(t *testing.T) {
t.Fatal("missing certificate after load")
}
if got.Status != statusError {
t.Errorf("status: got %q, want %q", got.Status, statusError)
if got.Status != "error" {
t.Errorf("status: got %q, want %q", got.Status, "error")
}
if got.Error != "connection refused" {
@@ -923,9 +912,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: {
"ns1.example.com.": {
Records: nil,
Status: statusError,
Status: "error",
Error: "SERVFAIL",
LastChecked: now,
},
@@ -952,9 +941,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
t.Fatal("missing hostname after load")
}
nsState := got.RecordsByNameserver[testNS1]
if nsState.Status != statusError {
t.Errorf("status: got %q, want %q", nsState.Status, statusError)
nsState := got.RecordsByNameserver["ns1.example.com."]
if nsState.Status != "error" {
t.Errorf("status: got %q, want %q", nsState.Status, "error")
}
if nsState.Error != "SERVFAIL" {
@@ -1073,8 +1062,7 @@ func TestConcurrentGetSet(t *testing.T) {
wg.Wait()
}
// runConcurrentOps performs a series of get/set/delete
// operations for concurrency testing.
// runConcurrentOps performs a series of get/set/delete operations for concurrency testing.
func runConcurrentOps(s *state.State, key string, now time.Time) {
const iterations = 50
@@ -1097,7 +1085,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) {
s.SetHostnameState(key+".example.com", &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.test.": {
Records: map[string][]string{"A": {testIP}},
Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok",
LastChecked: now,
},

View File

@@ -26,12 +26,6 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24
// Status values recorded for nameserver and certificate checks.
const (
statusOK = "ok"
statusError = "error"
)
// Params contains dependencies for Watcher.
type Params struct {
fx.In
@@ -350,7 +344,7 @@ func buildHostnameState(
for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs,
Status: statusOK,
Status: "ok",
LastChecked: now,
}
}
@@ -408,7 +402,7 @@ func (w *Watcher) detectNSDisappearances(
current map[string]map[string][]string,
) {
for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != statusOK {
if _, ok := current[ns]; ok || prevNS.Status != "ok" {
continue
}
@@ -427,7 +421,7 @@ func (w *Watcher) detectNSDisappearances(
for ns := range current {
prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusError {
if !ok || prevNS.Status != "error" {
continue
}
@@ -711,7 +705,7 @@ func (w *Watcher) handleTLSError(
now time.Time,
err error,
) {
if hasPrev && !w.firstRun && prev.Status == statusOK {
if hasPrev && !w.firstRun && prev.Status == "ok" {
msg := fmt.Sprintf(
"Host: %s\nIP: %s\nError: %s",
hostname, ip, err,
@@ -727,7 +721,7 @@ func (w *Watcher) handleTLSError(
w.state.SetCertificateState(
certKey, &state.CertificateState{
Status: statusError,
Status: "error",
Error: err.Error(),
LastChecked: now,
},
@@ -754,7 +748,7 @@ func (w *Watcher) handleTLSSuccess(
Issuer: cert.Issuer,
NotAfter: cert.NotAfter,
SubjectAlternativeNames: cert.SubjectAlternativeNames,
Status: statusOK,
Status: "ok",
LastChecked: now,
},
)
@@ -766,7 +760,7 @@ func (w *Watcher) detectTLSChanges(
prev *state.CertificateState,
cert *tlscheck.CertificateInfo,
) {
if prev.Status == statusError {
if prev.Status == "error" {
msg := fmt.Sprintf(
"Host: %s\nIP: %s\nTLS recovered",
hostname, ip,

View File

@@ -18,17 +18,6 @@ import (
// errNotFound is returned when mock data is missing.
var errNotFound = errors.New("not found")
// Fixture values shared across tests.
const (
testDomain = "example.com"
testHost = "www.example.com"
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
testIssuer = "DigiCert"
)
// --- Mock implementations ---
type mockResolver struct {
@@ -267,8 +256,8 @@ func TestFirstRunBaseline(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps)
@@ -280,37 +269,37 @@ func TestFirstRunBaseline(t *testing.T) {
}
func setupBaselineMocks(deps *testDeps) {
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
testNS2,
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
"ns2.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
testNS2: {"A": {testIPv4}},
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[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
testNS2: {"A": {testIPv4}},
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"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIPv4,
deps.resolver.ipAddresses["www.example.com"] = []string{
"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:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost,
Issuer: testIssuer,
CommonName: "www.example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
"www.example.com",
},
}
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: testDomain,
Issuer: testIssuer,
CommonName: "example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testDomain,
"example.com",
},
}
}
@@ -359,24 +348,24 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Domains = []string{"example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
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: testDomain,
Issuer: testIssuer,
CommonName: "example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testDomain,
"example.com",
},
}
@@ -417,17 +406,17 @@ func TestNSChangeDetection(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Domains = []string{"example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
testNS2,
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
"ns2.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
testNS2: {"A": {testIP}},
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
@@ -436,13 +425,13 @@ func TestNSChangeDetection(t *testing.T) {
w.RunOnce(ctx)
deps.resolver.mu.Lock()
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
"ns3.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
"ns3.example.com.": {"A": {testIP}},
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()
@@ -470,15 +459,15 @@ func TestRecordChangeDetection(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIPv4,
deps.resolver.ipAddresses["www.example.com"] = []string{
"93.184.216.34",
}
deps.portChecker.results["93.184.216.34:80"] = false
deps.portChecker.results["93.184.216.34:443"] = false
@@ -487,10 +476,10 @@ func TestRecordChangeDetection(t *testing.T) {
w.RunOnce(ctx)
deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {"93.184.216.35"}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.35"}},
}
deps.resolver.ipAddresses[testHost] = []string{
deps.resolver.ipAddresses["www.example.com"] = []string{
"93.184.216.35",
}
deps.resolver.mu.Unlock()
@@ -512,24 +501,24 @@ func TestPortStateChange(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIP,
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: testHost,
Issuer: testIssuer,
CommonName: "www.example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
"www.example.com",
},
}
@@ -552,24 +541,24 @@ func TestTLSExpiryWarning(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIP,
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: testHost,
Issuer: testIssuer,
CommonName: "www.example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
"www.example.com",
},
}
@@ -603,25 +592,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIP,
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: testHost,
Issuer: testIssuer,
CommonName: "www.example.com",
Issuer: "DigiCert",
NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{
testHost,
"www.example.com",
},
}
@@ -658,17 +647,17 @@ func TestGracefulShutdown(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Domains = []string{"example.com"}
cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond
w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{
testNS1,
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
}
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
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
@@ -698,13 +687,13 @@ func setupHostnameIP(
hostname, ip string,
) {
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}},
"ns1.example.com.": {"A": {ip}},
}
deps.portChecker.results[ip+":80"] = true
deps.portChecker.results[ip+":443"] = true
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: testIssuer,
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname},
}
@@ -713,7 +702,7 @@ func setupHostnameIP(
func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.resolver.mu.Lock()
deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}},
"ns1.example.com.": {"A": {ip}},
}
deps.resolver.mu.Unlock()
@@ -725,7 +714,7 @@ func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.tlsChecker.mu.Lock()
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname,
Issuer: testIssuer,
Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname},
}
@@ -736,11 +725,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
setupHostnameIP(deps, testHost, "10.0.0.1")
setupHostnameIP(deps, "www.example.com", "10.0.0.1")
ctx := t.Context()
w.RunOnce(ctx)
@@ -751,7 +740,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
}
// DNS changes to a new IP; port and TLS must pick it up.
updateHostnameIP(deps, testHost, "10.0.0.2")
updateHostnameIP(deps, "www.example.com", "10.0.0.2")
w.RunOnce(ctx)
@@ -771,8 +760,8 @@ func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg)
@@ -797,8 +786,8 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
@@ -844,8 +833,8 @@ func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain}
cfg.Hostnames = []string{testHost}
cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour
@@ -882,16 +871,16 @@ func TestNSFailureAndRecovery(t *testing.T) {
t.Parallel()
cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost}
cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
testNS2: {"A": {testIP}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
"ns2.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.ipAddresses[testHost] = []string{
testIP,
deps.resolver.ipAddresses["www.example.com"] = []string{
"1.2.3.4",
}
deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false
@@ -901,8 +890,8 @@ func TestNSFailureAndRecovery(t *testing.T) {
w.RunOnce(ctx)
deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{
testNS1: {"A": {testIP}},
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"1.2.3.4"}},
}
deps.resolver.mu.Unlock()

View File

@@ -9,9 +9,9 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# Pinned versions, 2026-07-07 (same pins as the Dockerfile)
# golangci-lint v2.10.1
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee"
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"