Compare commits
2 Commits
584b5f5b39
...
cd34e52064
| Author | SHA1 | Date | |
|---|---|---|---|
| cd34e52064 | |||
| f79cd98107 |
@@ -12,10 +12,10 @@ 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
|
||||
|
||||
linters-settings:
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
@@ -25,8 +25,25 @@ linters-settings:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
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$
|
||||
|
||||
20
README.md
20
README.md
@@ -17,6 +17,26 @@ 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)
|
||||
|
||||
7
TODO.md
7
TODO.md
@@ -26,8 +26,11 @@ 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`); fixed the resulting
|
||||
`goconst` findings. `.golangci.yml` unchanged (canonical)
|
||||
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
|
||||
|
||||
@@ -17,13 +17,33 @@ 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 {
|
||||
|
||||
@@ -25,18 +25,20 @@ const (
|
||||
colorDefault = "#6c757d"
|
||||
)
|
||||
|
||||
// Priority and fixture values shared across tests.
|
||||
// Priority strings used across multiple tests.
|
||||
const (
|
||||
prioError = "error"
|
||||
prioWarning = "warning"
|
||||
prioInfo = "info"
|
||||
prioSuccess = "success"
|
||||
prioInfo = "info"
|
||||
prioUnknown = "unknown"
|
||||
ntfyUrgent = "urgent"
|
||||
ntfyDefault = "default"
|
||||
testHost = "example.com"
|
||||
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")
|
||||
|
||||
@@ -113,13 +115,13 @@ func TestNtfyPriority(t *testing.T) {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{prioError, ntfyUrgent},
|
||||
{prioError, prioUrgent},
|
||||
{prioWarning, "high"},
|
||||
{prioSuccess, ntfyDefault},
|
||||
{prioSuccess, prioDefault},
|
||||
{prioInfo, "low"},
|
||||
{"", ntfyDefault},
|
||||
{prioUnknown, ntfyDefault},
|
||||
{"critical", ntfyDefault},
|
||||
{"", prioDefault},
|
||||
{prioUnknown, prioDefault},
|
||||
{"critical", prioDefault},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -301,10 +303,10 @@ func TestSendNtfyHeaders(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
if captured.priority != ntfyUrgent {
|
||||
if captured.priority != prioUrgent {
|
||||
t.Errorf(
|
||||
"Priority header = %q, want %q",
|
||||
captured.priority, ntfyUrgent,
|
||||
captured.priority, prioUrgent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -323,9 +325,9 @@ func TestSendNtfyAllPriorities(t *testing.T) {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{prioError, ntfyUrgent},
|
||||
{prioError, prioUrgent},
|
||||
{prioWarning, "high"},
|
||||
{prioSuccess, ntfyDefault},
|
||||
{prioSuccess, prioDefault},
|
||||
{prioInfo, "low"},
|
||||
}
|
||||
|
||||
@@ -368,56 +370,69 @@ func TestSendNtfyAllPriorities(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNtfyClientError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.WriteHeader(status)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
svc := notify.NewTestService(srv.Client().Transport)
|
||||
topicURL, _ := url.Parse(srv.URL)
|
||||
target, _ := url.Parse(srv.URL)
|
||||
|
||||
err := svc.SendNtfy(
|
||||
context.Background(), topicURL, "t", "m", "info",
|
||||
)
|
||||
err := send(svc, target)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 403 response")
|
||||
t.Fatalf("expected error for %d response", status)
|
||||
}
|
||||
|
||||
if !errors.Is(err, notify.ErrNtfyFailed) {
|
||||
t.Errorf("error = %v, want ErrNtfyFailed", err)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Errorf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}),
|
||||
assertSendStatusError(
|
||||
t, http.StatusInternalServerError,
|
||||
notify.ErrNtfyFailed, sendNtfyInfo,
|
||||
)
|
||||
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) {
|
||||
@@ -618,53 +633,19 @@ func TestSendSlackAllColors(t *testing.T) {
|
||||
func TestSendSlackClientError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}),
|
||||
assertSendStatusError(
|
||||
t, http.StatusBadRequest,
|
||||
notify.ErrSlackFailed, sendSlackInfo,
|
||||
)
|
||||
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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}),
|
||||
assertSendStatusError(
|
||||
t, http.StatusBadGateway,
|
||||
notify.ErrSlackFailed, sendSlackInfo,
|
||||
)
|
||||
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) {
|
||||
@@ -989,74 +970,62 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNotificationNtfyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.WriteHeader(status)
|
||||
}),
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
ntfyURL, _ := url.Parse(srv.URL)
|
||||
target, _ := url.Parse(srv.URL)
|
||||
|
||||
svc := notify.NewTestService(http.DefaultTransport)
|
||||
svc.SetNtfyURL(ntfyURL)
|
||||
setURL(svc, target)
|
||||
|
||||
// Should not panic or block.
|
||||
svc.SendNotification(
|
||||
context.Background(), "t", "m", "error",
|
||||
context.Background(), "t", "m", priority,
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
}),
|
||||
assertSendNotificationTolerates(
|
||||
t, http.StatusForbidden, prioError,
|
||||
(*notify.Service).SetSlackWebhookURL,
|
||||
)
|
||||
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()
|
||||
|
||||
srv := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}),
|
||||
assertSendNotificationTolerates(
|
||||
t, http.StatusBadGateway, prioWarning,
|
||||
(*notify.Service).SetMattermostWebhookURL,
|
||||
)
|
||||
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 ──────────────────────────
|
||||
|
||||
@@ -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 does not need crypto/rand
|
||||
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand
|
||||
|
||||
return time.Duration(jittered)
|
||||
}
|
||||
|
||||
@@ -223,7 +223,8 @@ 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()
|
||||
|
||||
@@ -1072,7 +1073,8 @@ 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
|
||||
|
||||
|
||||
@@ -26,8 +26,11 @@ const tlsPort = 443
|
||||
// hoursPerDay converts days to hours for duration calculations.
|
||||
const hoursPerDay = 24
|
||||
|
||||
// statusError is the status value recorded for failed checks.
|
||||
const statusError = "error"
|
||||
// Status values recorded for nameserver and certificate checks.
|
||||
const (
|
||||
statusOK = "ok"
|
||||
statusError = "error"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Watcher.
|
||||
type Params struct {
|
||||
@@ -347,7 +350,7 @@ func buildHostnameState(
|
||||
for ns, recs := range records {
|
||||
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
|
||||
Records: recs,
|
||||
Status: "ok",
|
||||
Status: statusOK,
|
||||
LastChecked: now,
|
||||
}
|
||||
}
|
||||
@@ -405,7 +408,7 @@ func (w *Watcher) detectNSDisappearances(
|
||||
current map[string]map[string][]string,
|
||||
) {
|
||||
for ns, prevNS := range prev.RecordsByNameserver {
|
||||
if _, ok := current[ns]; ok || prevNS.Status != "ok" {
|
||||
if _, ok := current[ns]; ok || prevNS.Status != statusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -708,7 +711,7 @@ func (w *Watcher) handleTLSError(
|
||||
now time.Time,
|
||||
err error,
|
||||
) {
|
||||
if hasPrev && !w.firstRun && prev.Status == "ok" {
|
||||
if hasPrev && !w.firstRun && prev.Status == statusOK {
|
||||
msg := fmt.Sprintf(
|
||||
"Host: %s\nIP: %s\nError: %s",
|
||||
hostname, ip, err,
|
||||
@@ -751,7 +754,7 @@ func (w *Watcher) handleTLSSuccess(
|
||||
Issuer: cert.Issuer,
|
||||
NotAfter: cert.NotAfter,
|
||||
SubjectAlternativeNames: cert.SubjectAlternativeNames,
|
||||
Status: "ok",
|
||||
Status: statusOK,
|
||||
LastChecked: now,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user