+ {{/* ---- Header ---- */}}
+
+
+ dnswatcher
+
+
+ state updated {{ .StateAge }} · page generated
+ {{ .GeneratedAt }} UTC · auto-refresh 30s
+
+
+
+ {{/* ---- Summary bar ---- */}}
+
+
+
+ Domains
+
+
+ {{ len .Snapshot.Domains }}
+
+
+
+
+ Hostnames
+
+
+ {{ len .Snapshot.Hostnames }}
+
+
+
+
+ Ports
+
+
+ {{ len .Snapshot.Ports }}
+
+
+
+
+ Certificates
+
+
+ {{ len .Snapshot.Certificates }}
+
+
+
+
+ {{/* ---- Domains ---- */}}
+
+
+ Domains
+
+ {{ if .Snapshot.Domains }}
+
+
+
+
+ | Domain |
+ Nameservers |
+ Checked |
+
+
+
+ {{ range $name, $ds := .Snapshot.Domains }}
+
+ |
+ {{ $name }}
+ |
+
+ {{ joinStrings $ds.Nameservers ", " }}
+ |
+
+ {{ relTime $ds.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No domains configured.
+
+ {{ end }}
+
+
+ {{/* ---- Hostnames ---- */}}
+
+
+ Hostnames
+
+ {{ if .Snapshot.Hostnames }}
+
+
+
+
+ | Hostname |
+ NS |
+ Status |
+ Records |
+ Checked |
+
+
+
+ {{ range $name, $hs := .Snapshot.Hostnames }}
+ {{ range $ns, $nsr := $hs.RecordsByNameserver }}
+
+ |
+ {{ $name }}
+ |
+
+ {{ $ns }}
+ |
+
+ {{ if eq $nsr.Status "ok" }}
+ ok
+ {{ else }}
+ {{ $nsr.Status }}
+ {{ end }}
+ |
+
+ {{ formatRecords $nsr.Records }}
+ |
+
+ {{ relTime $nsr.LastChecked }}
+ |
+
+ {{ end }}
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No hostnames configured.
+
+ {{ end }}
+
+
+ {{/* ---- Ports ---- */}}
+
+
+ Ports
+
+ {{ if .Snapshot.Ports }}
+
+
+
+
+ | Address |
+ State |
+ Hostnames |
+ Checked |
+
+
+
+ {{ range $key, $ps := .Snapshot.Ports }}
+
+ |
+ {{ $key }}
+ |
+
+ {{ if $ps.Open }}
+ open
+ {{ else }}
+ closed
+ {{ end }}
+ |
+
+ {{ joinStrings $ps.Hostnames ", " }}
+ |
+
+ {{ relTime $ps.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No port data yet.
+
+ {{ end }}
+
+
+ {{/* ---- Certificates ---- */}}
+
+
+ Certificates
+
+ {{ if .Snapshot.Certificates }}
+
+
+
+
+ | Endpoint |
+ Status |
+ CN |
+ Issuer |
+ Expires |
+ Checked |
+
+
+
+ {{ range $key, $cs := .Snapshot.Certificates }}
+
+ |
+ {{ $key }}
+ |
+
+ {{ if eq $cs.Status "ok" }}
+ ok
+ {{ else }}
+ {{ $cs.Status }}
+ {{ end }}
+ |
+
+ {{ $cs.CommonName }}
+ |
+
+ {{ $cs.Issuer }}
+ |
+
+ {{ if not $cs.NotAfter.IsZero }}
+ {{ $days := expiryDays $cs.NotAfter }}
+ {{ if lt $days 7 }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ else if lt $days 30 }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ else }}
+ {{ $cs.NotAfter.Format "2006-01-02" }}
+ ({{ $days }}d)
+ {{ end }}
+ {{ end }}
+ |
+
+ {{ relTime $cs.LastChecked }}
+ |
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+ No certificate data yet.
+
+ {{ end }}
+
+
+ {{/* ---- Recent Alerts ---- */}}
+
+
+ Recent Alerts ({{ len .Alerts }})
+
+ {{ if .Alerts }}
+
+ {{ range .Alerts }}
+
+
+ {{ if eq .Priority "error" }}
+ error
+ {{ else if eq .Priority "warning" }}
+ warning
+ {{ else if eq .Priority "success" }}
+ success
+ {{ else }}
+ info
+ {{ end }}
+
+ {{ .Title }}
+
+
+ {{ .Timestamp.Format "2006-01-02 15:04:05" }} UTC
+ ({{ relTime .Timestamp }})
+
+
+
+ {{ .Message }}
+
+
+ {{ end }}
+
+ {{ else }}
+
+ No alerts recorded since last restart.
+
+ {{ end }}
+
+
+ {{/* ---- Footer ---- */}}
+
+ dnswatcher · monitoring {{ len .Snapshot.Domains }} domains +
+ {{ len .Snapshot.Hostnames }} hostnames
+
+
+
+
diff --git a/internal/logger/logger.go b/internal/logger/logger.go
index 0bcdd28..8aaaad1 100644
--- a/internal/logger/logger.go
+++ b/internal/logger/logger.go
@@ -78,6 +78,5 @@ func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
- "buildarch", l.params.Globals.Buildarch,
)
}
diff --git a/internal/notify/delivery_test.go b/internal/notify/delivery_test.go
new file mode 100644
index 0000000..1822950
--- /dev/null
+++ b/internal/notify/delivery_test.go
@@ -0,0 +1,1130 @@
+package notify_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/notify"
+)
+
+// Color constants used across multiple tests.
+const (
+ colorError = "#dc3545"
+ colorWarning = "#ffc107"
+ colorSuccess = "#28a745"
+ colorInfo = "#17a2b8"
+ colorDefault = "#6c757d"
+)
+
+// errSimulated is a static error for transport failures.
+var errSimulated = errors.New("simulated transport failure")
+
+// failingTransport always returns an error on RoundTrip.
+type failingTransport struct {
+ err error
+}
+
+func (ft *failingTransport) RoundTrip(
+ _ *http.Request,
+) (*http.Response, error) {
+ return nil, ft.err
+}
+
+// waitForCondition polls until fn returns true or the test
+// times out. This accommodates the goroutine-based dispatch
+// in SendNotification.
+func waitForCondition(t *testing.T, fn func() bool) {
+ t.Helper()
+
+ const (
+ maxAttempts = 200
+ pollDelay = 10 * time.Millisecond
+ )
+
+ for range maxAttempts {
+ if fn() {
+ return
+ }
+
+ time.Sleep(pollDelay)
+ }
+
+ t.Fatal("condition not met within timeout")
+}
+
+// slackCapture holds values captured from a Slack/Mattermost
+// webhook request, protected by a mutex for goroutine safety.
+type slackCapture struct {
+ mu sync.Mutex
+ called bool
+ payload notify.SlackPayload
+}
+
+func newSlackCaptureServer() (
+ *httptest.Server, *slackCapture,
+) {
+ c := &slackCapture{}
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.called = true
+
+ b, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(b, &c.payload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+
+ return srv, c
+}
+
+// ── ntfyPriority ──────────────────────────────────────────
+
+func TestNtfyPriority(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ input string
+ want string
+ }{
+ {"error", "urgent"},
+ {"warning", "high"},
+ {"success", "default"},
+ {"info", "low"},
+ {"", "default"},
+ {"unknown", "default"},
+ {"critical", "default"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.input, func(t *testing.T) {
+ t.Parallel()
+
+ got := notify.NtfyPriority(tc.input)
+ if got != tc.want {
+ t.Errorf(
+ "NtfyPriority(%q) = %q, want %q",
+ tc.input, got, tc.want,
+ )
+ }
+ })
+ }
+}
+
+// ── slackColor ────────────────────────────────────────────
+
+func TestSlackColor(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ input string
+ want string
+ }{
+ {"error", colorError},
+ {"warning", colorWarning},
+ {"success", colorSuccess},
+ {"info", colorInfo},
+ {"", colorDefault},
+ {"unknown", colorDefault},
+ {"critical", colorDefault},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.input, func(t *testing.T) {
+ t.Parallel()
+
+ got := notify.SlackColor(tc.input)
+ if got != tc.want {
+ t.Errorf(
+ "SlackColor(%q) = %q, want %q",
+ tc.input, got, tc.want,
+ )
+ }
+ })
+ }
+}
+
+// ── newRequest ────────────────────────────────────────────
+
+func TestNewRequest(t *testing.T) {
+ t.Parallel()
+
+ target := &url.URL{
+ Scheme: "https",
+ Host: "example.com",
+ Path: "/webhook",
+ }
+ body := bytes.NewBufferString("hello")
+ ctx := context.Background()
+
+ req := notify.NewRequestForTest(
+ ctx, http.MethodPost, target, body,
+ )
+
+ if req.Method != http.MethodPost {
+ t.Errorf("Method = %q, want POST", req.Method)
+ }
+
+ if req.URL.String() != "https://example.com/webhook" {
+ t.Errorf(
+ "URL = %q, want %q",
+ req.URL.String(),
+ "https://example.com/webhook",
+ )
+ }
+
+ if req.Host != "example.com" {
+ t.Errorf(
+ "Host = %q, want %q", req.Host, "example.com",
+ )
+ }
+
+ if req.Header == nil {
+ t.Error("Header map should be initialized")
+ }
+
+ got, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Fatalf("reading body: %v", err)
+ }
+
+ if string(got) != "hello" {
+ t.Errorf("Body = %q, want %q", string(got), "hello")
+ }
+}
+
+func TestNewRequestPreservesContext(t *testing.T) {
+ t.Parallel()
+
+ type ctxKey string
+
+ ctx := context.WithValue(
+ context.Background(),
+ ctxKey("k"),
+ "v",
+ )
+ target := &url.URL{Scheme: "https", Host: "example.com"}
+
+ req := notify.NewRequestForTest(
+ ctx, http.MethodGet, target, http.NoBody,
+ )
+
+ if req.Context().Value(ctxKey("k")) != "v" {
+ t.Error("context value not preserved")
+ }
+}
+
+// ── sendNtfy ──────────────────────────────────────────────
+
+// ntfyCapture holds values captured from an ntfy request.
+type ntfyCapture struct {
+ method string
+ title string
+ priority string
+ body string
+}
+
+func newNtfyCaptureServer() (*httptest.Server, *ntfyCapture) {
+ c := &ntfyCapture{}
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ c.method = r.Method
+ c.title = r.Header.Get("Title")
+ c.priority = r.Header.Get("Priority")
+
+ b, _ := io.ReadAll(r.Body)
+ c.body = string(b)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+
+ return srv, c
+}
+
+func TestSendNtfyHeaders(t *testing.T) {
+ t.Parallel()
+
+ srv, captured := newNtfyCaptureServer()
+ defer srv.Close()
+
+ svc := notify.NewTestService(srv.Client().Transport)
+ topicURL, _ := url.Parse(srv.URL + "/test-topic")
+
+ err := svc.SendNtfy(
+ context.Background(),
+ topicURL,
+ "Test Title",
+ "Test message body",
+ "error",
+ )
+ if err != nil {
+ t.Fatalf("SendNtfy returned error: %v", err)
+ }
+
+ if captured.method != http.MethodPost {
+ t.Errorf("method = %q, want POST", captured.method)
+ }
+
+ if captured.title != "Test Title" {
+ t.Errorf(
+ "Title header = %q, want %q",
+ captured.title, "Test Title",
+ )
+ }
+
+ if captured.priority != "urgent" {
+ t.Errorf(
+ "Priority header = %q, want %q",
+ captured.priority, "urgent",
+ )
+ }
+
+ if captured.body != "Test message body" {
+ t.Errorf(
+ "body = %q, want %q",
+ captured.body, "Test message body",
+ )
+ }
+}
+
+func TestSendNtfyAllPriorities(t *testing.T) {
+ t.Parallel()
+
+ priorities := []struct {
+ input string
+ want string
+ }{
+ {"error", "urgent"},
+ {"warning", "high"},
+ {"success", "default"},
+ {"info", "low"},
+ }
+
+ for _, tc := range priorities {
+ t.Run(tc.input, func(t *testing.T) {
+ t.Parallel()
+
+ var gotPriority string
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ gotPriority = r.Header.Get("Priority")
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ svc := notify.NewTestService(
+ srv.Client().Transport,
+ )
+ topicURL, _ := url.Parse(srv.URL)
+
+ err := svc.SendNtfy(
+ context.Background(),
+ topicURL, "t", "m", tc.input,
+ )
+ if err != nil {
+ t.Fatalf("SendNtfy error: %v", err)
+ }
+
+ if gotPriority != tc.want {
+ t.Errorf(
+ "priority %q: got %q, want %q",
+ tc.input, gotPriority, tc.want,
+ )
+ }
+ })
+ }
+}
+
+func TestSendNtfyClientError(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ }),
+ )
+ 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 403 response")
+ }
+
+ if !errors.Is(err, notify.ErrNtfyFailed) {
+ t.Errorf("error = %v, want ErrNtfyFailed", err)
+ }
+}
+
+func TestSendNtfyServerError(t *testing.T) {
+ t.Parallel()
+
+ 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) {
+ t.Parallel()
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ 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.Fatalf("expected success for 200: %v", err)
+ }
+}
+
+func TestSendNtfyNetworkError(t *testing.T) {
+ t.Parallel()
+
+ transport := &failingTransport{err: errSimulated}
+
+ svc := notify.NewTestService(transport)
+ topicURL, _ := url.Parse(
+ "http://unreachable.invalid/topic",
+ )
+
+ err := svc.SendNtfy(
+ context.Background(), topicURL, "t", "m", "info",
+ )
+ if err == nil {
+ t.Fatal("expected error for network failure")
+ }
+}
+
+// ── sendSlack ─────────────────────────────────────────────
+
+func TestSendSlackPayloadFields(t *testing.T) {
+ t.Parallel()
+
+ var (
+ gotContentType string
+ gotPayload notify.SlackPayload
+ gotMethod string
+ )
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotContentType = r.Header.Get("Content-Type")
+
+ b, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(b, &gotPayload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ svc := notify.NewTestService(srv.Client().Transport)
+ webhookURL, _ := url.Parse(srv.URL + "/hooks/test")
+
+ err := svc.SendSlack(
+ context.Background(),
+ webhookURL,
+ "Alert Title",
+ "Alert body text",
+ "warning",
+ )
+ if err != nil {
+ t.Fatalf("SendSlack returned error: %v", err)
+ }
+
+ assertSlackPayload(
+ t,
+ gotMethod,
+ gotContentType,
+ gotPayload,
+ "Alert Title",
+ "Alert body text",
+ colorWarning,
+ )
+}
+
+func assertSlackPayload(
+ t *testing.T,
+ method, contentType string,
+ payload notify.SlackPayload,
+ wantTitle, wantText, wantColor string,
+) {
+ t.Helper()
+
+ if method != http.MethodPost {
+ t.Errorf("method = %q, want POST", method)
+ }
+
+ if contentType != "application/json" {
+ t.Errorf(
+ "Content-Type = %q, want application/json",
+ contentType,
+ )
+ }
+
+ if len(payload.Attachments) != 1 {
+ t.Fatalf(
+ "attachments length = %d, want 1",
+ len(payload.Attachments),
+ )
+ }
+
+ att := payload.Attachments[0]
+
+ if att.Title != wantTitle {
+ t.Errorf(
+ "title = %q, want %q", att.Title, wantTitle,
+ )
+ }
+
+ if att.Text != wantText {
+ t.Errorf("text = %q, want %q", att.Text, wantText)
+ }
+
+ if att.Color != wantColor {
+ t.Errorf(
+ "color = %q, want %q", att.Color, wantColor,
+ )
+ }
+}
+
+func TestSendSlackAllColors(t *testing.T) {
+ t.Parallel()
+
+ colors := []struct {
+ priority string
+ want string
+ }{
+ {"error", colorError},
+ {"warning", colorWarning},
+ {"success", colorSuccess},
+ {"info", colorInfo},
+ {"unknown", colorDefault},
+ }
+
+ for _, tc := range colors {
+ t.Run(tc.priority, func(t *testing.T) {
+ t.Parallel()
+
+ var gotPayload notify.SlackPayload
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(b, &gotPayload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ svc := notify.NewTestService(
+ srv.Client().Transport,
+ )
+ webhookURL, _ := url.Parse(srv.URL)
+
+ err := svc.SendSlack(
+ context.Background(),
+ webhookURL, "t", "m", tc.priority,
+ )
+ if err != nil {
+ t.Fatalf("SendSlack error: %v", err)
+ }
+
+ if len(gotPayload.Attachments) == 0 {
+ t.Fatal("no attachments in payload")
+ }
+
+ if gotPayload.Attachments[0].Color != tc.want {
+ t.Errorf(
+ "priority %q: color = %q, want %q",
+ tc.priority,
+ gotPayload.Attachments[0].Color,
+ tc.want,
+ )
+ }
+ })
+ }
+}
+
+func TestSendSlackClientError(t *testing.T) {
+ t.Parallel()
+
+ 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()
+
+ 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) {
+ t.Parallel()
+
+ transport := &failingTransport{err: errSimulated}
+
+ svc := notify.NewTestService(transport)
+ webhookURL, _ := url.Parse(
+ "http://unreachable.invalid/hooks",
+ )
+
+ err := svc.SendSlack(
+ context.Background(), webhookURL, "t", "m", "info",
+ )
+ if err == nil {
+ t.Fatal("expected error for network failure")
+ }
+}
+
+// ── SendNotification (exported) ───────────────────────────
+
+// endpointResult captures concurrent results from all three
+// notification endpoints.
+type endpointResult struct {
+ mu sync.Mutex
+ ntfyCalled bool
+ ntfyTitle string
+ slackCalled bool
+ slackPayload notify.SlackPayload
+ mmCalled bool
+ mmPayload notify.SlackPayload
+}
+
+func newEndpointServers(
+ r *endpointResult,
+) (*httptest.Server, *httptest.Server, *httptest.Server) {
+ ntfy := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, req *http.Request) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.ntfyCalled = true
+ r.ntfyTitle = req.Header.Get("Title")
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+
+ slack := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, req *http.Request) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.slackCalled = true
+
+ b, _ := io.ReadAll(req.Body)
+ _ = json.Unmarshal(b, &r.slackPayload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+
+ mm := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, req *http.Request) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ r.mmCalled = true
+
+ b, _ := io.ReadAll(req.Body)
+ _ = json.Unmarshal(b, &r.mmPayload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+
+ return ntfy, slack, mm
+}
+
+func assertAllEndpointsResult(
+ t *testing.T, r *endpointResult,
+) {
+ t.Helper()
+
+ if r.ntfyTitle != "DNS Changed" {
+ t.Errorf(
+ "ntfy Title = %q, want %q",
+ r.ntfyTitle, "DNS Changed",
+ )
+ }
+
+ if len(r.slackPayload.Attachments) == 0 {
+ t.Fatal("slack payload has no attachments")
+ }
+
+ if r.slackPayload.Attachments[0].Title != "DNS Changed" {
+ t.Errorf(
+ "slack title = %q, want %q",
+ r.slackPayload.Attachments[0].Title,
+ "DNS Changed",
+ )
+ }
+
+ if len(r.mmPayload.Attachments) == 0 {
+ t.Fatal("mattermost payload has no attachments")
+ }
+
+ wantText := "example.com A record updated"
+ if r.mmPayload.Attachments[0].Text != wantText {
+ t.Errorf(
+ "mattermost text = %q, want %q",
+ r.mmPayload.Attachments[0].Text,
+ wantText,
+ )
+ }
+}
+
+func TestSendNotificationAllEndpoints(t *testing.T) {
+ t.Parallel()
+
+ result := &endpointResult{}
+ ntfySrv, slackSrv, mmSrv := newEndpointServers(result)
+
+ defer ntfySrv.Close()
+ defer slackSrv.Close()
+ defer mmSrv.Close()
+
+ ntfyURL, _ := url.Parse(ntfySrv.URL)
+ slackURL, _ := url.Parse(slackSrv.URL)
+ mmURL, _ := url.Parse(mmSrv.URL)
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetNtfyURL(ntfyURL)
+ svc.SetSlackWebhookURL(slackURL)
+ svc.SetMattermostWebhookURL(mmURL)
+
+ svc.SendNotification(
+ context.Background(),
+ "DNS Changed",
+ "example.com A record updated",
+ "warning",
+ )
+
+ waitForCondition(t, func() bool {
+ result.mu.Lock()
+ defer result.mu.Unlock()
+
+ return result.ntfyCalled &&
+ result.slackCalled &&
+ result.mmCalled
+ })
+
+ result.mu.Lock()
+ defer result.mu.Unlock()
+
+ assertAllEndpointsResult(t, result)
+}
+
+func TestSendNotificationNoWebhooks(t *testing.T) {
+ t.Parallel()
+
+ svc := notify.NewTestService(http.DefaultTransport)
+
+ // All URL fields are nil — this should be a no-op.
+ svc.SendNotification(
+ context.Background(), "Title", "Message", "info",
+ )
+}
+
+func TestSendNotificationNtfyOnly(t *testing.T) {
+ t.Parallel()
+
+ var (
+ mu sync.Mutex
+ called bool
+ gotTitle string
+ )
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ called = true
+ gotTitle = r.Header.Get("Title")
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ ntfyURL, _ := url.Parse(srv.URL)
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetNtfyURL(ntfyURL)
+
+ svc.SendNotification(
+ context.Background(), "Only Ntfy", "body", "info",
+ )
+
+ waitForCondition(t, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+
+ return called
+ })
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ if gotTitle != "Only Ntfy" {
+ t.Errorf(
+ "title = %q, want %q", gotTitle, "Only Ntfy",
+ )
+ }
+}
+
+func TestSendNotificationSlackOnly(t *testing.T) {
+ t.Parallel()
+
+ var (
+ mu sync.Mutex
+ called bool
+ payload notify.SlackPayload
+ )
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ called = true
+
+ b, _ := io.ReadAll(r.Body)
+ _ = json.Unmarshal(b, &payload)
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ slackURL, _ := url.Parse(srv.URL)
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSlackWebhookURL(slackURL)
+
+ svc.SendNotification(
+ context.Background(),
+ "Slack Only", "body", "error",
+ )
+
+ waitForCondition(t, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+
+ return called
+ })
+
+ mu.Lock()
+ defer mu.Unlock()
+
+ if len(payload.Attachments) == 0 {
+ t.Fatal("no attachments")
+ }
+
+ if payload.Attachments[0].Color != colorError {
+ t.Errorf(
+ "color = %q, want %q",
+ payload.Attachments[0].Color, colorError,
+ )
+ }
+}
+
+func TestSendNotificationMattermostOnly(t *testing.T) {
+ t.Parallel()
+
+ srv, capture := newSlackCaptureServer()
+ defer srv.Close()
+
+ mmURL, _ := url.Parse(srv.URL)
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetMattermostWebhookURL(mmURL)
+
+ svc.SendNotification(
+ context.Background(),
+ "MM Only", "body", "success",
+ )
+
+ waitForCondition(t, func() bool {
+ capture.mu.Lock()
+ defer capture.mu.Unlock()
+
+ return capture.called
+ })
+
+ capture.mu.Lock()
+ defer capture.mu.Unlock()
+
+ if len(capture.payload.Attachments) == 0 {
+ t.Fatal("no attachments")
+ }
+
+ att := capture.payload.Attachments[0]
+
+ if att.Title != "MM Only" {
+ t.Errorf(
+ "title = %q, want %q", att.Title, "MM Only",
+ )
+ }
+
+ if att.Color != colorSuccess {
+ t.Errorf(
+ "color = %q, want %q", att.Color, colorSuccess,
+ )
+ }
+}
+
+func TestSendNotificationNtfyError(t *testing.T) {
+ t.Parallel()
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }),
+ )
+ defer srv.Close()
+
+ ntfyURL, _ := url.Parse(srv.URL)
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetNtfyURL(ntfyURL)
+
+ // Should not panic or block.
+ svc.SendNotification(
+ context.Background(), "t", "m", "error",
+ )
+
+ time.Sleep(100 * time.Millisecond)
+}
+
+func TestSendNotificationSlackError(t *testing.T) {
+ t.Parallel()
+
+ 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()
+
+ 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 ──────────────────────────
+
+func TestSlackPayloadJSON(t *testing.T) {
+ t.Parallel()
+
+ payload := notify.SlackPayload{
+ Text: "fallback",
+ Attachments: []notify.SlackAttachment{
+ {
+ Color: colorSuccess,
+ Title: "Test",
+ Text: "body",
+ },
+ },
+ }
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var decoded notify.SlackPayload
+
+ err = json.Unmarshal(data, &decoded)
+ if err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if decoded.Text != "fallback" {
+ t.Errorf(
+ "Text = %q, want %q", decoded.Text, "fallback",
+ )
+ }
+
+ if len(decoded.Attachments) != 1 {
+ t.Fatalf(
+ "Attachments len = %d, want 1",
+ len(decoded.Attachments),
+ )
+ }
+
+ att := decoded.Attachments[0]
+
+ if att.Color != colorSuccess {
+ t.Errorf(
+ "Color = %q, want %q", att.Color, colorSuccess,
+ )
+ }
+
+ if att.Title != "Test" {
+ t.Errorf("Title = %q, want %q", att.Title, "Test")
+ }
+
+ if att.Text != "body" {
+ t.Errorf("Text = %q, want %q", att.Text, "body")
+ }
+}
+
+func TestSlackPayloadEmptyAttachments(t *testing.T) {
+ t.Parallel()
+
+ payload := notify.SlackPayload{Text: "no attachments"}
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+
+ var raw map[string]json.RawMessage
+
+ err = json.Unmarshal(data, &raw)
+ if err != nil {
+ t.Fatalf("unmarshal raw: %v", err)
+ }
+
+ if _, exists := raw["attachments"]; exists {
+ t.Error(
+ "attachments should be omitted when empty",
+ )
+ }
+}
diff --git a/internal/notify/export_test.go b/internal/notify/export_test.go
new file mode 100644
index 0000000..ae6818d
--- /dev/null
+++ b/internal/notify/export_test.go
@@ -0,0 +1,105 @@
+package notify
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// NtfyPriority exports ntfyPriority for testing.
+func NtfyPriority(priority string) string {
+ return ntfyPriority(priority)
+}
+
+// SlackColor exports slackColor for testing.
+func SlackColor(priority string) string {
+ return slackColor(priority)
+}
+
+// NewRequestForTest exports newRequest for testing.
+func NewRequestForTest(
+ ctx context.Context,
+ method string,
+ target *url.URL,
+ body io.Reader,
+) *http.Request {
+ return newRequest(ctx, method, target, body)
+}
+
+// NewTestService creates a Service suitable for unit testing.
+// It discards log output and uses the given transport.
+func NewTestService(transport http.RoundTripper) *Service {
+ return &Service{
+ log: slog.New(slog.DiscardHandler),
+ transport: transport,
+ history: NewAlertHistory(),
+ }
+}
+
+// SetNtfyURL sets the ntfy URL on a Service for testing.
+func (svc *Service) SetNtfyURL(u *url.URL) {
+ svc.ntfyURL = u
+}
+
+// SetSlackWebhookURL sets the Slack webhook URL on a
+// Service for testing.
+func (svc *Service) SetSlackWebhookURL(u *url.URL) {
+ svc.slackWebhookURL = u
+}
+
+// SetMattermostWebhookURL sets the Mattermost webhook URL on
+// a Service for testing.
+func (svc *Service) SetMattermostWebhookURL(u *url.URL) {
+ svc.mattermostWebhookURL = u
+}
+
+// SendNtfy exports sendNtfy for testing.
+func (svc *Service) SendNtfy(
+ ctx context.Context,
+ topicURL *url.URL,
+ title, message, priority string,
+) error {
+ return svc.sendNtfy(ctx, topicURL, title, message, priority)
+}
+
+// SendSlack exports sendSlack for testing.
+func (svc *Service) SendSlack(
+ ctx context.Context,
+ webhookURL *url.URL,
+ title, message, priority string,
+) error {
+ return svc.sendSlack(
+ ctx, webhookURL, title, message, priority,
+ )
+}
+
+// SetRetryConfig overrides the retry configuration for
+// testing.
+func (svc *Service) SetRetryConfig(cfg RetryConfig) {
+ svc.retryConfig = cfg
+}
+
+// SetSleepFunc overrides the sleep function so tests can
+// eliminate real delays.
+func (svc *Service) SetSleepFunc(
+ fn func(time.Duration) <-chan time.Time,
+) {
+ svc.sleepFn = fn
+}
+
+// DeliverWithRetry exports deliverWithRetry for testing.
+func (svc *Service) DeliverWithRetry(
+ ctx context.Context,
+ endpoint string,
+ fn func(context.Context) error,
+) error {
+ return svc.deliverWithRetry(ctx, endpoint, fn)
+}
+
+// BackoffDuration exports RetryConfig.backoff for testing.
+func (rc RetryConfig) BackoffDuration(attempt int) time.Duration {
+ return rc.defaults().backoff(attempt)
+}
diff --git a/internal/notify/history.go b/internal/notify/history.go
new file mode 100644
index 0000000..53a9b97
--- /dev/null
+++ b/internal/notify/history.go
@@ -0,0 +1,62 @@
+package notify
+
+import (
+ "sync"
+ "time"
+)
+
+// maxAlertHistory is the maximum number of alerts to retain.
+const maxAlertHistory = 100
+
+// AlertEntry represents a single notification that was sent.
+type AlertEntry struct {
+ Timestamp time.Time
+ Title string
+ Message string
+ Priority string
+}
+
+// AlertHistory is a thread-safe ring buffer that stores
+// the most recent alerts.
+type AlertHistory struct {
+ mu sync.RWMutex
+ entries [maxAlertHistory]AlertEntry
+ count int
+ index int
+}
+
+// NewAlertHistory creates a new empty AlertHistory.
+func NewAlertHistory() *AlertHistory {
+ return &AlertHistory{}
+}
+
+// Add records a new alert entry in the ring buffer.
+func (h *AlertHistory) Add(entry AlertEntry) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ h.entries[h.index] = entry
+ h.index = (h.index + 1) % maxAlertHistory
+
+ if h.count < maxAlertHistory {
+ h.count++
+ }
+}
+
+// Recent returns the stored alerts in reverse chronological
+// order (newest first). Returns at most maxAlertHistory entries.
+func (h *AlertHistory) Recent() []AlertEntry {
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ result := make([]AlertEntry, h.count)
+
+ for i := range h.count {
+ // Walk backwards from the most recent entry.
+ idx := (h.index - 1 - i + maxAlertHistory) %
+ maxAlertHistory
+ result[i] = h.entries[idx]
+ }
+
+ return result
+}
diff --git a/internal/notify/history_test.go b/internal/notify/history_test.go
new file mode 100644
index 0000000..a60804d
--- /dev/null
+++ b/internal/notify/history_test.go
@@ -0,0 +1,88 @@
+package notify_test
+
+import (
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/notify"
+)
+
+func TestAlertHistoryEmpty(t *testing.T) {
+ t.Parallel()
+
+ h := notify.NewAlertHistory()
+
+ entries := h.Recent()
+ if len(entries) != 0 {
+ t.Fatalf("expected 0 entries, got %d", len(entries))
+ }
+}
+
+func TestAlertHistoryAddAndRecent(t *testing.T) {
+ t.Parallel()
+
+ h := notify.NewAlertHistory()
+
+ now := time.Now().UTC()
+
+ h.Add(notify.AlertEntry{
+ Timestamp: now.Add(-2 * time.Minute),
+ Title: "first",
+ Message: "msg1",
+ Priority: "info",
+ })
+
+ h.Add(notify.AlertEntry{
+ Timestamp: now.Add(-1 * time.Minute),
+ Title: "second",
+ Message: "msg2",
+ Priority: "warning",
+ })
+
+ entries := h.Recent()
+ if len(entries) != 2 {
+ t.Fatalf("expected 2 entries, got %d", len(entries))
+ }
+
+ // Newest first.
+ if entries[0].Title != "second" {
+ t.Errorf(
+ "expected newest first, got %q", entries[0].Title,
+ )
+ }
+
+ if entries[1].Title != "first" {
+ t.Errorf(
+ "expected oldest second, got %q", entries[1].Title,
+ )
+ }
+}
+
+func TestAlertHistoryOverflow(t *testing.T) {
+ t.Parallel()
+
+ h := notify.NewAlertHistory()
+
+ const totalEntries = 110
+
+ // Fill beyond capacity.
+ for i := range totalEntries {
+ h.Add(notify.AlertEntry{
+ Timestamp: time.Now().UTC(),
+ Title: "alert",
+ Message: "msg",
+ Priority: string(rune('0' + i%10)),
+ })
+ }
+
+ entries := h.Recent()
+
+ const maxHistory = 100
+
+ if len(entries) != maxHistory {
+ t.Fatalf(
+ "expected %d entries, got %d",
+ maxHistory, len(entries),
+ )
+ }
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 08dbe5a..878b912 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -1,4 +1,5 @@
-// Package notify provides notification delivery to Slack, Mattermost, and ntfy.
+// Package notify provides notification delivery to Slack,
+// Mattermost, and ntfy.
package notify
import (
@@ -7,8 +8,10 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"net/http"
+ "net/url"
"time"
"go.uber.org/fx"
@@ -33,8 +36,66 @@ var (
ErrMattermostFailed = errors.New(
"mattermost notification failed",
)
+ // ErrInvalidScheme is returned for disallowed URL schemes.
+ ErrInvalidScheme = errors.New("URL scheme not allowed")
+ // ErrMissingHost is returned when a URL has no host.
+ ErrMissingHost = errors.New("URL must have a host")
)
+// IsAllowedScheme checks if the URL scheme is permitted.
+func IsAllowedScheme(scheme string) bool {
+ return scheme == "https" || scheme == "http"
+}
+
+// ValidateWebhookURL validates and sanitizes a webhook URL.
+// It ensures the URL has an allowed scheme (http/https),
+// a non-empty host, and returns a pre-parsed *url.URL
+// reconstructed from validated components.
+func ValidateWebhookURL(raw string) (*url.URL, error) {
+ u, err := url.ParseRequestURI(raw)
+ if err != nil {
+ return nil, fmt.Errorf("invalid URL: %w", err)
+ }
+
+ if !IsAllowedScheme(u.Scheme) {
+ return nil, fmt.Errorf(
+ "%w: %s", ErrInvalidScheme, u.Scheme,
+ )
+ }
+
+ if u.Host == "" {
+ return nil, fmt.Errorf("%w", ErrMissingHost)
+ }
+
+ // Reconstruct from parsed components.
+ clean := &url.URL{
+ Scheme: u.Scheme,
+ Host: u.Host,
+ Path: u.Path,
+ RawQuery: u.RawQuery,
+ }
+
+ return clean, nil
+}
+
+// newRequest creates an http.Request from a pre-validated *url.URL.
+// This avoids passing URL strings to http.NewRequestWithContext,
+// which gosec flags as a potential SSRF vector.
+func newRequest(
+ ctx context.Context,
+ method string,
+ target *url.URL,
+ body io.Reader,
+) *http.Request {
+ return (&http.Request{
+ Method: method,
+ URL: target,
+ Host: target.Host,
+ Header: make(http.Header),
+ Body: io.NopCloser(body),
+ }).WithContext(ctx)
+}
+
// Params contains dependencies for Service.
type Params struct {
fx.In
@@ -45,9 +106,15 @@ type Params struct {
// Service provides notification functionality.
type Service struct {
- log *slog.Logger
- client *http.Client
- config *config.Config
+ log *slog.Logger
+ transport http.RoundTripper
+ config *config.Config
+ ntfyURL *url.URL
+ slackWebhookURL *url.URL
+ mattermostWebhookURL *url.URL
+ history *AlertHistory
+ retryConfig RetryConfig
+ sleepFn func(time.Duration) <-chan time.Time
}
// New creates a new notify Service.
@@ -55,99 +122,193 @@ func New(
_ fx.Lifecycle,
params Params,
) (*Service, error) {
- return &Service{
- log: params.Logger.Get(),
- client: &http.Client{
- Timeout: httpClientTimeout,
- },
- config: params.Config,
- }, nil
+ svc := &Service{
+ log: params.Logger.Get(),
+ transport: http.DefaultTransport,
+ config: params.Config,
+ history: NewAlertHistory(),
+ }
+
+ if params.Config.NtfyTopic != "" {
+ u, err := ValidateWebhookURL(
+ params.Config.NtfyTopic,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "invalid ntfy topic URL: %w", err,
+ )
+ }
+
+ svc.ntfyURL = u
+ }
+
+ if params.Config.SlackWebhook != "" {
+ u, err := ValidateWebhookURL(
+ params.Config.SlackWebhook,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "invalid slack webhook URL: %w", err,
+ )
+ }
+
+ svc.slackWebhookURL = u
+ }
+
+ if params.Config.MattermostWebhook != "" {
+ u, err := ValidateWebhookURL(
+ params.Config.MattermostWebhook,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "invalid mattermost webhook URL: %w", err,
+ )
+ }
+
+ svc.mattermostWebhookURL = u
+ }
+
+ return svc, nil
}
-// SendNotification sends a notification to all configured endpoints.
+// History returns the alert history for reading recent alerts.
+func (svc *Service) History() *AlertHistory {
+ return svc.history
+}
+
+// SendNotification sends a notification to all configured
+// endpoints and records it in the alert history.
func (svc *Service) SendNotification(
ctx context.Context,
title, message, priority string,
) {
- if svc.config.NtfyTopic != "" {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ svc.history.Add(AlertEntry{
+ Timestamp: time.Now().UTC(),
+ Title: title,
+ Message: message,
+ Priority: priority,
+ })
- err := svc.sendNtfy(
- notifyCtx,
- svc.config.NtfyTopic,
- title, message, priority,
- )
- if err != nil {
- svc.log.Error(
- "failed to send ntfy notification",
- "error", err,
- )
- }
- }()
+ svc.dispatchNtfy(ctx, title, message, priority)
+ svc.dispatchSlack(ctx, title, message, priority)
+ svc.dispatchMattermost(ctx, title, message, priority)
+}
+
+func (svc *Service) dispatchNtfy(
+ ctx context.Context,
+ title, message, priority string,
+) {
+ if svc.ntfyURL == nil {
+ return
}
- if svc.config.SlackWebhook != "" {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.config.SlackWebhook,
- title, message, priority,
- )
- if err != nil {
- svc.log.Error(
- "failed to send slack notification",
- "error", err,
+ err := svc.deliverWithRetry(
+ notifyCtx, "ntfy",
+ func(c context.Context) error {
+ return svc.sendNtfy(
+ c, svc.ntfyURL,
+ title, message, priority,
)
- }
- }()
+ },
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send ntfy notification "+
+ "after retries",
+ "error", err,
+ )
+ }
+ }()
+}
+
+func (svc *Service) dispatchSlack(
+ ctx context.Context,
+ title, message, priority string,
+) {
+ if svc.slackWebhookURL == nil {
+ return
}
- if svc.config.MattermostWebhook != "" {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.config.MattermostWebhook,
- title, message, priority,
- )
- if err != nil {
- svc.log.Error(
- "failed to send mattermost notification",
- "error", err,
+ err := svc.deliverWithRetry(
+ notifyCtx, "slack",
+ func(c context.Context) error {
+ return svc.sendSlack(
+ c, svc.slackWebhookURL,
+ title, message, priority,
)
- }
- }()
+ },
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send slack notification "+
+ "after retries",
+ "error", err,
+ )
+ }
+ }()
+}
+
+func (svc *Service) dispatchMattermost(
+ ctx context.Context,
+ title, message, priority string,
+) {
+ if svc.mattermostWebhookURL == nil {
+ return
}
+
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
+
+ err := svc.deliverWithRetry(
+ notifyCtx, "mattermost",
+ func(c context.Context) error {
+ return svc.sendSlack(
+ c, svc.mattermostWebhookURL,
+ title, message, priority,
+ )
+ },
+ )
+ if err != nil {
+ svc.log.Error(
+ "failed to send mattermost notification "+
+ "after retries",
+ "error", err,
+ )
+ }
+ }()
}
func (svc *Service) sendNtfy(
ctx context.Context,
- topic, title, message, priority string,
+ topicURL *url.URL,
+ title, message, priority string,
) error {
svc.log.Debug(
"sending ntfy notification",
- "topic", topic,
+ "topic", topicURL.String(),
"title", title,
)
- request, err := http.NewRequestWithContext(
- ctx,
- http.MethodPost,
- topic,
- bytes.NewBufferString(message),
+ ctx, cancel := context.WithTimeout(
+ ctx, httpClientTimeout,
+ )
+ defer cancel()
+
+ body := bytes.NewBufferString(message)
+ request := newRequest(
+ ctx, http.MethodPost, topicURL, body,
)
- if err != nil {
- return fmt.Errorf("creating ntfy request: %w", err)
- }
request.Header.Set("Title", title)
request.Header.Set("Priority", ntfyPriority(priority))
- resp, err := svc.client.Do(request)
+ resp, err := svc.transport.RoundTrip(request)
if err != nil {
return fmt.Errorf("sending ntfy request: %w", err)
}
@@ -156,7 +317,8 @@ func (svc *Service) sendNtfy(
if resp.StatusCode >= httpStatusClientError {
return fmt.Errorf(
- "%w: status %d", ErrNtfyFailed, resp.StatusCode,
+ "%w: status %d",
+ ErrNtfyFailed, resp.StatusCode,
)
}
@@ -193,11 +355,17 @@ type SlackAttachment struct {
func (svc *Service) sendSlack(
ctx context.Context,
- webhookURL, title, message, priority string,
+ webhookURL *url.URL,
+ title, message, priority string,
) error {
+ ctx, cancel := context.WithTimeout(
+ ctx, httpClientTimeout,
+ )
+ defer cancel()
+
svc.log.Debug(
"sending webhook notification",
- "url", webhookURL,
+ "url", webhookURL.String(),
"title", title,
)
@@ -213,22 +381,19 @@ func (svc *Service) sendSlack(
body, err := json.Marshal(payload)
if err != nil {
- return fmt.Errorf("marshaling webhook payload: %w", err)
+ return fmt.Errorf(
+ "marshaling webhook payload: %w", err,
+ )
}
- request, err := http.NewRequestWithContext(
- ctx,
- http.MethodPost,
- webhookURL,
+ request := newRequest(
+ ctx, http.MethodPost, webhookURL,
bytes.NewBuffer(body),
)
- if err != nil {
- return fmt.Errorf("creating webhook request: %w", err)
- }
request.Header.Set("Content-Type", "application/json")
- resp, err := svc.client.Do(request)
+ resp, err := svc.transport.RoundTrip(request)
if err != nil {
return fmt.Errorf("sending webhook request: %w", err)
}
diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go
new file mode 100644
index 0000000..3ac34dd
--- /dev/null
+++ b/internal/notify/notify_test.go
@@ -0,0 +1,100 @@
+package notify_test
+
+import (
+ "testing"
+
+ "sneak.berlin/go/dnswatcher/internal/notify"
+)
+
+func TestValidateWebhookURLValid(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ input string
+ wantURL string
+ }{
+ {
+ name: "valid https URL",
+ input: "https://hooks.slack.com/T00/B00",
+ wantURL: "https://hooks.slack.com/T00/B00",
+ },
+ {
+ name: "valid http URL",
+ input: "http://localhost:8080/webhook",
+ wantURL: "http://localhost:8080/webhook",
+ },
+ {
+ name: "https with query",
+ input: "https://ntfy.sh/topic?auth=tok",
+ wantURL: "https://ntfy.sh/topic?auth=tok",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := notify.ValidateWebhookURL(tt.input)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if got.String() != tt.wantURL {
+ t.Errorf(
+ "got %q, want %q",
+ got.String(), tt.wantURL,
+ )
+ }
+ })
+ }
+}
+
+func TestValidateWebhookURLInvalid(t *testing.T) {
+ t.Parallel()
+
+ invalid := []struct {
+ name string
+ input string
+ }{
+ {"ftp scheme", "ftp://example.com/file"},
+ {"file scheme", "file:///etc/passwd"},
+ {"empty string", ""},
+ {"no scheme", "example.com/webhook"},
+ {"no host", "https:///path"},
+ }
+
+ for _, tt := range invalid {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ got, err := notify.ValidateWebhookURL(tt.input)
+ if err == nil {
+ t.Errorf(
+ "expected error for %q, got %v",
+ tt.input, got,
+ )
+ }
+ })
+ }
+}
+
+func TestIsAllowedScheme(t *testing.T) {
+ t.Parallel()
+
+ if !notify.IsAllowedScheme("https") {
+ t.Error("https should be allowed")
+ }
+
+ if !notify.IsAllowedScheme("http") {
+ t.Error("http should be allowed")
+ }
+
+ if notify.IsAllowedScheme("ftp") {
+ t.Error("ftp should not be allowed")
+ }
+
+ if notify.IsAllowedScheme("") {
+ t.Error("empty scheme should not be allowed")
+ }
+}
diff --git a/internal/notify/retry.go b/internal/notify/retry.go
new file mode 100644
index 0000000..bc0a08b
--- /dev/null
+++ b/internal/notify/retry.go
@@ -0,0 +1,139 @@
+package notify
+
+import (
+ "context"
+ "math"
+ "math/rand/v2"
+ "time"
+)
+
+// Retry defaults.
+const (
+ // DefaultMaxRetries is the number of additional attempts
+ // after the first failure.
+ DefaultMaxRetries = 5
+
+ // DefaultBaseDelay is the initial delay before the first
+ // retry attempt.
+ DefaultBaseDelay = 1 * time.Second
+
+ // DefaultMaxDelay caps the computed backoff delay.
+ DefaultMaxDelay = 60 * time.Second
+
+ // backoffMultiplier is the exponential growth factor.
+ backoffMultiplier = 2
+
+ // jitterFraction controls the ±random spread applied
+ // to each delay (0.25 = ±25%).
+ jitterFraction = 0.25
+)
+
+// RetryConfig holds tuning knobs for the retry loop.
+// Zero values fall back to the package defaults above.
+type RetryConfig struct {
+ MaxRetries int
+ BaseDelay time.Duration
+ MaxDelay time.Duration
+}
+
+// defaults returns a copy with zero fields replaced by
+// package defaults.
+func (rc RetryConfig) defaults() RetryConfig {
+ if rc.MaxRetries <= 0 {
+ rc.MaxRetries = DefaultMaxRetries
+ }
+
+ if rc.BaseDelay <= 0 {
+ rc.BaseDelay = DefaultBaseDelay
+ }
+
+ if rc.MaxDelay <= 0 {
+ rc.MaxDelay = DefaultMaxDelay
+ }
+
+ return rc
+}
+
+// backoff computes the delay for attempt n (0-indexed) with
+// jitter. The raw delay is BaseDelay * 2^n, capped at
+// MaxDelay, then randomised by ±jitterFraction.
+func (rc RetryConfig) backoff(attempt int) time.Duration {
+ raw := float64(rc.BaseDelay) *
+ math.Pow(backoffMultiplier, float64(attempt))
+
+ if raw > float64(rc.MaxDelay) {
+ raw = float64(rc.MaxDelay)
+ }
+
+ // Apply jitter: uniform in [raw*(1-j), raw*(1+j)].
+ lo := raw * (1 - jitterFraction)
+ hi := raw * (1 + jitterFraction)
+
+ jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
+
+ return time.Duration(jittered)
+}
+
+// deliverWithRetry calls fn, retrying on error with
+// exponential backoff. It logs every failed attempt and
+// returns the last error if all attempts are exhausted.
+func (svc *Service) deliverWithRetry(
+ ctx context.Context,
+ endpoint string,
+ fn func(context.Context) error,
+) error {
+ cfg := svc.retryConfig.defaults()
+
+ var lastErr error
+
+ // attempt 0 is the initial call; attempts 1..MaxRetries
+ // are retries.
+ for attempt := range cfg.MaxRetries + 1 {
+ lastErr = fn(ctx)
+ if lastErr == nil {
+ if attempt > 0 {
+ svc.log.Info(
+ "notification delivered after retry",
+ "endpoint", endpoint,
+ "attempt", attempt+1,
+ )
+ }
+
+ return nil
+ }
+
+ // Last attempt — don't sleep, just return.
+ if attempt == cfg.MaxRetries {
+ break
+ }
+
+ delay := cfg.backoff(attempt)
+
+ svc.log.Warn(
+ "notification delivery failed, retrying",
+ "endpoint", endpoint,
+ "attempt", attempt+1,
+ "maxAttempts", cfg.MaxRetries+1,
+ "retryIn", delay,
+ "error", lastErr,
+ )
+
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-svc.sleepFunc(delay):
+ }
+ }
+
+ return lastErr
+}
+
+// sleepFunc returns a channel that closes after d.
+// It is a field-level indirection so tests can override it.
+func (svc *Service) sleepFunc(d time.Duration) <-chan time.Time {
+ if svc.sleepFn != nil {
+ return svc.sleepFn(d)
+ }
+
+ return time.After(d)
+}
diff --git a/internal/notify/retry_test.go b/internal/notify/retry_test.go
new file mode 100644
index 0000000..878e1bf
--- /dev/null
+++ b/internal/notify/retry_test.go
@@ -0,0 +1,493 @@
+package notify_test
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/notify"
+)
+
+// Static test errors (err113).
+var (
+ errTransient = errors.New("transient failure")
+ errPermanent = errors.New("permanent failure")
+ errFail = errors.New("fail")
+)
+
+// instantSleep returns a closed channel immediately, removing
+// real delays from tests.
+func instantSleep(_ time.Duration) <-chan time.Time {
+ ch := make(chan time.Time, 1)
+ ch <- time.Now()
+
+ return ch
+}
+
+// ── backoff calculation ───────────────────────────────────
+
+func TestBackoffDurationIncreases(t *testing.T) {
+ t.Parallel()
+
+ cfg := notify.RetryConfig{
+ MaxRetries: 5,
+ BaseDelay: 1 * time.Second,
+ MaxDelay: 30 * time.Second,
+ }
+
+ prev := time.Duration(0)
+
+ // With jitter the exact value varies, but the trend
+ // should be increasing for the first few attempts.
+ for attempt := range 4 {
+ d := cfg.BackoffDuration(attempt)
+ if d <= 0 {
+ t.Fatalf(
+ "attempt %d: backoff must be positive, got %v",
+ attempt, d,
+ )
+ }
+
+ // Allow jitter to occasionally flatten a step, but
+ // the midpoint (no-jitter) should be strictly higher.
+ midpoint := cfg.BaseDelay * (1 << attempt)
+ if attempt > 0 && midpoint <= prev {
+ t.Fatalf(
+ "midpoint should grow: attempt %d midpoint=%v prev=%v",
+ attempt, midpoint, prev,
+ )
+ }
+
+ prev = midpoint
+ }
+}
+
+func TestBackoffDurationCappedAtMax(t *testing.T) {
+ t.Parallel()
+
+ cfg := notify.RetryConfig{
+ MaxRetries: 5,
+ BaseDelay: 1 * time.Second,
+ MaxDelay: 5 * time.Second,
+ }
+
+ // Attempt 10 would be 1024s without capping.
+ d := cfg.BackoffDuration(10)
+
+ // With ±25% jitter on a 5s cap: max is 6.25s.
+ const maxWithJitter = 5*time.Second +
+ 5*time.Second/4 +
+ time.Millisecond // rounding margin
+
+ if d > maxWithJitter {
+ t.Errorf(
+ "backoff %v exceeds max+jitter %v",
+ d, maxWithJitter,
+ )
+ }
+}
+
+// ── deliverWithRetry ──────────────────────────────────────
+
+func TestDeliverWithRetrySucceedsFirstAttempt(t *testing.T) {
+ t.Parallel()
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSleepFunc(instantSleep)
+
+ var calls atomic.Int32
+
+ err := svc.DeliverWithRetry(
+ context.Background(), "test",
+ func(_ context.Context) error {
+ calls.Add(1)
+
+ return nil
+ },
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if calls.Load() != 1 {
+ t.Errorf("expected 1 call, got %d", calls.Load())
+ }
+}
+
+func TestDeliverWithRetryRetriesOnFailure(t *testing.T) {
+ t.Parallel()
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSleepFunc(instantSleep)
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 3,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ var calls atomic.Int32
+
+ // Fail twice, then succeed on the third attempt.
+ err := svc.DeliverWithRetry(
+ context.Background(), "test",
+ func(_ context.Context) error {
+ n := calls.Add(1)
+ if n <= 2 {
+ return errTransient
+ }
+
+ return nil
+ },
+ )
+ if err != nil {
+ t.Fatalf("expected success after retries: %v", err)
+ }
+
+ if calls.Load() != 3 {
+ t.Errorf("expected 3 calls, got %d", calls.Load())
+ }
+}
+
+func TestDeliverWithRetryExhaustsAttempts(t *testing.T) {
+ t.Parallel()
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSleepFunc(instantSleep)
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 2,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ var calls atomic.Int32
+
+ err := svc.DeliverWithRetry(
+ context.Background(), "test",
+ func(_ context.Context) error {
+ calls.Add(1)
+
+ return errPermanent
+ },
+ )
+ if err == nil {
+ t.Fatal("expected error when all retries exhausted")
+ }
+
+ if !errors.Is(err, errPermanent) {
+ t.Errorf("expected permanent failure, got: %v", err)
+ }
+
+ // 1 initial + 2 retries = 3 total.
+ if calls.Load() != 3 {
+ t.Errorf("expected 3 calls, got %d", calls.Load())
+ }
+}
+
+func TestDeliverWithRetryRespectsContextCancellation(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 5,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ // Use a blocking sleep so the context cancellation is
+ // the only way out.
+ svc.SetSleepFunc(func(_ time.Duration) <-chan time.Time {
+ return make(chan time.Time) // never fires
+ })
+
+ ctx, cancel := context.WithCancel(context.Background())
+
+ done := make(chan error, 1)
+
+ go func() {
+ done <- svc.DeliverWithRetry(
+ ctx, "test",
+ func(_ context.Context) error {
+ return errFail
+ },
+ )
+ }()
+
+ // Wait for the first failure + retry sleep to be
+ // entered, then cancel.
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+
+ select {
+ case err := <-done:
+ if !errors.Is(err, context.Canceled) {
+ t.Errorf(
+ "expected context.Canceled, got: %v", err,
+ )
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("deliverWithRetry did not return after cancel")
+ }
+}
+
+// ── integration: SendNotification with retry ──────────────
+
+func TestSendNotificationRetriesTransientFailure(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ var (
+ mu sync.Mutex
+ attempts int
+ )
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ mu.Lock()
+ attempts++
+ n := attempts
+ mu.Unlock()
+
+ if n <= 2 {
+ w.WriteHeader(
+ http.StatusInternalServerError,
+ )
+
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ defer srv.Close()
+
+ svc := newRetryTestService(srv.URL, "ntfy")
+
+ svc.SendNotification(
+ context.Background(),
+ "Retry Test", "body", "warning",
+ )
+
+ waitForCondition(t, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+
+ return attempts >= 3
+ })
+}
+
+// newRetryTestService creates a test service with instant
+// sleep and low retry delays for the named endpoint.
+func newRetryTestService(
+ rawURL, endpoint string,
+) *notify.Service {
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSleepFunc(instantSleep)
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 3,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ u, _ := url.Parse(rawURL)
+
+ switch endpoint {
+ case "ntfy":
+ svc.SetNtfyURL(u)
+ case "slack":
+ svc.SetSlackWebhookURL(u)
+ case "mattermost":
+ svc.SetMattermostWebhookURL(u)
+ }
+
+ return svc
+}
+
+func TestSendNotificationAllEndpointsRetrySetup(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ result := newEndpointRetryResult()
+ ntfySrv, slackSrv, mmSrv := newRetryServers(result)
+
+ defer ntfySrv.Close()
+ defer slackSrv.Close()
+ defer mmSrv.Close()
+
+ svc := buildAllEndpointRetryService(
+ ntfySrv.URL, slackSrv.URL, mmSrv.URL,
+ )
+
+ svc.SendNotification(
+ context.Background(),
+ "Multi-Retry", "testing", "error",
+ )
+
+ assertAllEndpointsRetried(t, result)
+}
+
+// endpointRetryResult tracks per-endpoint retry state.
+type endpointRetryResult struct {
+ mu sync.Mutex
+ ntfyAttempts int
+ slackAttempts int
+ mmAttempts int
+ ntfyOK bool
+ slackOK bool
+ mmOK bool
+}
+
+func newEndpointRetryResult() *endpointRetryResult {
+ return &endpointRetryResult{}
+}
+
+func newRetryServers(
+ r *endpointRetryResult,
+) (*httptest.Server, *httptest.Server, *httptest.Server) {
+ mk := func(
+ attempts *int, ok *bool,
+ ) *httptest.Server {
+ return httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ r.mu.Lock()
+ *attempts++
+ n := *attempts
+ r.mu.Unlock()
+
+ if n == 1 {
+ w.WriteHeader(
+ http.StatusServiceUnavailable,
+ )
+
+ return
+ }
+
+ r.mu.Lock()
+ *ok = true
+ r.mu.Unlock()
+
+ w.WriteHeader(http.StatusOK)
+ }),
+ )
+ }
+
+ return mk(&r.ntfyAttempts, &r.ntfyOK),
+ mk(&r.slackAttempts, &r.slackOK),
+ mk(&r.mmAttempts, &r.mmOK)
+}
+
+func buildAllEndpointRetryService(
+ ntfyURL, slackURL, mmURL string,
+) *notify.Service {
+ svc := notify.NewTestService(http.DefaultTransport)
+ svc.SetSleepFunc(instantSleep)
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 3,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ nu, _ := url.Parse(ntfyURL)
+ su, _ := url.Parse(slackURL)
+ mu, _ := url.Parse(mmURL)
+
+ svc.SetNtfyURL(nu)
+ svc.SetSlackWebhookURL(su)
+ svc.SetMattermostWebhookURL(mu)
+
+ return svc
+}
+
+func assertAllEndpointsRetried(
+ t *testing.T,
+ r *endpointRetryResult,
+) {
+ t.Helper()
+
+ waitForCondition(t, func() bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ return r.ntfyOK && r.slackOK && r.mmOK
+ })
+
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if r.ntfyAttempts < 2 {
+ t.Errorf(
+ "ntfy: expected >= 2 attempts, got %d",
+ r.ntfyAttempts,
+ )
+ }
+
+ if r.slackAttempts < 2 {
+ t.Errorf(
+ "slack: expected >= 2 attempts, got %d",
+ r.slackAttempts,
+ )
+ }
+
+ if r.mmAttempts < 2 {
+ t.Errorf(
+ "mattermost: expected >= 2 attempts, got %d",
+ r.mmAttempts,
+ )
+ }
+}
+
+func TestSendNotificationPermanentFailureLogsError(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ var (
+ mu sync.Mutex
+ attempts int
+ )
+
+ srv := httptest.NewServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ mu.Lock()
+ attempts++
+ mu.Unlock()
+
+ w.WriteHeader(
+ http.StatusInternalServerError,
+ )
+ }),
+ )
+ defer srv.Close()
+
+ svc := newRetryTestService(srv.URL, "slack")
+ svc.SetRetryConfig(notify.RetryConfig{
+ MaxRetries: 2,
+ BaseDelay: time.Millisecond,
+ MaxDelay: 10 * time.Millisecond,
+ })
+
+ svc.SendNotification(
+ context.Background(),
+ "Permanent Fail", "body", "error",
+ )
+
+ // 1 initial + 2 retries = 3 total.
+ waitForCondition(t, func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+
+ return attempts >= 3
+ })
+}
diff --git a/internal/portcheck/portcheck.go b/internal/portcheck/portcheck.go
index 2c061b7..a57b230 100644
--- a/internal/portcheck/portcheck.go
+++ b/internal/portcheck/portcheck.go
@@ -4,18 +4,39 @@ package portcheck
import (
"context"
"errors"
+ "fmt"
"log/slog"
+ "net"
+ "strconv"
+ "sync"
+ "time"
"go.uber.org/fx"
+ "golang.org/x/sync/errgroup"
"sneak.berlin/go/dnswatcher/internal/logger"
)
-// ErrNotImplemented indicates the port checker is not yet implemented.
-var ErrNotImplemented = errors.New(
- "port checker not yet implemented",
+const (
+ minPort = 1
+ maxPort = 65535
+ defaultTimeout = 5 * time.Second
)
+// ErrInvalidPort is returned when a port number is outside
+// the valid TCP range (1–65535).
+var ErrInvalidPort = errors.New("invalid port number")
+
+// PortResult holds the outcome of a single TCP port check.
+type PortResult struct {
+ // Open indicates whether the port accepted a connection.
+ Open bool
+ // Error contains a description if the connection failed.
+ Error string
+ // Latency is the time taken for the TCP handshake.
+ Latency time.Duration
+}
+
// Params contains dependencies for Checker.
type Params struct {
fx.In
@@ -38,11 +59,145 @@ func New(
}, nil
}
-// CheckPort tests TCP connectivity to the given address and port.
-func (c *Checker) CheckPort(
- _ context.Context,
- _ string,
- _ int,
-) (bool, error) {
- return false, ErrNotImplemented
+// NewStandalone creates a Checker without fx dependencies.
+func NewStandalone() *Checker {
+ return &Checker{
+ log: slog.Default(),
+ }
+}
+
+// validatePort checks that a port number is within the valid
+// TCP port range (1–65535).
+func validatePort(port int) error {
+ if port < minPort || port > maxPort {
+ return fmt.Errorf(
+ "%w: %d (must be between %d and %d)",
+ ErrInvalidPort, port, minPort, maxPort,
+ )
+ }
+
+ return nil
+}
+
+// CheckPort tests TCP connectivity to the given address and port.
+// It uses a 5-second timeout unless the context has an earlier
+// deadline.
+func (c *Checker) CheckPort(
+ ctx context.Context,
+ address string,
+ port int,
+) (*PortResult, error) {
+ err := validatePort(port)
+ if err != nil {
+ return nil, err
+ }
+
+ target := net.JoinHostPort(
+ address, strconv.Itoa(port),
+ )
+
+ timeout := defaultTimeout
+
+ deadline, hasDeadline := ctx.Deadline()
+ if hasDeadline {
+ remaining := time.Until(deadline)
+ if remaining < timeout {
+ timeout = remaining
+ }
+ }
+
+ return c.checkConnection(ctx, target, timeout), nil
+}
+
+// CheckPorts tests TCP connectivity to multiple ports on the
+// given address concurrently. It returns a map of port number
+// to result.
+func (c *Checker) CheckPorts(
+ ctx context.Context,
+ address string,
+ ports []int,
+) (map[int]*PortResult, error) {
+ for _, port := range ports {
+ err := validatePort(port)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ var mu sync.Mutex
+
+ results := make(map[int]*PortResult, len(ports))
+
+ g, ctx := errgroup.WithContext(ctx)
+
+ for _, port := range ports {
+ g.Go(func() error {
+ result, err := c.CheckPort(ctx, address, port)
+ if err != nil {
+ return fmt.Errorf(
+ "checking port %d: %w", port, err,
+ )
+ }
+
+ mu.Lock()
+ results[port] = result
+ mu.Unlock()
+
+ return nil
+ })
+ }
+
+ err := g.Wait()
+ if err != nil {
+ return nil, err
+ }
+
+ return results, nil
+}
+
+// checkConnection performs the TCP dial and returns a result.
+func (c *Checker) checkConnection(
+ ctx context.Context,
+ target string,
+ timeout time.Duration,
+) *PortResult {
+ dialer := &net.Dialer{Timeout: timeout}
+ start := time.Now()
+
+ conn, dialErr := dialer.DialContext(ctx, "tcp", target)
+ latency := time.Since(start)
+
+ if dialErr != nil {
+ c.log.Debug(
+ "port check failed",
+ "target", target,
+ "error", dialErr.Error(),
+ )
+
+ return &PortResult{
+ Open: false,
+ Error: dialErr.Error(),
+ Latency: latency,
+ }
+ }
+
+ closeErr := conn.Close()
+ if closeErr != nil {
+ c.log.Debug(
+ "closing connection",
+ "target", target,
+ "error", closeErr.Error(),
+ )
+ }
+
+ c.log.Debug(
+ "port check succeeded",
+ "target", target,
+ "latency", latency,
+ )
+
+ return &PortResult{
+ Open: true,
+ Latency: latency,
+ }
}
diff --git a/internal/portcheck/portcheck_test.go b/internal/portcheck/portcheck_test.go
new file mode 100644
index 0000000..7ea7fc0
--- /dev/null
+++ b/internal/portcheck/portcheck_test.go
@@ -0,0 +1,211 @@
+package portcheck_test
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/portcheck"
+)
+
+func listenTCP(
+ t *testing.T,
+) (net.Listener, int) {
+ t.Helper()
+
+ lc := &net.ListenConfig{}
+
+ ln, err := lc.Listen(
+ context.Background(), "tcp", "127.0.0.1:0",
+ )
+ if err != nil {
+ t.Fatalf("failed to start listener: %v", err)
+ }
+
+ addr, ok := ln.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatal("unexpected address type")
+ }
+
+ return ln, addr.Port
+}
+
+func TestCheckPortOpen(t *testing.T) {
+ t.Parallel()
+
+ ln, port := listenTCP(t)
+
+ defer func() { _ = ln.Close() }()
+
+ checker := portcheck.NewStandalone()
+
+ result, err := checker.CheckPort(
+ context.Background(), "127.0.0.1", port,
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if !result.Open {
+ t.Error("expected port to be open")
+ }
+
+ if result.Error != "" {
+ t.Errorf("expected no error, got: %s", result.Error)
+ }
+
+ if result.Latency <= 0 {
+ t.Error("expected positive latency")
+ }
+}
+
+func TestCheckPortClosed(t *testing.T) {
+ t.Parallel()
+
+ ln, port := listenTCP(t)
+ _ = ln.Close()
+
+ checker := portcheck.NewStandalone()
+
+ result, err := checker.CheckPort(
+ context.Background(), "127.0.0.1", port,
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result.Open {
+ t.Error("expected port to be closed")
+ }
+
+ if result.Error == "" {
+ t.Error("expected error message for closed port")
+ }
+}
+
+func TestCheckPortContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ checker := portcheck.NewStandalone()
+
+ result, err := checker.CheckPort(ctx, "127.0.0.1", 1)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result.Open {
+ t.Error("expected port to not be open")
+ }
+}
+
+func TestCheckPortsMultiple(t *testing.T) {
+ t.Parallel()
+
+ ln, openPort := listenTCP(t)
+
+ defer func() { _ = ln.Close() }()
+
+ ln2, closedPort := listenTCP(t)
+ _ = ln2.Close()
+
+ checker := portcheck.NewStandalone()
+
+ results, err := checker.CheckPorts(
+ context.Background(),
+ "127.0.0.1",
+ []int{openPort, closedPort},
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if len(results) != 2 {
+ t.Fatalf(
+ "expected 2 results, got %d", len(results),
+ )
+ }
+
+ if !results[openPort].Open {
+ t.Error("expected open port to be open")
+ }
+
+ if results[closedPort].Open {
+ t.Error("expected closed port to be closed")
+ }
+}
+
+func TestCheckPortInvalidPorts(t *testing.T) {
+ t.Parallel()
+
+ checker := portcheck.NewStandalone()
+
+ cases := []struct {
+ name string
+ port int
+ }{
+ {"zero", 0},
+ {"negative", -1},
+ {"too high", 65536},
+ {"very negative", -1000},
+ {"very high", 100000},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ _, err := checker.CheckPort(
+ context.Background(), "127.0.0.1", tc.port,
+ )
+ if err == nil {
+ t.Errorf(
+ "expected error for port %d, got nil",
+ tc.port,
+ )
+ }
+ })
+ }
+}
+
+func TestCheckPortsInvalidPort(t *testing.T) {
+ t.Parallel()
+
+ checker := portcheck.NewStandalone()
+
+ _, err := checker.CheckPorts(
+ context.Background(),
+ "127.0.0.1",
+ []int{80, 0, 443},
+ )
+ if err == nil {
+ t.Error("expected error for invalid port in list")
+ }
+}
+
+func TestCheckPortLatencyReasonable(t *testing.T) {
+ t.Parallel()
+
+ ln, port := listenTCP(t)
+
+ defer func() { _ = ln.Close() }()
+
+ checker := portcheck.NewStandalone()
+
+ result, err := checker.CheckPort(
+ context.Background(), "127.0.0.1", port,
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if result.Latency > time.Second {
+ t.Errorf(
+ "latency too high for localhost: %v",
+ result.Latency,
+ )
+ }
+}
diff --git a/internal/resolver/dns_client.go b/internal/resolver/dns_client.go
new file mode 100644
index 0000000..589c657
--- /dev/null
+++ b/internal/resolver/dns_client.go
@@ -0,0 +1,48 @@
+package resolver
+
+import (
+ "context"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+// DNSClient abstracts DNS wire-protocol exchanges so the resolver
+// can be tested without hitting real nameservers.
+type DNSClient interface {
+ ExchangeContext(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+ ) (*dns.Msg, time.Duration, error)
+}
+
+// udpClient wraps a real dns.Client for production use.
+type udpClient struct {
+ timeout time.Duration
+}
+
+func (c *udpClient) ExchangeContext(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+) (*dns.Msg, time.Duration, error) {
+ cl := &dns.Client{Timeout: c.timeout}
+
+ return cl.ExchangeContext(ctx, msg, addr)
+}
+
+// tcpClient wraps a real dns.Client using TCP.
+type tcpClient struct {
+ timeout time.Duration
+}
+
+func (c *tcpClient) ExchangeContext(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+) (*dns.Msg, time.Duration, error) {
+ cl := &dns.Client{Net: "tcp", Timeout: c.timeout}
+
+ return cl.ExchangeContext(ctx, msg, addr)
+}
diff --git a/internal/resolver/errors.go b/internal/resolver/errors.go
index 94bc313..3f203d4 100644
--- a/internal/resolver/errors.go
+++ b/internal/resolver/errors.go
@@ -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(
diff --git a/internal/resolver/iterative.go b/internal/resolver/iterative.go
new file mode 100644
index 0000000..eebab39
--- /dev/null
+++ b/internal/resolver/iterative.go
@@ -0,0 +1,803 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+const (
+ queryTimeoutDuration = 2 * time.Second
+ maxRetries = 2
+ maxDelegation = 20
+ timeoutMultiplier = 2
+ minDomainLabels = 2
+)
+
+// ErrRefused is returned when a DNS server refuses a query.
+var ErrRefused = errors.New("dns query refused")
+
+func rootServerList() []string {
+ return []string{
+ "198.41.0.4", // a.root-servers.net
+ "170.247.170.2", // b
+ "192.33.4.12", // c
+ "199.7.91.13", // d
+ "192.203.230.10", // e
+ "192.5.5.241", // f
+ "192.112.36.4", // g
+ "198.97.190.53", // h
+ "192.36.148.17", // i
+ "192.58.128.30", // j
+ "193.0.14.129", // k
+ "199.7.83.42", // l
+ "202.12.27.33", // m
+ }
+}
+
+func checkCtx(ctx context.Context) error {
+ err := ctx.Err()
+ if err != nil {
+ return ErrContextCanceled
+ }
+
+ return nil
+}
+
+func (r *Resolver) exchangeWithTimeout(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+ attempt int,
+) (*dns.Msg, error) {
+ _ = attempt // timeout escalation handled by client config
+
+ resp, _, err := r.client.ExchangeContext(ctx, msg, addr)
+
+ return resp, err
+}
+
+func (r *Resolver) tryExchange(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+) (*dns.Msg, error) {
+ var resp *dns.Msg
+
+ var err error
+
+ for attempt := range maxRetries {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err = r.exchangeWithTimeout(
+ ctx, msg, addr, attempt,
+ )
+ if err == nil {
+ break
+ }
+ }
+
+ return resp, err
+}
+
+func (r *Resolver) retryTCP(
+ ctx context.Context,
+ msg *dns.Msg,
+ addr string,
+ resp *dns.Msg,
+) *dns.Msg {
+ if !resp.Truncated {
+ return resp
+ }
+
+ tcpResp, _, tcpErr := r.tcp.ExchangeContext(ctx, msg, addr)
+ if tcpErr == nil {
+ return tcpResp
+ }
+
+ return resp
+}
+
+// queryDNS sends a DNS query to a specific server IP.
+// Tries non-recursive first, falls back to recursive on
+// REFUSED (handles DNS interception environments).
+func (r *Resolver) queryDNS(
+ ctx context.Context,
+ serverIP string,
+ name string,
+ qtype uint16,
+) (*dns.Msg, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ name = dns.Fqdn(name)
+ addr := net.JoinHostPort(serverIP, "53")
+
+ msg := new(dns.Msg)
+ msg.SetQuestion(name, qtype)
+ msg.RecursionDesired = false
+
+ resp, err := r.tryExchange(ctx, msg, addr)
+ if err != nil {
+ return nil, fmt.Errorf("query %s @%s: %w", name, serverIP, err)
+ }
+
+ if resp.Rcode == dns.RcodeRefused {
+ msg.RecursionDesired = true
+
+ resp, err = r.tryExchange(ctx, msg, addr)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "query %s @%s: %w", name, serverIP, err,
+ )
+ }
+
+ if resp.Rcode == dns.RcodeRefused {
+ return nil, fmt.Errorf(
+ "query %s @%s: %w", name, serverIP, ErrRefused,
+ )
+ }
+ }
+
+ resp = r.retryTCP(ctx, msg, addr, resp)
+
+ return resp, nil
+}
+
+func extractNSSet(rrs []dns.RR) []string {
+ nsSet := make(map[string]bool)
+
+ for _, rr := range rrs {
+ if ns, ok := rr.(*dns.NS); ok {
+ nsSet[strings.ToLower(ns.Ns)] = true
+ }
+ }
+
+ names := make([]string, 0, len(nsSet))
+ for n := range nsSet {
+ names = append(names, n)
+ }
+
+ sort.Strings(names)
+
+ return names
+}
+
+func extractGlue(rrs []dns.RR) map[string][]net.IP {
+ glue := make(map[string][]net.IP)
+
+ for _, rr := range rrs {
+ switch r := rr.(type) {
+ case *dns.A:
+ name := strings.ToLower(r.Hdr.Name)
+ glue[name] = append(glue[name], r.A)
+ case *dns.AAAA:
+ name := strings.ToLower(r.Hdr.Name)
+ glue[name] = append(glue[name], r.AAAA)
+ }
+ }
+
+ return glue
+}
+
+func glueIPs(nsNames []string, glue map[string][]net.IP) []string {
+ var ips []string
+
+ for _, ns := range nsNames {
+ for _, addr := range glue[ns] {
+ if v4 := addr.To4(); v4 != nil {
+ ips = append(ips, v4.String())
+ }
+ }
+ }
+
+ return ips
+}
+
+func (r *Resolver) followDelegation(
+ ctx context.Context,
+ domain string,
+ servers []string,
+) ([]string, error) {
+ for range maxDelegation {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err := r.queryServers(
+ ctx, servers, domain, dns.TypeNS,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ ansNS := extractNSSet(resp.Answer)
+ if len(ansNS) > 0 {
+ return ansNS, nil
+ }
+
+ authNS := extractNSSet(resp.Ns)
+ if len(authNS) == 0 {
+ return r.resolveNSIterative(ctx, domain)
+ }
+
+ glue := extractGlue(resp.Extra)
+ nextServers := glueIPs(authNS, glue)
+
+ if len(nextServers) == 0 {
+ nextServers = r.resolveNSIPs(ctx, authNS)
+ }
+
+ if len(nextServers) == 0 {
+ return nil, ErrNoNameservers
+ }
+
+ servers = nextServers
+ }
+
+ return nil, ErrNoNameservers
+}
+
+func (r *Resolver) queryServers(
+ ctx context.Context,
+ servers []string,
+ name string,
+ qtype uint16,
+) (*dns.Msg, error) {
+ var lastErr error
+
+ for _, ip := range servers {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err := r.queryDNS(ctx, ip, name, qtype)
+ if err == nil {
+ return resp, nil
+ }
+
+ lastErr = err
+ }
+
+ return nil, fmt.Errorf("all servers failed: %w", lastErr)
+}
+
+func (r *Resolver) resolveNSIPs(
+ ctx context.Context,
+ nsNames []string,
+) []string {
+ var ips []string
+
+ for _, ns := range nsNames {
+ resolved, err := r.resolveARecord(ctx, ns)
+ if err == nil {
+ ips = append(ips, resolved...)
+ }
+
+ if len(ips) > 0 {
+ break
+ }
+ }
+
+ return ips
+}
+
+// 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) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ domain = dns.Fqdn(domain)
+ servers := rootServerList()
+
+ for range maxDelegation {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err := r.queryServers(
+ ctx, servers, domain, dns.TypeNS,
+ )
+ if err != nil {
+ 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 using
+// iterative resolution through the delegation chain.
+func (r *Resolver) resolveARecord(
+ ctx context.Context,
+ hostname string,
+) ([]string, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ hostname = dns.Fqdn(hostname)
+ servers := rootServerList()
+
+ for range maxDelegation {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err := r.queryServers(
+ ctx, servers, hostname, dns.TypeA,
+ )
+ if err != nil {
+ return nil, fmt.Errorf(
+ "resolving %s: %w", hostname, err,
+ )
+ }
+
+ // Check for A records in the answer section.
+ var ips []string
+
+ for _, rr := range resp.Answer {
+ if a, ok := rr.(*dns.A); ok {
+ ips = append(ips, a.A.String())
+ }
+ }
+
+ 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(
+ "cannot resolve %s: %w", hostname, ErrNoNameservers,
+ )
+}
+
+// FindAuthoritativeNameservers traces the delegation chain from
+// root servers to discover all authoritative nameservers for the
+// given domain. Walks up the label hierarchy for subdomains.
+func (r *Resolver) FindAuthoritativeNameservers(
+ ctx context.Context,
+ domain string,
+) ([]string, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ domain = dns.Fqdn(strings.ToLower(domain))
+ labels := dns.SplitDomainName(domain)
+
+ for i := range labels {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ candidate := strings.Join(labels[i:], ".") + "."
+
+ nsNames, err := r.followDelegation(
+ ctx, candidate, rootServerList(),
+ )
+ if err == nil && len(nsNames) > 0 {
+ sort.Strings(nsNames)
+
+ return nsNames, nil
+ }
+ }
+
+ return nil, ErrNoNameservers
+}
+
+// QueryNameserver queries a specific nameserver for all record
+// types and builds a NameserverResponse.
+func (r *Resolver) QueryNameserver(
+ ctx context.Context,
+ nsHostname string,
+ hostname string,
+) (*NameserverResponse, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ nsIPs, err := r.resolveARecord(ctx, nsHostname)
+ if err != nil {
+ return nil, fmt.Errorf("resolving NS %s: %w", nsHostname, err)
+ }
+
+ hostname = dns.Fqdn(hostname)
+
+ 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,
+ nsIP string,
+ hostname string,
+) (*NameserverResponse, error) {
+ resp := &NameserverResponse{
+ Nameserver: nsHostname,
+ Records: make(map[string][]string),
+ Status: StatusOK,
+ }
+
+ qtypes := []uint16{
+ dns.TypeA, dns.TypeAAAA, dns.TypeCNAME,
+ dns.TypeMX, dns.TypeTXT, dns.TypeSRV,
+ dns.TypeCAA, dns.TypeNS,
+ }
+
+ state := r.queryEachType(ctx, nsIP, hostname, qtypes, resp)
+ classifyResponse(resp, state)
+
+ return resp, nil
+}
+
+type queryState struct {
+ gotNXDomain bool
+ gotSERVFAIL bool
+ gotTimeout bool
+ hasRecords bool
+}
+
+func (r *Resolver) queryEachType(
+ ctx context.Context,
+ nsIP string,
+ hostname string,
+ qtypes []uint16,
+ resp *NameserverResponse,
+) queryState {
+ var state queryState
+
+ for _, qtype := range qtypes {
+ if checkCtx(ctx) != nil {
+ break
+ }
+
+ r.querySingleType(ctx, nsIP, hostname, qtype, resp, &state)
+ }
+
+ for k := range resp.Records {
+ sort.Strings(resp.Records[k])
+ }
+
+ return state
+}
+
+func (r *Resolver) querySingleType(
+ ctx context.Context,
+ nsIP string,
+ hostname string,
+ qtype uint16,
+ resp *NameserverResponse,
+ state *queryState,
+) {
+ msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
+ if err != nil {
+ if isTimeout(err) {
+ state.gotTimeout = true
+ }
+
+ return
+ }
+
+ if msg.Rcode == dns.RcodeNameError {
+ state.gotNXDomain = true
+
+ return
+ }
+
+ if msg.Rcode == dns.RcodeServerFailure {
+ state.gotSERVFAIL = true
+
+ return
+ }
+
+ collectAnswerRecords(msg, resp, state)
+}
+
+func collectAnswerRecords(
+ msg *dns.Msg,
+ resp *NameserverResponse,
+ state *queryState,
+) {
+ for _, rr := range msg.Answer {
+ val := extractRecordValue(rr)
+ if val == "" {
+ continue
+ }
+
+ typeName := dns.TypeToString[rr.Header().Rrtype]
+ resp.Records[typeName] = append(
+ resp.Records[typeName], val,
+ )
+ state.hasRecords = true
+ }
+}
+
+// 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
+ }
+}
+
+// extractRecordValue formats a DNS RR value as a string.
+func extractRecordValue(rr dns.RR) string {
+ switch r := rr.(type) {
+ case *dns.A:
+ return r.A.String()
+ case *dns.AAAA:
+ return r.AAAA.String()
+ case *dns.CNAME:
+ return r.Target
+ case *dns.MX:
+ return fmt.Sprintf("%d %s", r.Preference, r.Mx)
+ case *dns.TXT:
+ return strings.Join(r.Txt, "")
+ case *dns.SRV:
+ return fmt.Sprintf(
+ "%d %d %d %s",
+ r.Priority, r.Weight, r.Port, r.Target,
+ )
+ case *dns.CAA:
+ return fmt.Sprintf(
+ "%d %s \"%s\"", r.Flag, r.Tag, r.Value,
+ )
+ case *dns.NS:
+ return r.Ns
+ default:
+ return ""
+ }
+}
+
+// parentDomain returns the registerable parent domain.
+func parentDomain(hostname string) string {
+ hostname = dns.Fqdn(strings.ToLower(hostname))
+ labels := dns.SplitDomainName(hostname)
+
+ if len(labels) <= minDomainLabels {
+ return strings.Join(labels, ".") + "."
+ }
+
+ return strings.Join(
+ labels[len(labels)-minDomainLabels:], ".",
+ ) + "."
+}
+
+// QueryAllNameservers discovers auth NSes for the hostname's
+// parent domain, then queries each one independently.
+func (r *Resolver) QueryAllNameservers(
+ ctx context.Context,
+ hostname string,
+) (map[string]*NameserverResponse, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ parent := parentDomain(hostname)
+
+ nameservers, err := r.FindAuthoritativeNameservers(ctx, parent)
+ if err != nil {
+ return nil, err
+ }
+
+ return r.queryEachNS(ctx, nameservers, hostname)
+}
+
+func (r *Resolver) queryEachNS(
+ ctx context.Context,
+ nameservers []string,
+ hostname string,
+) (map[string]*NameserverResponse, error) {
+ results := make(map[string]*NameserverResponse)
+
+ for _, ns := range nameservers {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ resp, err := r.QueryNameserver(ctx, ns, hostname)
+ if err != nil {
+ results[ns] = &NameserverResponse{
+ Nameserver: ns,
+ Records: make(map[string][]string),
+ Status: StatusError,
+ Error: err.Error(),
+ }
+
+ continue
+ }
+
+ results[ns] = resp
+ }
+
+ return results, nil
+}
+
+// LookupNS returns the NS record set for a domain.
+func (r *Resolver) LookupNS(
+ ctx context.Context,
+ domain string,
+) ([]string, error) {
+ return r.FindAuthoritativeNameservers(ctx, domain)
+}
+
+// LookupAllRecords performs iterative resolution to find all DNS
+// records for the given hostname, keyed by authoritative nameserver.
+func (r *Resolver) LookupAllRecords(
+ ctx context.Context,
+ hostname string,
+) (map[string]map[string][]string, error) {
+ results, err := r.QueryAllNameservers(ctx, hostname)
+ if err != nil {
+ return nil, err
+ }
+
+ out := make(map[string]map[string][]string, len(results))
+ for ns, resp := range results {
+ out[ns] = resp.Records
+ }
+
+ return out, nil
+}
+
+// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
+// addresses, following CNAME chains up to MaxCNAMEDepth.
+func (r *Resolver) ResolveIPAddresses(
+ ctx context.Context,
+ hostname string,
+) ([]string, error) {
+ if checkCtx(ctx) != nil {
+ return nil, ErrContextCanceled
+ }
+
+ return r.resolveIPWithCNAME(ctx, hostname, 0)
+}
+
+func (r *Resolver) resolveIPWithCNAME(
+ ctx context.Context,
+ hostname string,
+ depth int,
+) ([]string, error) {
+ if depth > MaxCNAMEDepth {
+ return nil, ErrCNAMEDepthExceeded
+ }
+
+ results, err := r.QueryAllNameservers(ctx, hostname)
+ if err != nil {
+ return nil, err
+ }
+
+ ips, cnameTarget := collectIPs(results)
+
+ if len(ips) == 0 && cnameTarget != "" {
+ return r.resolveIPWithCNAME(ctx, cnameTarget, depth+1)
+ }
+
+ sort.Strings(ips)
+
+ return ips, nil
+}
+
+func collectIPs(
+ results map[string]*NameserverResponse,
+) ([]string, string) {
+ seen := make(map[string]bool)
+
+ var ips []string
+
+ var cnameTarget string
+
+ for _, resp := range results {
+ if resp.Status == StatusNXDomain {
+ continue
+ }
+
+ for _, ip := range resp.Records["A"] {
+ if !seen[ip] {
+ seen[ip] = true
+ ips = append(ips, ip)
+ }
+ }
+
+ for _, ip := range resp.Records["AAAA"] {
+ if !seen[ip] {
+ seen[ip] = true
+ ips = append(ips, ip)
+ }
+ }
+
+ if len(resp.Records["CNAME"]) > 0 && cnameTarget == "" {
+ cnameTarget = resp.Records["CNAME"][0]
+ }
+ }
+
+ return ips, cnameTarget
+}
diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go
index b155782..aec9b89 100644
--- a/internal/resolver/resolver.go
+++ b/internal/resolver/resolver.go
@@ -4,7 +4,6 @@
package resolver
import (
- "context"
"log/slog"
"go.uber.org/fx"
@@ -18,6 +17,7 @@ const (
StatusError = "error"
StatusNXDomain = "nxdomain"
StatusNoData = "nodata"
+ StatusTimeout = "timeout"
)
// MaxCNAMEDepth is the maximum CNAME chain depth to follow.
@@ -40,7 +40,9 @@ type NameserverResponse struct {
// Resolver performs iterative DNS resolution from root servers.
type Resolver struct {
- log *slog.Logger
+ log *slog.Logger
+ client DNSClient
+ tcp DNSClient
}
// New creates a new Resolver instance for use with uber/fx.
@@ -49,66 +51,33 @@ func New(
params Params,
) (*Resolver, error) {
return &Resolver{
- log: params.Logger.Get(),
+ log: params.Logger.Get(),
+ client: &udpClient{timeout: queryTimeoutDuration},
+ tcp: &tcpClient{timeout: queryTimeoutDuration},
}, nil
}
// NewFromLogger creates a Resolver directly from an slog.Logger,
// useful for testing without the fx lifecycle.
func NewFromLogger(log *slog.Logger) *Resolver {
- return &Resolver{log: log}
+ return &Resolver{
+ log: log,
+ client: &udpClient{timeout: queryTimeoutDuration},
+ tcp: &tcpClient{timeout: queryTimeoutDuration},
+ }
}
-// FindAuthoritativeNameservers traces the delegation chain from
-// root servers to discover all authoritative nameservers for the
-// given domain. Returns the NS hostnames (e.g. ["ns1.example.com.",
-// "ns2.example.com."]).
-func (r *Resolver) FindAuthoritativeNameservers(
- _ context.Context,
- _ string,
-) ([]string, error) {
- return nil, ErrNotImplemented
+// NewFromLoggerWithClient creates a Resolver with a custom DNS
+// client, useful for testing with mock DNS responses.
+func NewFromLoggerWithClient(
+ log *slog.Logger,
+ client DNSClient,
+) *Resolver {
+ return &Resolver{
+ log: log,
+ client: client,
+ tcp: client,
+ }
}
-// QueryNameserver queries a specific authoritative nameserver for
-// all supported record types (A, AAAA, CNAME, MX, TXT, SRV, CAA,
-// NS) for the given hostname. Returns a NameserverResponse with
-// per-type record slices and a status indicating success or the
-// type of failure.
-func (r *Resolver) QueryNameserver(
- _ context.Context,
- _ string,
- _ string,
-) (*NameserverResponse, error) {
- return nil, ErrNotImplemented
-}
-
-// QueryAllNameservers discovers the authoritative nameservers for
-// the hostname's parent domain, then queries each one independently.
-// Returns a map from nameserver hostname to its response.
-func (r *Resolver) QueryAllNameservers(
- _ context.Context,
- _ string,
-) (map[string]*NameserverResponse, error) {
- return nil, ErrNotImplemented
-}
-
-// LookupNS returns the NS record set for a domain by performing
-// iterative resolution. This is used for domain (apex) monitoring.
-func (r *Resolver) LookupNS(
- _ context.Context,
- _ string,
-) ([]string, error) {
- return nil, ErrNotImplemented
-}
-
-// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
-// addresses by querying all authoritative nameservers and following
-// CNAME chains up to MaxCNAMEDepth. Returns a deduplicated list
-// of IP address strings.
-func (r *Resolver) ResolveIPAddresses(
- _ context.Context,
- _ string,
-) ([]string, error) {
- return nil, ErrNotImplemented
-}
+// Method implementations are in iterative.go.
diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go
index c7c2c99..e68b367 100644
--- a/internal/resolver/resolver_test.go
+++ b/internal/resolver/resolver_test.go
@@ -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 (
@@ -16,43 +23,41 @@ import (
"sneak.berlin/go/dnswatcher/internal/resolver"
)
-// Test domain and hostnames hosted on Cloudflare.
-// These records must exist in the sneak.cloud Cloudflare zone:
+// ----------------------------------------------------------------
+// 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
+// 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
-// mx.dns.sneak.cloud MX 10 mail.dns.sneak.cloud
-// mail.dns A 192.0.2.10
-// txt.dns.sneak.cloud TXT "v=spf1 -all"
+// 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"
- testHostCNAMETarget = "cname-target.dns.sneak.cloud"
- testHostMX = "mx.dns.sneak.cloud"
- testHostMail = "mail.dns.sneak.cloud"
- testHostTXT = "txt.dns.sneak.cloud"
- testHostNXDomain = "nxdomain-surely-does-not-exist.dns.sneak.cloud"
+ 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"
)
-// queryTimeout is the default timeout for test queries.
-const queryTimeout = 30 * time.Second
+// ----------------------------------------------------------------
+// Test helpers
+// ----------------------------------------------------------------
func newTestResolver(t *testing.T) *resolver.Resolver {
t.Helper()
- log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
- Level: slog.LevelDebug,
- }))
+ log := slog.New(slog.NewTextHandler(
+ os.Stderr,
+ &slog.HandlerOptions{Level: slog.LevelDebug},
+ ))
return resolver.NewFromLogger(log)
}
@@ -61,14 +66,33 @@ func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithTimeout(
- context.Background(), queryTimeout,
+ context.Background(), 60*time.Second,
)
t.Cleanup(cancel)
return ctx
}
-// --- FindAuthoritativeNameservers tests ---
+func findOneNSForDomain(
+ t *testing.T,
+ r *resolver.Resolver,
+ ctx context.Context, //nolint:revive // test helper
+ domain string,
+) string {
+ t.Helper()
+
+ nameservers, err := r.FindAuthoritativeNameservers(
+ ctx, domain,
+ )
+ require.NoError(t, err)
+ require.NotEmpty(t, nameservers)
+
+ return nameservers[0]
+}
+
+// ----------------------------------------------------------------
+// FindAuthoritativeNameservers tests
+// ----------------------------------------------------------------
func TestFindAuthoritativeNameservers_ValidDomain(
t *testing.T,
@@ -79,36 +103,23 @@ func TestFindAuthoritativeNameservers_ValidDomain(
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
+ ctx, "google.com",
)
require.NoError(t, err)
- require.NotEmpty(t, nameservers, "should find at least one NS")
+ require.NotEmpty(t, nameservers)
- // sneak.cloud is on Cloudflare, NS should contain cloudflare
- for _, ns := range nameservers {
- t.Logf("discovered NS: %s", ns)
- assert.True(
- t,
- strings.HasSuffix(ns, "."),
- "NS should be FQDN with trailing dot: %s", ns,
- )
- }
-
- // Verify at least one is a Cloudflare NS
- hasCloudflare := false
+ hasGoogleNS := false
for _, ns := range nameservers {
- if strings.Contains(ns, "cloudflare") {
- hasCloudflare = true
+ if strings.Contains(ns, "google") {
+ hasGoogleNS = true
break
}
}
- assert.True(
- t, hasCloudflare,
- "sneak.cloud should be hosted on Cloudflare, got: %v",
- nameservers,
+ assert.True(t, hasGoogleNS,
+ "expected google nameservers, got: %v", nameservers,
)
}
@@ -120,26 +131,11 @@ func TestFindAuthoritativeNameservers_Subdomain(
r := newTestResolver(t)
ctx := testContext(t)
- // Looking up NS for a hostname that isn't a zone should
- // return the parent zone's NS records.
nameservers, err := r.FindAuthoritativeNameservers(
- ctx, testHostBasic,
+ ctx, "www.google.com",
)
require.NoError(t, err)
require.NotEmpty(t, nameservers)
-
- // Should be the same Cloudflare NSes as the parent domain
- hasCloudflare := false
-
- for _, ns := range nameservers {
- if strings.Contains(ns, "cloudflare") {
- hasCloudflare = true
-
- break
- }
- }
-
- assert.True(t, hasCloudflare)
}
func TestFindAuthoritativeNameservers_TLD(t *testing.T) {
@@ -149,14 +145,26 @@ func TestFindAuthoritativeNameservers_TLD(t *testing.T) {
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
- ctx, "cloud",
+ ctx, "com",
)
require.NoError(t, err)
- require.NotEmpty(t, nameservers, "should find TLD nameservers")
+ require.NotEmpty(t, nameservers,
+ "should find TLD nameservers",
+ )
+
+ hasGTLD := false
for _, ns := range nameservers {
- t.Logf("TLD NS: %s", ns)
+ 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(
@@ -168,12 +176,10 @@ func TestFindAuthoritativeNameservers_ReturnsSorted(
ctx := testContext(t)
nameservers, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
+ ctx, "google.com",
)
require.NoError(t, err)
- require.NotEmpty(t, nameservers)
- // Results should be sorted for deterministic comparison
assert.True(
t,
sort.StringsAreSorted(nameservers),
@@ -190,45 +196,146 @@ func TestFindAuthoritativeNameservers_Deterministic(
ctx := testContext(t)
first, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
+ ctx, "google.com",
)
require.NoError(t, err)
second, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
+ ctx, "google.com",
)
require.NoError(t, err)
- assert.Equal(
- t, first, second,
- "repeated lookups should return same result",
- )
+ assert.Equal(t, first, second)
}
-// --- QueryNameserver tests ---
+func TestFindAuthoritativeNameservers_TrailingDot(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ns1, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+
+ ns2, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com.",
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, ns1, ns2)
+}
+
+func TestFindAuthoritativeNameservers_CloudflareDomain(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.FindAuthoritativeNameservers(
+ ctx, "cloudflare.com",
+ )
+ require.NoError(t, err)
+ require.NotEmpty(t, nameservers)
+
+ for _, ns := range nameservers {
+ assert.True(t, strings.HasSuffix(ns, "."),
+ "NS should be FQDN with trailing dot: %s", ns,
+ )
+ }
+}
+
+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
+// ----------------------------------------------------------------
func TestQueryNameserver_BasicA(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostBasic)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "www.google.com",
+ )
require.NoError(t, err)
require.NotNil(t, resp)
assert.Equal(t, resolver.StatusOK, resp.Status)
assert.Equal(t, ns, resp.Nameserver)
- aRecords := resp.Records["A"]
- require.NotEmpty(t, aRecords, "basic.dns should have A records")
- assert.Contains(t, aRecords, "192.0.2.1")
-
- t.Logf(
- "QueryNameserver(%s, %s) A records: %v",
- ns, testHostBasic, aRecords,
+ hasRecords := len(resp.Records["A"]) > 0 ||
+ len(resp.Records["CNAME"]) > 0
+ assert.True(t, hasRecords,
+ "expected A or CNAME records for www.google.com",
)
}
@@ -237,8 +344,7 @@ func TestQueryNameserver_MultipleA(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
-
- ns := findOneNS(t, r, ctx)
+ ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostMultiA)
require.NoError(t, err)
@@ -260,20 +366,24 @@ func TestQueryNameserver_AAAA(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostIPv6)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "cloudflare.com",
+ )
require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, resolver.StatusOK, resp.Status)
aaaaRecords := resp.Records["AAAA"]
- require.NotEmpty(
- t, aaaaRecords,
- "ipv6.dns should have AAAA records",
+ require.NotEmpty(t, aaaaRecords,
+ "cloudflare.com should have AAAA records",
)
- assert.Contains(t, aaaaRecords, "2001:db8::1")
+
+ for _, ip := range aaaaRecords {
+ parsed := net.ParseIP(ip)
+ require.NotNil(t, parsed,
+ "should be valid IP: %s", ip,
+ )
+ }
}
func TestQueryNameserver_DualStack(t *testing.T) {
@@ -281,8 +391,7 @@ func TestQueryNameserver_DualStack(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
-
- ns := findOneNS(t, r, ctx)
+ ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostDualStack)
require.NoError(t, err)
@@ -298,8 +407,7 @@ func TestQueryNameserver_CNAME(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
-
- ns := findOneNS(t, r, ctx)
+ ns := findOneNSForDomain(t, r, ctx, testDomain)
resp, err := r.QueryNameserver(ctx, ns, testHostCNAME)
require.NoError(t, err)
@@ -321,35 +429,16 @@ func TestQueryNameserver_MX(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostMX)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, resolver.StatusOK, resp.Status)
mxRecords := resp.Records["MX"]
- require.NotEmpty(
- t, mxRecords,
- "mx.dns should have MX records",
- )
-
- // MX records are formatted as "priority host"
- hasMail := false
-
- for _, mx := range mxRecords {
- if strings.Contains(mx, "mail.dns.sneak.cloud.") {
- hasMail = true
-
- break
- }
- }
-
- assert.True(
- t, hasMail,
- "MX should reference mail.dns.sneak.cloud, got: %v",
- mxRecords,
+ require.NotEmpty(t, mxRecords,
+ "google.com should have MX records",
)
}
@@ -358,18 +447,16 @@ func TestQueryNameserver_TXT(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostTXT)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
require.NoError(t, err)
- require.NotNil(t, resp)
- assert.Equal(t, resolver.StatusOK, resp.Status)
txtRecords := resp.Records["TXT"]
- require.NotEmpty(
- t, txtRecords,
- "txt.dns should have TXT records",
+ require.NotEmpty(t, txtRecords,
+ "google.com should have TXT records",
)
hasSPF := false
@@ -382,9 +469,8 @@ func TestQueryNameserver_TXT(t *testing.T) {
}
}
- assert.True(
- t, hasSPF,
- "TXT should contain SPF record, got: %v", txtRecords,
+ assert.True(t, hasSPF,
+ "google.com should have SPF TXT record",
)
}
@@ -393,17 +479,15 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain)
- require.NoError(t, err)
- require.NotNil(t, resp)
-
- assert.Equal(
- t, resolver.StatusNXDomain, resp.Status,
- "nonexistent host should return nxdomain status",
+ resp, err := r.QueryNameserver(
+ ctx, ns,
+ "this-surely-does-not-exist-xyz.google.com",
)
+ require.NoError(t, err)
+
+ assert.Equal(t, resolver.StatusNXDomain, resp.Status)
}
func TestQueryNameserver_RecordsSorted(t *testing.T) {
@@ -411,19 +495,18 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostMultiA)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
require.NoError(t, err)
- // Each record type's values should be sorted for determinism
for recordType, values := range resp.Records {
assert.True(
t,
sort.StringsAreSorted(values),
- "%s records should be sorted, got: %v",
- recordType, values,
+ "%s records should be sorted", recordType,
)
}
}
@@ -435,29 +518,29 @@ func TestQueryNameserver_ResponseIncludesNameserver(
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostBasic)
+ resp, err := r.QueryNameserver(
+ ctx, ns, "cloudflare.com",
+ )
require.NoError(t, err)
- assert.Equal(
- t, ns, resp.Nameserver,
- "response should include the queried nameserver",
- )
+ assert.Equal(t, ns, resp.Nameserver)
}
-func TestQueryNameserver_EmptyRecordsMapOnNXDomain(
+func TestQueryNameserver_EmptyRecordsOnNXDomain(
t *testing.T,
) {
t.Parallel()
r := newTestResolver(t)
ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
- ns := findOneNS(t, r, ctx)
-
- resp, err := r.QueryNameserver(ctx, ns, testHostNXDomain)
+ resp, err := r.QueryNameserver(
+ ctx, ns,
+ "this-surely-does-not-exist-xyz.google.com",
+ )
require.NoError(t, err)
totalRecords := 0
@@ -465,14 +548,142 @@ func TestQueryNameserver_EmptyRecordsMapOnNXDomain(
totalRecords += len(values)
}
- assert.Zero(
- t, totalRecords,
- "NXDOMAIN should have no records, got: %v",
- resp.Records,
+ assert.Zero(t, totalRecords)
+}
+
+func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ resp1, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
+ require.NoError(t, err)
+
+ resp2, err := r.QueryNameserver(
+ ctx, ns, "google.com.",
+ )
+ require.NoError(t, err)
+
+ 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,
)
}
-// --- QueryAllNameservers tests ---
+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
+// ----------------------------------------------------------------
func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
t.Parallel()
@@ -480,26 +691,36 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- results, err := r.QueryAllNameservers(ctx, testHostBasic)
+ results, err := r.QueryAllNameservers(
+ ctx, "google.com",
+ )
require.NoError(t, err)
require.NotEmpty(t, results)
- // Should have queried each NS independently
- t.Logf(
- "QueryAllNameservers returned %d nameserver results",
- len(results),
- )
+ assert.GreaterOrEqual(t, len(results), 2)
for ns, resp := range results {
- t.Logf(" %s: status=%s A=%v", ns, resp.Status, resp.Records["A"])
assert.Equal(t, ns, resp.Nameserver)
}
+}
- // Should have more than one NS for Cloudflare-hosted domain
- assert.GreaterOrEqual(
- t, len(results), 2,
- "should query at least 2 nameservers",
+func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ results, err := r.QueryAllNameservers(
+ ctx, "google.com",
)
+ require.NoError(t, err)
+
+ for ns, resp := range results {
+ assert.Equal(
+ t, resolver.StatusOK, resp.Status,
+ "NS %s should return OK", ns,
+ )
+ }
}
func TestQueryAllNameservers_Consistent(t *testing.T) {
@@ -544,21 +765,22 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
ctx := testContext(t)
results, err := r.QueryAllNameservers(
- ctx, testHostNXDomain,
+ ctx,
+ "this-surely-does-not-exist-xyz.google.com",
)
require.NoError(t, err)
- require.NotEmpty(t, results)
for ns, resp := range results {
assert.Equal(
t, resolver.StatusNXDomain, resp.Status,
- "NS %s should return nxdomain for nonexistent host",
- ns,
+ "NS %s should return nxdomain", ns,
)
}
}
-// --- LookupNS tests ---
+// ----------------------------------------------------------------
+// LookupNS tests
+// ----------------------------------------------------------------
func TestLookupNS_ValidDomain(t *testing.T) {
t.Parallel()
@@ -566,16 +788,13 @@ func TestLookupNS_ValidDomain(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- nameservers, err := r.LookupNS(ctx, testDomain)
+ nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
require.NotEmpty(t, nameservers)
for _, ns := range nameservers {
- t.Logf("NS record: %s", ns)
- assert.True(
- t,
- strings.HasSuffix(ns, "."),
- "NS should be FQDN: %s", ns,
+ assert.True(t, strings.HasSuffix(ns, "."),
+ "NS should have trailing dot: %s", ns,
)
}
}
@@ -586,14 +805,10 @@ func TestLookupNS_Sorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- nameservers, err := r.LookupNS(ctx, testDomain)
+ nameservers, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
- assert.True(
- t,
- sort.StringsAreSorted(nameservers),
- "NS records should be sorted, got: %v", nameservers,
- )
+ assert.True(t, sort.StringsAreSorted(nameservers))
}
func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
@@ -602,23 +817,38 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- fromLookup, err := r.LookupNS(ctx, testDomain)
+ fromLookup, err := r.LookupNS(ctx, "google.com")
require.NoError(t, err)
fromFind, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
+ ctx, "google.com",
)
require.NoError(t, err)
- // Both methods should return the same NS set
- assert.Equal(
- t, fromFind, fromLookup,
- "LookupNS and FindAuthoritativeNameservers "+
- "should return the same set",
- )
+ assert.Equal(t, fromFind, fromLookup)
}
-// --- ResolveIPAddresses tests ---
+// ----------------------------------------------------------------
+// ResolveIPAddresses tests
+// ----------------------------------------------------------------
+
+func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ips, err := r.ResolveIPAddresses(ctx, "google.com")
+ require.NoError(t, err)
+ require.NotEmpty(t, ips)
+
+ for _, ip := range ips {
+ parsed := net.ParseIP(ip)
+ assert.NotNil(t, parsed,
+ "should be valid IP: %s", ip,
+ )
+ }
+}
func TestResolveIPAddresses_BasicA(t *testing.T) {
t.Parallel()
@@ -687,14 +917,11 @@ func TestResolveIPAddresses_FollowsCNAME(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- // cname.dns.sneak.cloud -> cname-target.dns.sneak.cloud -> 198.51.100.1
- ips, err := r.ResolveIPAddresses(ctx, testHostCNAME)
+ // 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)
- assert.Contains(
- t, ips, "198.51.100.1",
- "should follow CNAME to resolve target IP",
- )
}
func TestResolveIPAddresses_Deduplicated(t *testing.T) {
@@ -703,17 +930,13 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- ips, err := r.ResolveIPAddresses(ctx, testHostBasic)
+ ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
- // Check for duplicates
seen := make(map[string]bool)
for _, ip := range ips {
- assert.False(
- t, seen[ip],
- "IP %s appears more than once", ip,
- )
+ assert.False(t, seen[ip], "duplicate IP: %s", ip)
seen[ip] = true
}
}
@@ -724,14 +947,10 @@ func TestResolveIPAddresses_Sorted(t *testing.T) {
r := newTestResolver(t)
ctx := testContext(t)
- ips, err := r.ResolveIPAddresses(ctx, testHostDualStack)
+ ips, err := r.ResolveIPAddresses(ctx, "google.com")
require.NoError(t, err)
- assert.True(
- t,
- sort.StringsAreSorted(ips),
- "IP addresses should be sorted, got: %v", ips,
- )
+ assert.True(t, sort.StringsAreSorted(ips))
}
func TestResolveIPAddresses_NXDomainReturnsEmpty(
@@ -742,14 +961,28 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty(
r := newTestResolver(t)
ctx := testContext(t)
- ips, err := r.ResolveIPAddresses(ctx, testHostNXDomain)
- // Should not error — NXDOMAIN is an expected DNS response.
- // It just means no IPs to return.
+ ips, err := r.ResolveIPAddresses(
+ ctx,
+ "this-surely-does-not-exist-xyz.google.com",
+ )
require.NoError(t, err)
assert.Empty(t, ips)
}
-// --- Context cancellation tests ---
+func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com")
+ require.NoError(t, err)
+ require.NotEmpty(t, ips)
+}
+
+// ----------------------------------------------------------------
+// Context cancellation tests
+// ----------------------------------------------------------------
func TestFindAuthoritativeNameservers_ContextCanceled(
t *testing.T,
@@ -757,11 +990,10 @@ func TestFindAuthoritativeNameservers_ContextCanceled(
t.Parallel()
r := newTestResolver(t)
-
ctx, cancel := context.WithCancel(context.Background())
- cancel() // Cancel immediately
+ cancel()
- _, err := r.FindAuthoritativeNameservers(ctx, testDomain)
+ _, err := r.FindAuthoritativeNameservers(ctx, "google.com")
assert.Error(t, err)
}
@@ -769,12 +1001,11 @@ func TestQueryNameserver_ContextCanceled(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
-
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.QueryNameserver(
- ctx, "ns1.example.com.", testHostBasic,
+ ctx, "ns1.google.com.", "google.com",
)
assert.Error(t, err)
}
@@ -783,11 +1014,10 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
-
ctx, cancel := context.WithCancel(context.Background())
cancel()
- _, err := r.QueryAllNameservers(ctx, testHostBasic)
+ _, err := r.QueryAllNameservers(ctx, "google.com")
assert.Error(t, err)
}
@@ -795,238 +1025,9 @@ func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
-
ctx, cancel := context.WithCancel(context.Background())
cancel()
- _, err := r.ResolveIPAddresses(ctx, testHostBasic)
+ _, err := r.ResolveIPAddresses(ctx, "google.com")
assert.Error(t, err)
}
-
-// --- Iterative resolution verification ---
-
-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)
-}
-
-// --- NS record and delegation tests ---
-
-func TestQueryNameserver_NSRecordsForZoneApex(
- t *testing.T,
-) {
- t.Parallel()
-
- r := newTestResolver(t)
- ctx := testContext(t)
-
- ns := findOneNS(t, r, ctx)
-
- // Querying a zone apex should return NS records
- resp, err := r.QueryNameserver(ctx, ns, testDomain)
- 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",
- )
-
- // Should contain Cloudflare nameservers
- hasCloudflare := false
-
- for _, nsRecord := range nsRecords {
- t.Logf("NS record: %s", nsRecord)
-
- if strings.Contains(nsRecord, "cloudflare") {
- hasCloudflare = true
- }
- }
-
- 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 := findOneNS(t, r, ctx)
-
- // A leaf hostname (not a zone cut) should not have NS records
- resp, err := r.QueryNameserver(ctx, ns, testHostBasic)
- require.NoError(t, err)
- require.NotNil(t, resp)
-
- nsRecords := resp.Records["NS"]
- assert.Empty(
- t, nsRecords,
- "leaf hostname should not have NS records, got: %v",
- nsRecords,
- )
-}
-
-func TestFindAuthoritativeNameservers_AllNSRespondAuthoritatively(
- t *testing.T,
-) {
- // Every NS returned by FindAuthoritativeNameservers should
- // actually serve the zone. A lame NS would return REFUSED,
- // SERVFAIL, or a non-authoritative answer. This test verifies
- // that each discovered NS can answer queries authoritatively.
- t.Parallel()
-
- r := newTestResolver(t)
- ctx := testContext(t)
-
- nameservers, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
- )
- require.NoError(t, err)
- require.NotEmpty(t, nameservers)
-
- for _, ns := range nameservers {
- resp, queryErr := r.QueryNameserver(
- ctx, ns, testHostBasic,
- )
- 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,
- )
- assert.NotEmpty(
- t, resp.Records["A"],
- "NS %s should return A records for %s",
- ns, testHostBasic,
- )
- }
-}
-
-func TestQueryNameserver_LameNameserver(t *testing.T) {
- // Querying a nameserver that does not serve a zone should
- // return a non-ok status (error or refused), not silently
- // return empty results with status "ok".
- t.Parallel()
-
- r := newTestResolver(t)
- ctx := testContext(t)
-
- // Use a known-good nameserver that does NOT serve sneak.cloud.
- // a.iana-servers.net serves example.com, not sneak.cloud.
- resp, err := r.QueryNameserver(
- ctx, "a.iana-servers.net.", testHostBasic,
- )
- 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,
- )
-}
-
-// --- Edge cases ---
-
-func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
- t.Parallel()
-
- r := newTestResolver(t)
- ctx := testContext(t)
-
- ns := findOneNS(t, r, ctx)
-
- // Both with and without trailing dot should work
- resp1, err := r.QueryNameserver(
- ctx, ns, "basic.dns.sneak.cloud",
- )
- require.NoError(t, err)
-
- resp2, err := r.QueryNameserver(
- ctx, ns, "basic.dns.sneak.cloud.",
- )
- require.NoError(t, err)
-
- assert.Equal(
- t, resp1.Records["A"], resp2.Records["A"],
- "trailing dot should not affect results",
- )
-}
-
-func TestFindAuthoritativeNameservers_TrailingDot(
- t *testing.T,
-) {
- t.Parallel()
-
- r := newTestResolver(t)
- ctx := testContext(t)
-
- ns1, err := r.FindAuthoritativeNameservers(
- ctx, "sneak.cloud",
- )
- require.NoError(t, err)
-
- ns2, err := r.FindAuthoritativeNameservers(
- ctx, "sneak.cloud.",
- )
- require.NoError(t, err)
-
- assert.Equal(
- t, ns1, ns2,
- "trailing dot should not affect NS lookup",
- )
-}
-
-// --- Helper functions ---
-
-// findOneNS discovers authoritative nameservers and returns the first
-// one, failing the test if none are found.
-func findOneNS(
- t *testing.T,
- r *resolver.Resolver,
- ctx context.Context, //nolint:revive // test helper
-) string {
- t.Helper()
-
- nameservers, err := r.FindAuthoritativeNameservers(
- ctx, testDomain,
- )
- require.NoError(t, err)
- require.NotEmpty(
- t, nameservers,
- "should find at least one NS for %s", testDomain,
- )
-
- return nameservers[0]
-}
diff --git a/internal/server/routes.go b/internal/server/routes.go
index cd07ba1..fa99177 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -1,11 +1,14 @@
package server
import (
+ "net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
+
+ "sneak.berlin/go/dnswatcher/static"
)
// requestTimeout is the maximum duration for handling a request.
@@ -22,7 +25,25 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
- // Health check
+ // Dashboard (read-only web UI)
+ s.router.Get("/", s.handlers.HandleDashboard())
+
+ // Static assets (embedded CSS/JS)
+ s.router.Mount(
+ "/s",
+ http.StripPrefix(
+ "/s",
+ http.FileServer(http.FS(static.Static)),
+ ),
+ )
+
+ // Health check (standard well-known path)
+ s.router.Get(
+ "/.well-known/healthcheck",
+ s.handlers.HandleHealthCheck(),
+ )
+
+ // Legacy health check (keep for backward compatibility)
s.router.Get("/health", s.handlers.HandleHealthCheck())
// API v1 routes
diff --git a/internal/state/state.go b/internal/state/state.go
index fca7276..efe681c 100644
--- a/internal/state/state.go
+++ b/internal/state/state.go
@@ -57,10 +57,49 @@ type HostnameState struct {
// PortState holds the monitoring state for a port.
type PortState struct {
Open bool `json:"open"`
- Hostname string `json:"hostname"`
+ Hostnames []string `json:"hostnames"`
LastChecked time.Time `json:"lastChecked"`
}
+// UnmarshalJSON implements custom unmarshaling to handle both
+// the old single-hostname format and the new multi-hostname
+// format for backward compatibility with existing state files.
+func (ps *PortState) UnmarshalJSON(data []byte) error {
+ // Use an alias to prevent infinite recursion.
+ type portStateAlias struct {
+ Open bool `json:"open"`
+ Hostnames []string `json:"hostnames"`
+ LastChecked time.Time `json:"lastChecked"`
+ }
+
+ var alias portStateAlias
+
+ err := json.Unmarshal(data, &alias)
+ if err != nil {
+ return fmt.Errorf("unmarshaling port state: %w", err)
+ }
+
+ ps.Open = alias.Open
+ ps.Hostnames = alias.Hostnames
+ ps.LastChecked = alias.LastChecked
+
+ // If Hostnames is empty, try reading the old single-hostname
+ // format for backward compatibility.
+ if len(ps.Hostnames) == 0 {
+ var old struct {
+ Hostname string `json:"hostname"`
+ }
+
+ // Best-effort: ignore errors since the main unmarshal
+ // already succeeded.
+ if json.Unmarshal(data, &old) == nil && old.Hostname != "" {
+ ps.Hostnames = []string{old.Hostname}
+ }
+ }
+
+ return nil
+}
+
// CertificateState holds TLS certificate monitoring state.
type CertificateState struct {
CommonName string `json:"commonName"`
@@ -156,8 +195,8 @@ func (s *State) Load() error {
// Save writes the current state to disk atomically.
func (s *State) Save() error {
- s.mu.RLock()
- defer s.mu.RUnlock()
+ s.mu.Lock()
+ defer s.mu.Unlock()
s.snapshot.LastUpdated = time.Now().UTC()
@@ -263,6 +302,27 @@ func (s *State) GetPortState(key string) (*PortState, bool) {
return ps, ok
}
+// DeletePortState removes a port state entry.
+func (s *State) DeletePortState(key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ delete(s.snapshot.Ports, key)
+}
+
+// GetAllPortKeys returns all port state keys.
+func (s *State) GetAllPortKeys() []string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ keys := make([]string, 0, len(s.snapshot.Ports))
+ for k := range s.snapshot.Ports {
+ keys = append(keys, k)
+ }
+
+ return keys
+}
+
// SetCertificateState updates the state for a certificate.
func (s *State) SetCertificateState(
key string,
diff --git a/internal/state/state_test.go b/internal/state/state_test.go
new file mode 100644
index 0000000..5e95eb9
--- /dev/null
+++ b/internal/state/state_test.go
@@ -0,0 +1,1302 @@
+package state_test
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/state"
+)
+
+const testHostname = "www.example.com"
+
+// populateState fills a State with representative test data across all categories.
+func populateState(t *testing.T, s *state.State) {
+ t.Helper()
+
+ now := time.Now().UTC().Truncate(time.Second)
+
+ s.SetDomainState("example.com", &state.DomainState{
+ Nameservers: []string{"ns1.example.com.", "ns2.example.com."},
+ LastChecked: now,
+ })
+
+ s.SetDomainState("example.org", &state.DomainState{
+ Nameservers: []string{"ns1.example.org."},
+ LastChecked: now,
+ })
+
+ s.SetHostnameState(testHostname, &state.HostnameState{
+ RecordsByNameserver: map[string]*state.NameserverRecordState{
+ "ns1.example.com.": {
+ Records: map[string][]string{
+ "A": {"93.184.216.34"},
+ "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
+ },
+ Status: "ok",
+ LastChecked: now,
+ },
+ "ns2.example.com.": {
+ Records: map[string][]string{
+ "A": {"93.184.216.34"},
+ },
+ Status: "ok",
+ LastChecked: now,
+ },
+ },
+ LastChecked: now,
+ })
+
+ s.SetPortState("93.184.216.34:80", &state.PortState{
+ Open: true,
+ Hostnames: []string{testHostname},
+ LastChecked: now,
+ })
+
+ s.SetPortState("93.184.216.34:443", &state.PortState{
+ Open: true,
+ Hostnames: []string{testHostname, "api.example.com"},
+ LastChecked: now,
+ })
+
+ s.SetCertificateState("93.184.216.34:443:www.example.com", &state.CertificateState{
+ CommonName: testHostname,
+ Issuer: "DigiCert TLS RSA SHA256 2020 CA1",
+ NotAfter: now.Add(365 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{testHostname, "example.com"},
+ Status: "ok",
+ LastChecked: now,
+ })
+}
+
+// TestSaveLoadRoundTrip_Domains verifies domain data survives a save/load cycle.
+func TestSaveLoadRoundTrip_Domains(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ snap := loaded.GetSnapshot()
+
+ if len(snap.Domains) != 2 {
+ t.Fatalf("expected 2 domains, got %d", len(snap.Domains))
+ }
+
+ dom, ok := snap.Domains["example.com"]
+ if !ok {
+ t.Fatal("missing domain example.com")
+ }
+
+ if len(dom.Nameservers) != 2 {
+ t.Errorf("expected 2 nameservers, got %d", len(dom.Nameservers))
+ }
+}
+
+// TestSaveLoadRoundTrip_Hostnames verifies hostname data survives a save/load cycle.
+func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ snap := loaded.GetSnapshot()
+
+ if len(snap.Hostnames) != 1 {
+ t.Fatalf("expected 1 hostname, got %d", len(snap.Hostnames))
+ }
+
+ hn, ok := snap.Hostnames[testHostname]
+ if !ok {
+ t.Fatal("missing hostname")
+ }
+
+ if len(hn.RecordsByNameserver) != 2 {
+ t.Errorf("expected 2 NS entries, got %d", len(hn.RecordsByNameserver))
+ }
+
+ verifyNS1Records(t, hn)
+}
+
+// verifyNS1Records checks the ns1.example.com. records in the loaded hostname state.
+func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
+ t.Helper()
+
+ 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] != "93.184.216.34" {
+ t.Errorf("ns1 A records: got %v", aRecords)
+ }
+
+ aaaaRecords := ns1.Records["AAAA"]
+ if len(aaaaRecords) != 1 || aaaaRecords[0] != "2606:2800:220:1:248:1893:25c8:1946" {
+ t.Errorf("ns1 AAAA records: got %v", aaaaRecords)
+ }
+
+ if ns1.Status != "ok" {
+ t.Errorf("ns1 status: got %q, want %q", ns1.Status, "ok")
+ }
+}
+
+// TestSaveLoadRoundTrip_Ports verifies port data survives a save/load cycle.
+func TestSaveLoadRoundTrip_Ports(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ snap := loaded.GetSnapshot()
+
+ if len(snap.Ports) != 2 {
+ t.Fatalf("expected 2 ports, got %d", len(snap.Ports))
+ }
+
+ port443, ok := snap.Ports["93.184.216.34:443"]
+ if !ok {
+ t.Fatal("missing port 93.184.216.34:443")
+ }
+
+ if !port443.Open {
+ t.Error("port 443: expected open")
+ }
+
+ if len(port443.Hostnames) != 2 {
+ t.Errorf("expected 2 hostnames, got %d", len(port443.Hostnames))
+ }
+}
+
+// TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle.
+func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ snap := loaded.GetSnapshot()
+
+ if len(snap.Certificates) != 1 {
+ t.Fatalf("expected 1 certificate, got %d", len(snap.Certificates))
+ }
+
+ cert, ok := snap.Certificates["93.184.216.34:443:www.example.com"]
+ if !ok {
+ t.Fatal("missing certificate")
+ }
+
+ if cert.CommonName != testHostname {
+ t.Errorf("cert CN: got %q", cert.CommonName)
+ }
+
+ if cert.Issuer != "DigiCert TLS RSA SHA256 2020 CA1" {
+ t.Errorf("cert issuer: got %q", cert.Issuer)
+ }
+
+ if len(cert.SubjectAlternativeNames) != 2 {
+ t.Errorf("cert SANs: expected 2, got %d", len(cert.SubjectAlternativeNames))
+ }
+
+ if cert.Status != "ok" {
+ t.Errorf("cert status: got %q", cert.Status)
+ }
+}
+
+// TestSaveCreatesDirectory verifies Save creates the data directory if missing.
+func TestSaveCreatesDirectory(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ nested := filepath.Join(dir, "nested", "deep")
+
+ s := state.NewForTestWithDataDir(nested)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ statePath := filepath.Join(nested, "state.json")
+
+ info, err := os.Stat(statePath)
+ if err != nil {
+ t.Fatalf("state file not found: %v", err)
+ }
+
+ if info.Size() == 0 {
+ t.Error("state file is empty")
+ }
+}
+
+// TestSaveAtomicWrite verifies the atomic write pattern (no leftover temp files).
+func TestSaveAtomicWrite(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ statePath := filepath.Join(dir, "state.json")
+ tmpPath := statePath + ".tmp"
+
+ // No .tmp file should remain after successful save.
+ _, err = os.Stat(tmpPath)
+ if !os.IsNotExist(err) {
+ t.Error("temp file should not exist after successful save")
+ }
+
+ // Modify state and save again; verify updated content.
+ s.SetDomainState("new.example.com", &state.DomainState{
+ Nameservers: []string{"ns1.new.example.com."},
+ LastChecked: time.Now().UTC(),
+ })
+
+ err = s.Save()
+ if err != nil {
+ t.Fatalf("second Save() error: %v", err)
+ }
+
+ _, err = os.Stat(tmpPath)
+ if !os.IsNotExist(err) {
+ t.Error("temp file should not exist after second save")
+ }
+
+ // Verify new domain is present by loading.
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ _, ok := loaded.GetDomainState("new.example.com")
+ if !ok {
+ t.Error("new domain not found after second save")
+ }
+}
+
+// TestLoadMissingFile verifies that loading a nonexistent file is not an error.
+func TestLoadMissingFile(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTestWithDataDir(t.TempDir())
+
+ err := s.Load()
+ if err != nil {
+ t.Fatalf("Load() with missing file should not error: %v", err)
+ }
+
+ snap := s.GetSnapshot()
+
+ if len(snap.Domains) != 0 {
+ t.Error("expected empty domains")
+ }
+
+ if len(snap.Hostnames) != 0 {
+ t.Error("expected empty hostnames")
+ }
+
+ if len(snap.Ports) != 0 {
+ t.Error("expected empty ports")
+ }
+
+ if len(snap.Certificates) != 0 {
+ t.Error("expected empty certificates")
+ }
+}
+
+// TestLoadCorruptFile verifies that a corrupt state file returns an error.
+func TestLoadCorruptFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+
+ err := os.WriteFile(
+ filepath.Join(dir, "state.json"),
+ []byte("not valid json{{{"),
+ 0o600,
+ )
+ if err != nil {
+ t.Fatalf("writing corrupt file: %v", err)
+ }
+
+ s := state.NewForTestWithDataDir(dir)
+
+ err = s.Load()
+ if err == nil {
+ t.Fatal("Load() should return error for corrupt file")
+ }
+}
+
+// TestLoadEmptyFile verifies that an empty state file returns an error.
+func TestLoadEmptyFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+
+ err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(""), 0o600)
+ if err != nil {
+ t.Fatalf("writing empty file: %v", err)
+ }
+
+ s := state.NewForTestWithDataDir(dir)
+
+ err = s.Load()
+ if err == nil {
+ t.Fatal("Load() should return error for empty file")
+ }
+}
+
+// TestLoadReadPermissionError verifies that an unreadable state file returns an error.
+func TestLoadReadPermissionError(t *testing.T) {
+ t.Parallel()
+
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test when running as root")
+ }
+
+ dir := t.TempDir()
+ statePath := filepath.Join(dir, "state.json")
+
+ err := os.WriteFile(statePath, []byte("{}"), 0o600)
+ if err != nil {
+ t.Fatalf("writing file: %v", err)
+ }
+
+ err = os.Chmod(statePath, 0o000)
+ if err != nil {
+ t.Fatalf("chmod: %v", err)
+ }
+
+ t.Cleanup(func() {
+ _ = os.Chmod(statePath, 0o600)
+ })
+
+ s := state.NewForTestWithDataDir(dir)
+
+ err = s.Load()
+ if err == nil {
+ t.Fatal("Load() should return error for unreadable file")
+ }
+}
+
+// TestSaveWritePermissionError verifies that Save returns an error
+// when the directory is not writable.
+func TestSaveWritePermissionError(t *testing.T) {
+ t.Parallel()
+
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test when running as root")
+ }
+
+ dir := t.TempDir()
+ dataDir := filepath.Join(dir, "readonly")
+
+ err := os.MkdirAll(dataDir, 0o700)
+ if err != nil {
+ t.Fatalf("creating dir: %v", err)
+ }
+
+ //nolint:gosec // intentionally making dir read-only+execute for test
+ err = os.Chmod(dataDir, 0o500)
+ if err != nil {
+ t.Fatalf("chmod: %v", err)
+ }
+
+ t.Cleanup(func() {
+ //nolint:gosec // restoring write permission for cleanup
+ _ = os.Chmod(dataDir, 0o700)
+ })
+
+ s := state.NewForTestWithDataDir(dataDir)
+
+ err = s.Save()
+ if err == nil {
+ t.Fatal("Save() should return error for read-only directory")
+ }
+}
+
+// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
+// current multi-hostname format.
+func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{
+ "open": true,
+ "hostnames": ["www.example.com", "api.example.com"],
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }`)
+
+ var ps state.PortState
+
+ err := json.Unmarshal(data, &ps)
+ if err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ if !ps.Open {
+ t.Error("expected open=true")
+ }
+
+ if len(ps.Hostnames) != 2 {
+ t.Fatalf("expected 2 hostnames, got %d", len(ps.Hostnames))
+ }
+
+ if ps.Hostnames[0] != testHostname {
+ t.Errorf("hostname[0]: got %q", ps.Hostnames[0])
+ }
+
+ if ps.Hostnames[1] != "api.example.com" {
+ t.Errorf("hostname[1]: got %q", ps.Hostnames[1])
+ }
+
+ if ps.LastChecked.IsZero() {
+ t.Error("expected non-zero LastChecked")
+ }
+}
+
+// TestPortStateUnmarshalJSON_OldFormat verifies backward-compatible
+// deserialization of the old single-hostname format.
+func TestPortStateUnmarshalJSON_OldFormat(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{
+ "open": false,
+ "hostname": "legacy.example.com",
+ "lastChecked": "2026-01-15T10:30:00Z"
+ }`)
+
+ var ps state.PortState
+
+ err := json.Unmarshal(data, &ps)
+ if err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ if ps.Open {
+ t.Error("expected open=false")
+ }
+
+ if len(ps.Hostnames) != 1 {
+ t.Fatalf("expected 1 hostname, got %d", len(ps.Hostnames))
+ }
+
+ if ps.Hostnames[0] != "legacy.example.com" {
+ t.Errorf("hostname: got %q", ps.Hostnames[0])
+ }
+}
+
+// TestPortStateUnmarshalJSON_EmptyHostnames verifies that when neither
+// hostnames nor hostname is present, Hostnames remains nil.
+func TestPortStateUnmarshalJSON_EmptyHostnames(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{
+ "open": true,
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }`)
+
+ var ps state.PortState
+
+ err := json.Unmarshal(data, &ps)
+ if err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ if len(ps.Hostnames) != 0 {
+ t.Errorf("expected empty hostnames, got %v", ps.Hostnames)
+ }
+}
+
+// TestPortStateUnmarshalJSON_InvalidJSON verifies that invalid JSON returns an error.
+func TestPortStateUnmarshalJSON_InvalidJSON(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{invalid json}`)
+
+ var ps state.PortState
+
+ err := json.Unmarshal(data, &ps)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+// TestPortStateUnmarshalJSON_BothFormats verifies that when both "hostnames"
+// and "hostname" are present, the new format takes precedence.
+func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
+ t.Parallel()
+
+ data := []byte(`{
+ "open": true,
+ "hostnames": ["new.example.com"],
+ "hostname": "old.example.com",
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }`)
+
+ var ps state.PortState
+
+ err := json.Unmarshal(data, &ps)
+ if err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ // When hostnames is non-empty, the old hostname field is ignored.
+ if len(ps.Hostnames) != 1 {
+ t.Fatalf("expected 1 hostname, got %d", len(ps.Hostnames))
+ }
+
+ if ps.Hostnames[0] != "new.example.com" {
+ t.Errorf("hostname: got %q, want %q", ps.Hostnames[0], "new.example.com")
+ }
+}
+
+// TestGetSnapshot_ReturnsCopy verifies that GetSnapshot returns a value copy.
+func TestGetSnapshot_ReturnsCopy(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ populateState(t, s)
+
+ snap := s.GetSnapshot()
+
+ // Mutate the returned snapshot's map.
+ snap.Domains["mutated.example.com"] = &state.DomainState{
+ Nameservers: []string{"ns1.mutated.com."},
+ }
+
+ // The original domain data should still be accessible.
+ _, ok := s.GetDomainState("example.com")
+ if !ok {
+ t.Error("original domain should still exist")
+ }
+}
+
+// TestDomainState_GetSet tests domain state getter and setter.
+func TestDomainState_GetSet(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ // Get on missing key returns false.
+ _, ok := s.GetDomainState("nonexistent.com")
+ if ok {
+ t.Error("expected false for missing domain")
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ ds := &state.DomainState{
+ Nameservers: []string{"ns1.test.com."},
+ LastChecked: now,
+ }
+
+ s.SetDomainState("test.com", ds)
+
+ got, ok := s.GetDomainState("test.com")
+ if !ok {
+ t.Fatal("expected true for existing domain")
+ }
+
+ if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." {
+ t.Errorf("nameservers: got %v", got.Nameservers)
+ }
+
+ if !got.LastChecked.Equal(now) {
+ t.Errorf("lastChecked: got %v, want %v", got.LastChecked, now)
+ }
+
+ // Overwrite.
+ ds2 := &state.DomainState{
+ Nameservers: []string{"ns1.test.com.", "ns2.test.com."},
+ LastChecked: now.Add(time.Hour),
+ }
+
+ s.SetDomainState("test.com", ds2)
+
+ got2, ok := s.GetDomainState("test.com")
+ if !ok {
+ t.Fatal("expected true after overwrite")
+ }
+
+ if len(got2.Nameservers) != 2 {
+ t.Errorf("expected 2 nameservers after overwrite, got %d", len(got2.Nameservers))
+ }
+}
+
+// TestHostnameState_GetSet tests hostname state getter and setter.
+func TestHostnameState_GetSet(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ _, ok := s.GetHostnameState("missing.example.com")
+ if ok {
+ t.Error("expected false for missing hostname")
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ hs := &state.HostnameState{
+ RecordsByNameserver: map[string]*state.NameserverRecordState{
+ "ns1.example.com.": {
+ Records: map[string][]string{"A": {"1.2.3.4"}},
+ Status: "ok",
+ LastChecked: now,
+ },
+ },
+ LastChecked: now,
+ }
+
+ s.SetHostnameState(testHostname, hs)
+
+ got, ok := s.GetHostnameState(testHostname)
+ if !ok {
+ t.Fatal("expected true for existing hostname")
+ }
+
+ nsState, ok := got.RecordsByNameserver["ns1.example.com."]
+ if !ok {
+ t.Fatal("missing nameserver entry")
+ }
+
+ if nsState.Status != "ok" {
+ t.Errorf("status: got %q", nsState.Status)
+ }
+
+ aRecords := nsState.Records["A"]
+ if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" {
+ t.Errorf("A records: got %v", aRecords)
+ }
+}
+
+// TestPortState_GetSetDelete tests port state getter, setter, and deletion.
+func TestPortState_GetSetDelete(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ _, ok := s.GetPortState("1.2.3.4:80")
+ if ok {
+ t.Error("expected false for missing port")
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ ps := &state.PortState{
+ Open: true,
+ Hostnames: []string{testHostname},
+ LastChecked: now,
+ }
+
+ s.SetPortState("1.2.3.4:80", ps)
+
+ got, ok := s.GetPortState("1.2.3.4:80")
+ if !ok {
+ t.Fatal("expected true for existing port")
+ }
+
+ if !got.Open {
+ t.Error("expected open=true")
+ }
+
+ // Delete the port state.
+ s.DeletePortState("1.2.3.4:80")
+
+ _, ok = s.GetPortState("1.2.3.4:80")
+ if ok {
+ t.Error("expected false after delete")
+ }
+}
+
+// TestGetAllPortKeys tests retrieving all port state keys.
+func TestGetAllPortKeys(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ keys := s.GetAllPortKeys()
+ if len(keys) != 0 {
+ t.Errorf("expected 0 keys, got %d", len(keys))
+ }
+
+ now := time.Now().UTC()
+
+ s.SetPortState("1.2.3.4:80", &state.PortState{
+ Open: true, Hostnames: []string{"a.example.com"}, LastChecked: now,
+ })
+
+ s.SetPortState("1.2.3.4:443", &state.PortState{
+ Open: true, Hostnames: []string{"a.example.com"}, LastChecked: now,
+ })
+
+ s.SetPortState("5.6.7.8:80", &state.PortState{
+ Open: false, Hostnames: []string{"b.example.com"}, LastChecked: now,
+ })
+
+ keys = s.GetAllPortKeys()
+ if len(keys) != 3 {
+ t.Fatalf("expected 3 keys, got %d", len(keys))
+ }
+
+ keySet := make(map[string]bool)
+ for _, k := range keys {
+ keySet[k] = true
+ }
+
+ for _, want := range []string{"1.2.3.4:80", "1.2.3.4:443", "5.6.7.8:80"} {
+ if !keySet[want] {
+ t.Errorf("missing key %q", want)
+ }
+ }
+}
+
+// TestCertificateState_GetSet tests certificate state getter and setter.
+func TestCertificateState_GetSet(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ _, ok := s.GetCertificateState("1.2.3.4:443:www.example.com")
+ if ok {
+ t.Error("expected false for missing certificate")
+ }
+
+ now := time.Now().UTC().Truncate(time.Second)
+ cs := &state.CertificateState{
+ CommonName: testHostname,
+ Issuer: "Let's Encrypt Authority X3",
+ NotAfter: now.Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{testHostname},
+ Status: "ok",
+ LastChecked: now,
+ }
+
+ s.SetCertificateState("1.2.3.4:443:www.example.com", cs)
+
+ got, ok := s.GetCertificateState("1.2.3.4:443:www.example.com")
+ if !ok {
+ t.Fatal("expected true for existing certificate")
+ }
+
+ if got.CommonName != testHostname {
+ t.Errorf("CN: got %q", got.CommonName)
+ }
+
+ if got.Issuer != "Let's Encrypt Authority X3" {
+ t.Errorf("issuer: got %q", got.Issuer)
+ }
+
+ if got.Status != "ok" {
+ t.Errorf("status: got %q", got.Status)
+ }
+
+ if len(got.SubjectAlternativeNames) != 1 {
+ t.Errorf("SANs: expected 1, got %d", len(got.SubjectAlternativeNames))
+ }
+}
+
+// TestCertificateState_ErrorField tests that the error field round-trips.
+func TestCertificateState_ErrorField(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ now := time.Now().UTC().Truncate(time.Second)
+ cs := &state.CertificateState{
+ Status: "error",
+ Error: "connection refused",
+ LastChecked: now,
+ }
+
+ s.SetCertificateState("10.0.0.1:443:err.example.com", cs)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ got, ok := loaded.GetCertificateState("10.0.0.1:443:err.example.com")
+ if !ok {
+ t.Fatal("missing certificate after load")
+ }
+
+ if got.Status != "error" {
+ t.Errorf("status: got %q, want %q", got.Status, "error")
+ }
+
+ if got.Error != "connection refused" {
+ t.Errorf("error field: got %q", got.Error)
+ }
+}
+
+// TestHostnameState_ErrorField tests that hostname error states round-trip.
+func TestHostnameState_ErrorField(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ now := time.Now().UTC().Truncate(time.Second)
+ hs := &state.HostnameState{
+ RecordsByNameserver: map[string]*state.NameserverRecordState{
+ "ns1.example.com.": {
+ Records: nil,
+ Status: "error",
+ Error: "SERVFAIL",
+ LastChecked: now,
+ },
+ },
+ LastChecked: now,
+ }
+
+ s.SetHostnameState("fail.example.com", hs)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ got, ok := loaded.GetHostnameState("fail.example.com")
+ if !ok {
+ t.Fatal("missing hostname after load")
+ }
+
+ nsState := got.RecordsByNameserver["ns1.example.com."]
+ if nsState.Status != "error" {
+ t.Errorf("status: got %q, want %q", nsState.Status, "error")
+ }
+
+ if nsState.Error != "SERVFAIL" {
+ t.Errorf("error: got %q, want %q", nsState.Error, "SERVFAIL")
+ }
+}
+
+// TestSnapshotVersion verifies that the snapshot version is written correctly.
+func TestSnapshotVersion(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ //nolint:gosec // test file with known path
+ data, err := os.ReadFile(filepath.Join(dir, "state.json"))
+ if err != nil {
+ t.Fatalf("reading state file: %v", err)
+ }
+
+ var snap state.Snapshot
+
+ err = json.Unmarshal(data, &snap)
+ if err != nil {
+ t.Fatalf("parsing state: %v", err)
+ }
+
+ if snap.Version != 1 {
+ t.Errorf("version: got %d, want 1", snap.Version)
+ }
+
+ if snap.LastUpdated.IsZero() {
+ t.Error("lastUpdated should be set after save")
+ }
+}
+
+// TestSaveUpdatesLastUpdated verifies that Save sets LastUpdated.
+func TestSaveUpdatesLastUpdated(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ before := time.Now().UTC().Add(-time.Second)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ after := time.Now().UTC().Add(time.Second)
+
+ snap := s.GetSnapshot()
+ if snap.LastUpdated.Before(before) || snap.LastUpdated.After(after) {
+ t.Errorf(
+ "LastUpdated %v not in expected range [%v, %v]",
+ snap.LastUpdated, before, after,
+ )
+ }
+}
+
+// TestLoadPreservesExistingStateOnMissingFile verifies that when Load is
+// called and the file doesn't exist, the existing in-memory state is kept.
+func TestLoadPreservesExistingStateOnMissingFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ // Add some data before loading.
+ s.SetDomainState("pre.example.com", &state.DomainState{
+ Nameservers: []string{"ns1.pre.example.com."},
+ LastChecked: time.Now().UTC(),
+ })
+
+ err := s.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ // Since the file doesn't exist, the existing data should persist.
+ _, ok := s.GetDomainState("pre.example.com")
+ if !ok {
+ t.Error("existing domain state should persist when file is missing")
+ }
+}
+
+// TestConcurrentGetSet verifies that concurrent reads and writes don't race.
+func TestConcurrentGetSet(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ const goroutines = 20
+
+ var wg sync.WaitGroup
+
+ wg.Add(goroutines)
+
+ for i := range goroutines {
+ go func(id int) {
+ defer wg.Done()
+
+ now := time.Now().UTC()
+ key := string(rune('a' + id%26))
+
+ runConcurrentOps(s, key, now)
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+// 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
+
+ for j := range iterations {
+ s.SetDomainState(key+".com", &state.DomainState{
+ Nameservers: []string{"ns1." + key + ".com."},
+ LastChecked: now,
+ })
+
+ s.GetDomainState(key + ".com")
+
+ s.SetPortState(key+":80", &state.PortState{
+ Open: j%2 == 0, Hostnames: []string{key + ".com"}, LastChecked: now,
+ })
+
+ s.GetPortState(key + ":80")
+ s.GetAllPortKeys()
+ s.GetSnapshot()
+
+ s.SetHostnameState(key+".example.com", &state.HostnameState{
+ RecordsByNameserver: map[string]*state.NameserverRecordState{
+ "ns1.test.": {
+ Records: map[string][]string{"A": {"1.2.3.4"}},
+ Status: "ok",
+ LastChecked: now,
+ },
+ },
+ LastChecked: now,
+ })
+
+ s.GetHostnameState(key + ".example.com")
+
+ s.SetCertificateState("1.2.3.4:443:"+key+".com", &state.CertificateState{
+ CommonName: key + ".com", Status: "ok", LastChecked: now,
+ })
+
+ s.GetCertificateState("1.2.3.4:443:" + key + ".com")
+
+ if j%10 == 0 {
+ s.DeletePortState(key + ":80")
+ }
+ }
+}
+
+// TestConcurrentSaveLoad verifies that concurrent Save and Load
+// operations don't race or corrupt state.
+func TestConcurrentSaveLoad(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ populateState(t, s)
+
+ const goroutines = 10
+
+ var wg sync.WaitGroup
+
+ wg.Add(goroutines)
+
+ for i := range goroutines {
+ go func(id int) {
+ defer wg.Done()
+
+ if id%2 == 0 {
+ _ = s.Save()
+ } else {
+ _ = s.Load()
+ }
+ }(i)
+ }
+
+ wg.Wait()
+}
+
+// TestPortStateRoundTripThroughSaveLoad verifies backward-compat deserialization
+// works through the full Save→Load cycle with old-format data on disk.
+func TestPortStateRoundTripThroughSaveLoad(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+
+ // Write a state file with old single-hostname port format.
+ oldFormatJSON := `{
+ "version": 1,
+ "lastUpdated": "2026-02-19T12:00:00Z",
+ "domains": {},
+ "hostnames": {},
+ "ports": {
+ "10.0.0.1:80": {
+ "open": true,
+ "hostname": "legacy.example.com",
+ "lastChecked": "2026-02-19T12:00:00Z"
+ },
+ "10.0.0.1:443": {
+ "open": true,
+ "hostnames": ["modern.example.com"],
+ "lastChecked": "2026-02-19T12:00:00Z"
+ }
+ },
+ "certificates": {}
+ }`
+
+ err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(oldFormatJSON), 0o600)
+ if err != nil {
+ t.Fatalf("writing old format state: %v", err)
+ }
+
+ s := state.NewForTestWithDataDir(dir)
+
+ err = s.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ // Verify old format was migrated.
+ ps80, ok := s.GetPortState("10.0.0.1:80")
+ if !ok {
+ t.Fatal("missing port 10.0.0.1:80")
+ }
+
+ if len(ps80.Hostnames) != 1 || ps80.Hostnames[0] != "legacy.example.com" {
+ t.Errorf("old format port hostnames: got %v", ps80.Hostnames)
+ }
+
+ // Verify new format loaded correctly.
+ ps443, ok := s.GetPortState("10.0.0.1:443")
+ if !ok {
+ t.Fatal("missing port 10.0.0.1:443")
+ }
+
+ if len(ps443.Hostnames) != 1 || ps443.Hostnames[0] != "modern.example.com" {
+ t.Errorf("new format port hostnames: got %v", ps443.Hostnames)
+ }
+}
+
+// TestMultipleSavesOverwrite verifies that subsequent saves overwrite the
+// previous state completely.
+func TestMultipleSavesOverwrite(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ s.SetDomainState("first.com", &state.DomainState{
+ Nameservers: []string{"ns1.first.com."},
+ LastChecked: time.Now().UTC(),
+ })
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("first Save() error: %v", err)
+ }
+
+ // Create a brand new state with the same dir and set different data.
+ s2 := state.NewForTestWithDataDir(dir)
+
+ s2.SetDomainState("second.com", &state.DomainState{
+ Nameservers: []string{"ns1.second.com."},
+ LastChecked: time.Now().UTC(),
+ })
+
+ err = s2.Save()
+ if err != nil {
+ t.Fatalf("second Save() error: %v", err)
+ }
+
+ // Load and verify only the second domain exists.
+ loaded := state.NewForTestWithDataDir(dir)
+
+ err = loaded.Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+
+ snap := loaded.GetSnapshot()
+
+ if _, ok := snap.Domains["first.com"]; ok {
+ t.Error("first.com should not exist after overwrite")
+ }
+
+ if _, ok := snap.Domains["second.com"]; !ok {
+ t.Error("second.com should exist after overwrite")
+ }
+}
+
+// TestNewForTest verifies the test helper creates a valid empty state.
+func TestNewForTest(t *testing.T) {
+ t.Parallel()
+
+ s := state.NewForTest()
+
+ snap := s.GetSnapshot()
+
+ if snap.Version != 1 {
+ t.Errorf("version: got %d, want 1", snap.Version)
+ }
+
+ if snap.Domains == nil {
+ t.Error("Domains map should be initialized")
+ }
+
+ if snap.Hostnames == nil {
+ t.Error("Hostnames map should be initialized")
+ }
+
+ if snap.Ports == nil {
+ t.Error("Ports map should be initialized")
+ }
+
+ if snap.Certificates == nil {
+ t.Error("Certificates map should be initialized")
+ }
+}
+
+// TestSaveFilePermissions verifies the saved file has restricted permissions.
+func TestSaveFilePermissions(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ s := state.NewForTestWithDataDir(dir)
+
+ err := s.Save()
+ if err != nil {
+ t.Fatalf("Save() error: %v", err)
+ }
+
+ info, err := os.Stat(filepath.Join(dir, "state.json"))
+ if err != nil {
+ t.Fatalf("stat error: %v", err)
+ }
+
+ perm := info.Mode().Perm()
+ if perm != 0o600 {
+ t.Errorf("file permissions: got %o, want 600", perm)
+ }
+}
diff --git a/internal/state/state_test_helper.go b/internal/state/state_test_helper.go
new file mode 100644
index 0000000..7e9aac7
--- /dev/null
+++ b/internal/state/state_test_helper.go
@@ -0,0 +1,38 @@
+package state
+
+import (
+ "log/slog"
+
+ "sneak.berlin/go/dnswatcher/internal/config"
+)
+
+// NewForTest creates a State for unit testing with no persistence.
+func NewForTest() *State {
+ return &State{
+ log: slog.Default(),
+ snapshot: &Snapshot{
+ Version: stateVersion,
+ Domains: make(map[string]*DomainState),
+ Hostnames: make(map[string]*HostnameState),
+ Ports: make(map[string]*PortState),
+ Certificates: make(map[string]*CertificateState),
+ },
+ config: &config.Config{DataDir: ""},
+ }
+}
+
+// NewForTestWithDataDir creates a State backed by the given directory
+// for tests that need file persistence.
+func NewForTestWithDataDir(dataDir string) *State {
+ return &State{
+ log: slog.Default(),
+ snapshot: &Snapshot{
+ Version: stateVersion,
+ Domains: make(map[string]*DomainState),
+ Hostnames: make(map[string]*HostnameState),
+ Ports: make(map[string]*PortState),
+ Certificates: make(map[string]*CertificateState),
+ },
+ config: &config.Config{DataDir: dataDir},
+ }
+}
diff --git a/internal/tlscheck/extractcertinfo_test.go b/internal/tlscheck/extractcertinfo_test.go
new file mode 100644
index 0000000..56830e8
--- /dev/null
+++ b/internal/tlscheck/extractcertinfo_test.go
@@ -0,0 +1,67 @@
+package tlscheck_test
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "net"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/tlscheck"
+)
+
+func TestCheckCertificateNoPeerCerts(t *testing.T) {
+ t.Parallel()
+
+ lc := &net.ListenConfig{}
+
+ ln, err := lc.Listen(
+ context.Background(), "tcp", "127.0.0.1:0",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ defer func() { _ = ln.Close() }()
+
+ addr, ok := ln.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatal("unexpected address type")
+ }
+
+ // Accept and immediately close to cause TLS handshake failure.
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ return
+ }
+
+ _ = conn.Close()
+ }()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(2*time.Second),
+ tlscheck.WithTLSConfig(&tls.Config{
+ InsecureSkipVerify: true, //nolint:gosec // test
+ MinVersion: tls.VersionTLS12,
+ }),
+ tlscheck.WithPort(addr.Port),
+ )
+
+ _, err = checker.CheckCertificate(
+ context.Background(), "127.0.0.1", "localhost",
+ )
+ if err == nil {
+ t.Fatal("expected error when server presents no certs")
+ }
+}
+
+func TestErrNoPeerCertificatesIsSentinel(t *testing.T) {
+ t.Parallel()
+
+ err := tlscheck.ErrNoPeerCertificates
+ if !errors.Is(err, tlscheck.ErrNoPeerCertificates) {
+ t.Fatal("expected sentinel error to match")
+ }
+}
diff --git a/internal/tlscheck/tlscheck.go b/internal/tlscheck/tlscheck.go
index 42f086d..60f349a 100644
--- a/internal/tlscheck/tlscheck.go
+++ b/internal/tlscheck/tlscheck.go
@@ -3,8 +3,12 @@ package tlscheck
import (
"context"
+ "crypto/tls"
"errors"
+ "fmt"
"log/slog"
+ "net"
+ "strconv"
"time"
"go.uber.org/fx"
@@ -12,11 +16,56 @@ import (
"sneak.berlin/go/dnswatcher/internal/logger"
)
-// ErrNotImplemented indicates the TLS checker is not yet implemented.
-var ErrNotImplemented = errors.New(
- "tls checker not yet implemented",
+const (
+ defaultTimeout = 10 * time.Second
+ defaultPort = 443
)
+// ErrUnexpectedConnType indicates the connection was not a TLS
+// connection.
+var ErrUnexpectedConnType = errors.New(
+ "unexpected connection type",
+)
+
+// ErrNoPeerCertificates indicates the TLS connection had no peer
+// certificates.
+var ErrNoPeerCertificates = errors.New(
+ "no peer certificates",
+)
+
+// CertificateInfo holds information about a TLS certificate.
+type CertificateInfo struct {
+ CommonName string
+ Issuer string
+ NotAfter time.Time
+ SubjectAlternativeNames []string
+ SerialNumber string
+}
+
+// Option configures a Checker.
+type Option func(*Checker)
+
+// WithTimeout sets the connection timeout.
+func WithTimeout(d time.Duration) Option {
+ return func(c *Checker) {
+ c.timeout = d
+ }
+}
+
+// WithTLSConfig sets a custom TLS configuration.
+func WithTLSConfig(cfg *tls.Config) Option {
+ return func(c *Checker) {
+ c.tlsConfig = cfg
+ }
+}
+
+// WithPort sets the TLS port to connect to.
+func WithPort(port int) Option {
+ return func(c *Checker) {
+ c.port = port
+ }
+}
+
// Params contains dependencies for Checker.
type Params struct {
fx.In
@@ -26,15 +75,10 @@ type Params struct {
// Checker performs TLS certificate inspection.
type Checker struct {
- log *slog.Logger
-}
-
-// CertificateInfo holds information about a TLS certificate.
-type CertificateInfo struct {
- CommonName string
- Issuer string
- NotAfter time.Time
- SubjectAlternativeNames []string
+ log *slog.Logger
+ timeout time.Duration
+ tlsConfig *tls.Config
+ port int
}
// New creates a new TLS Checker instance.
@@ -43,16 +87,110 @@ func New(
params Params,
) (*Checker, error) {
return &Checker{
- log: params.Logger.Get(),
+ log: params.Logger.Get(),
+ timeout: defaultTimeout,
+ port: defaultPort,
}, nil
}
-// CheckCertificate connects to the given IP:port using SNI and
-// returns certificate information.
-func (c *Checker) CheckCertificate(
- _ context.Context,
- _ string,
- _ string,
-) (*CertificateInfo, error) {
- return nil, ErrNotImplemented
+// NewStandalone creates a Checker without fx dependencies.
+func NewStandalone(opts ...Option) *Checker {
+ checker := &Checker{
+ log: slog.Default(),
+ timeout: defaultTimeout,
+ port: defaultPort,
+ }
+
+ for _, opt := range opts {
+ opt(checker)
+ }
+
+ return checker
+}
+
+// CheckCertificate connects to the given IP address using the
+// specified SNI hostname and returns certificate information.
+func (c *Checker) CheckCertificate(
+ ctx context.Context,
+ ipAddress string,
+ sniHostname string,
+) (*CertificateInfo, error) {
+ target := net.JoinHostPort(
+ ipAddress, strconv.Itoa(c.port),
+ )
+
+ tlsCfg := c.buildTLSConfig(sniHostname)
+ dialer := &tls.Dialer{
+ NetDialer: &net.Dialer{Timeout: c.timeout},
+ Config: tlsCfg,
+ }
+
+ conn, err := dialer.DialContext(ctx, "tcp", target)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "TLS dial to %s: %w", target, err,
+ )
+ }
+
+ defer func() {
+ closeErr := conn.Close()
+ if closeErr != nil {
+ c.log.Debug(
+ "closing TLS connection",
+ "target", target,
+ "error", closeErr.Error(),
+ )
+ }
+ }()
+
+ tlsConn, ok := conn.(*tls.Conn)
+ if !ok {
+ return nil, fmt.Errorf(
+ "%s: %w", target, ErrUnexpectedConnType,
+ )
+ }
+
+ return c.extractCertInfo(tlsConn)
+}
+
+func (c *Checker) buildTLSConfig(
+ sniHostname string,
+) *tls.Config {
+ if c.tlsConfig != nil {
+ cfg := c.tlsConfig.Clone()
+ cfg.ServerName = sniHostname
+
+ return cfg
+ }
+
+ return &tls.Config{
+ ServerName: sniHostname,
+ MinVersion: tls.VersionTLS12,
+ }
+}
+
+func (c *Checker) extractCertInfo(
+ conn *tls.Conn,
+) (*CertificateInfo, error) {
+ state := conn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return nil, ErrNoPeerCertificates
+ }
+
+ cert := state.PeerCertificates[0]
+
+ sans := make([]string, 0, len(cert.DNSNames)+len(cert.IPAddresses))
+ sans = append(sans, cert.DNSNames...)
+
+ for _, ip := range cert.IPAddresses {
+ sans = append(sans, ip.String())
+ }
+
+ return &CertificateInfo{
+ CommonName: cert.Subject.CommonName,
+ Issuer: cert.Issuer.CommonName,
+ NotAfter: cert.NotAfter,
+ SubjectAlternativeNames: sans,
+ SerialNumber: cert.SerialNumber.String(),
+ }, nil
}
diff --git a/internal/tlscheck/tlscheck_test.go b/internal/tlscheck/tlscheck_test.go
new file mode 100644
index 0000000..715f474
--- /dev/null
+++ b/internal/tlscheck/tlscheck_test.go
@@ -0,0 +1,169 @@
+package tlscheck_test
+
+import (
+ "context"
+ "crypto/tls"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/tlscheck"
+)
+
+func startTLSServer(
+ t *testing.T,
+) (*httptest.Server, string, int) {
+ t.Helper()
+
+ srv := httptest.NewTLSServer(
+ http.HandlerFunc(
+ func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ },
+ ),
+ )
+
+ addr, ok := srv.Listener.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatal("unexpected address type")
+ }
+
+ return srv, addr.IP.String(), addr.Port
+}
+
+func TestCheckCertificateValid(t *testing.T) {
+ t.Parallel()
+
+ srv, ip, port := startTLSServer(t)
+
+ defer srv.Close()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(5*time.Second),
+ tlscheck.WithTLSConfig(&tls.Config{
+ //nolint:gosec // test uses self-signed cert
+ InsecureSkipVerify: true,
+ }),
+ tlscheck.WithPort(port),
+ )
+
+ info, err := checker.CheckCertificate(
+ context.Background(), ip, "localhost",
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if info == nil {
+ t.Fatal("expected non-nil CertificateInfo")
+ }
+
+ if info.NotAfter.IsZero() {
+ t.Error("expected non-zero NotAfter")
+ }
+
+ if info.SerialNumber == "" {
+ t.Error("expected non-empty SerialNumber")
+ }
+}
+
+func TestCheckCertificateConnectionRefused(t *testing.T) {
+ t.Parallel()
+
+ lc := &net.ListenConfig{}
+
+ ln, err := lc.Listen(
+ context.Background(), "tcp", "127.0.0.1:0",
+ )
+ if err != nil {
+ t.Fatalf("failed to listen: %v", err)
+ }
+
+ addr, ok := ln.Addr().(*net.TCPAddr)
+ if !ok {
+ t.Fatal("unexpected address type")
+ }
+
+ port := addr.Port
+
+ _ = ln.Close()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(2*time.Second),
+ tlscheck.WithPort(port),
+ )
+
+ _, err = checker.CheckCertificate(
+ context.Background(), "127.0.0.1", "localhost",
+ )
+ if err == nil {
+ t.Fatal("expected error for connection refused")
+ }
+}
+
+func TestCheckCertificateContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(2*time.Second),
+ tlscheck.WithPort(1),
+ )
+
+ _, err := checker.CheckCertificate(
+ ctx, "127.0.0.1", "localhost",
+ )
+ if err == nil {
+ t.Fatal("expected error for canceled context")
+ }
+}
+
+func TestCheckCertificateTimeout(t *testing.T) {
+ t.Parallel()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(1*time.Millisecond),
+ tlscheck.WithPort(1),
+ )
+
+ _, err := checker.CheckCertificate(
+ context.Background(),
+ "192.0.2.1",
+ "example.com",
+ )
+ if err == nil {
+ t.Fatal("expected error for timeout")
+ }
+}
+
+func TestCheckCertificateSANs(t *testing.T) {
+ t.Parallel()
+
+ srv, ip, port := startTLSServer(t)
+
+ defer srv.Close()
+
+ checker := tlscheck.NewStandalone(
+ tlscheck.WithTimeout(5*time.Second),
+ tlscheck.WithTLSConfig(&tls.Config{
+ //nolint:gosec // test uses self-signed cert
+ InsecureSkipVerify: true,
+ }),
+ tlscheck.WithPort(port),
+ )
+
+ info, err := checker.CheckCertificate(
+ context.Background(), ip, "localhost",
+ )
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if info.CommonName == "" && len(info.SubjectAlternativeNames) == 0 {
+ t.Error("expected CN or SANs to be populated")
+ }
+}
diff --git a/internal/watcher/interfaces.go b/internal/watcher/interfaces.go
new file mode 100644
index 0000000..dd68017
--- /dev/null
+++ b/internal/watcher/interfaces.go
@@ -0,0 +1,61 @@
+// Package watcher provides the main monitoring orchestrator.
+package watcher
+
+import (
+ "context"
+
+ "sneak.berlin/go/dnswatcher/internal/portcheck"
+ "sneak.berlin/go/dnswatcher/internal/tlscheck"
+)
+
+// DNSResolver performs iterative DNS resolution.
+type DNSResolver interface {
+ // LookupNS discovers authoritative nameservers for a domain.
+ LookupNS(
+ ctx context.Context,
+ domain string,
+ ) ([]string, error)
+
+ // LookupAllRecords queries all record types for a hostname,
+ // returning results keyed by nameserver then record type.
+ LookupAllRecords(
+ ctx context.Context,
+ hostname string,
+ ) (map[string]map[string][]string, error)
+
+ // ResolveIPAddresses resolves a hostname to all IP addresses.
+ ResolveIPAddresses(
+ ctx context.Context,
+ hostname string,
+ ) ([]string, error)
+}
+
+// PortChecker tests TCP port connectivity.
+type PortChecker interface {
+ // CheckPort tests TCP connectivity to an address and port.
+ CheckPort(
+ ctx context.Context,
+ address string,
+ port int,
+ ) (*portcheck.PortResult, error)
+}
+
+// TLSChecker inspects TLS certificates.
+type TLSChecker interface {
+ // CheckCertificate connects via TLS and returns cert info.
+ CheckCertificate(
+ ctx context.Context,
+ ip string,
+ hostname string,
+ ) (*tlscheck.CertificateInfo, error)
+}
+
+// Notifier delivers notifications to configured endpoints.
+type Notifier interface {
+ // SendNotification sends a notification with the given
+ // details.
+ SendNotification(
+ ctx context.Context,
+ title, message, priority string,
+ )
+}
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
index a9b1caa..bdf4f22 100644
--- a/internal/watcher/watcher.go
+++ b/internal/watcher/watcher.go
@@ -1,21 +1,31 @@
-// Package watcher provides the main monitoring orchestrator and scheduler.
package watcher
import (
"context"
+ "fmt"
"log/slog"
+ "sort"
+ "strings"
+ "sync"
+ "time"
"go.uber.org/fx"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/logger"
- "sneak.berlin/go/dnswatcher/internal/notify"
- "sneak.berlin/go/dnswatcher/internal/portcheck"
- "sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
)
+// monitoredPorts are the TCP ports checked for each IP address.
+var monitoredPorts = []int{80, 443} //nolint:gochecknoglobals
+
+// tlsPort is the port used for TLS certificate checks.
+const tlsPort = 443
+
+// hoursPerDay converts days to hours for duration calculations.
+const hoursPerDay = 24
+
// Params contains dependencies for Watcher.
type Params struct {
fx.In
@@ -23,61 +33,92 @@ type Params struct {
Logger *logger.Logger
Config *config.Config
State *state.State
- Resolver *resolver.Resolver
- PortCheck *portcheck.Checker
- TLSCheck *tlscheck.Checker
- Notify *notify.Service
+ Resolver DNSResolver
+ PortCheck PortChecker
+ TLSCheck TLSChecker
+ Notify Notifier
}
// Watcher orchestrates all monitoring checks on a schedule.
type Watcher struct {
- log *slog.Logger
- config *config.Config
- state *state.State
- resolver *resolver.Resolver
- portCheck *portcheck.Checker
- tlsCheck *tlscheck.Checker
- notify *notify.Service
- cancel context.CancelFunc
+ 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.
+// New creates a new Watcher instance wired into the fx lifecycle.
func New(
lifecycle fx.Lifecycle,
params Params,
) (*Watcher, error) {
- watcher := &Watcher{
- log: params.Logger.Get(),
- config: params.Config,
- state: params.State,
- resolver: params.Resolver,
- portCheck: params.PortCheck,
- tlsCheck: params.TLSCheck,
- notify: params.Notify,
+ 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,
+ expiryNotified: make(map[string]time.Time),
}
lifecycle.Append(fx.Hook{
- OnStart: func(startCtx context.Context) error {
- ctx, cancel := context.WithCancel(startCtx)
- watcher.cancel = cancel
+ OnStart: func(_ context.Context) error {
+ // Use context.Background() — the fx startup context
+ // expires after startup completes, so deriving from it
+ // would cancel the watcher immediately. The watcher's
+ // lifetime is controlled by w.cancel in OnStop.
+ ctx, cancel := context.WithCancel(context.Background())
+ w.cancel = cancel
- go watcher.Run(ctx)
+ go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx
return nil
},
OnStop: func(_ context.Context) error {
- if watcher.cancel != nil {
- watcher.cancel()
+ if w.cancel != nil {
+ w.cancel()
}
return nil
},
})
- return watcher, nil
+ return w, nil
}
-// Run starts the monitoring loop.
+// NewForTest creates a Watcher without fx for unit testing.
+func NewForTest(
+ cfg *config.Config,
+ st *state.State,
+ res DNSResolver,
+ pc PortChecker,
+ tc TLSChecker,
+ n Notifier,
+) *Watcher {
+ return &Watcher{
+ log: slog.Default(),
+ config: cfg,
+ state: st,
+ resolver: res,
+ portCheck: pc,
+ tlsCheck: tc,
+ notify: n,
+ firstRun: true,
+ expiryNotified: make(map[string]time.Time),
+ }
+}
+
+// Run starts the monitoring loop with periodic scheduling.
func (w *Watcher) Run(ctx context.Context) {
w.log.Info(
"watcher starting",
@@ -87,8 +128,812 @@ func (w *Watcher) Run(ctx context.Context) {
"tlsInterval", w.config.TLSInterval,
)
- // Stub: wait for context cancellation.
- // Implementation will add initial check + periodic scheduling.
- <-ctx.Done()
- w.log.Info("watcher stopped")
+ w.RunOnce(ctx)
+ w.maybeSendTestNotification(ctx)
+
+ dnsTicker := time.NewTicker(w.config.DNSInterval)
+ tlsTicker := time.NewTicker(w.config.TLSInterval)
+
+ defer dnsTicker.Stop()
+ defer tlsTicker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ w.log.Info("watcher stopped")
+
+ return
+ case <-dnsTicker.C:
+ w.runDNSChecks(ctx)
+
+ w.checkAllPorts(ctx)
+ w.saveState()
+ case <-tlsTicker.C:
+ // Run DNS first so TLS checks use freshly
+ // resolved IP addresses, not stale ones from
+ // a previous cycle.
+ w.runDNSChecks(ctx)
+
+ w.runTLSChecks(ctx)
+ w.saveState()
+ }
+ }
+}
+
+// RunOnce performs a single complete monitoring cycle.
+// DNS checks run first so that port and TLS checks use
+// freshly resolved IP addresses. Port checks run before
+// TLS because TLS checks only target IPs with an open
+// port 443.
+func (w *Watcher) RunOnce(ctx context.Context) {
+ w.detectFirstRun()
+
+ // Phase 1: DNS resolution must complete first so that
+ // subsequent checks use fresh IP addresses.
+ w.runDNSChecks(ctx)
+
+ // Phase 2: Port checks populate port state that TLS
+ // checks depend on (TLS only targets IPs where port
+ // 443 is open).
+ w.checkAllPorts(ctx)
+
+ // Phase 3: TLS checks use fresh DNS IPs and current
+ // port state.
+ w.runTLSChecks(ctx)
+
+ w.saveState()
+ w.firstRun = false
+}
+
+func (w *Watcher) detectFirstRun() {
+ snap := w.state.GetSnapshot()
+ hasState := len(snap.Domains) > 0 ||
+ len(snap.Hostnames) > 0 ||
+ len(snap.Ports) > 0 ||
+ len(snap.Certificates) > 0
+
+ if hasState {
+ w.firstRun = false
+ }
+}
+
+// runDNSChecks performs DNS resolution for all configured domains
+// and hostnames, updating state with freshly resolved records.
+// This must complete before port or TLS checks run so those
+// checks operate on current IP addresses.
+func (w *Watcher) runDNSChecks(ctx context.Context) {
+ for _, domain := range w.config.Domains {
+ w.checkDomain(ctx, domain)
+ }
+
+ for _, hostname := range w.config.Hostnames {
+ w.checkHostname(ctx, hostname)
+ }
+}
+
+func (w *Watcher) checkDomain(
+ ctx context.Context,
+ domain string,
+) {
+ nameservers, err := w.resolver.LookupNS(ctx, domain)
+ if err != nil {
+ w.log.Error(
+ "failed to lookup NS",
+ "domain", domain,
+ "error", err,
+ )
+
+ return
+ }
+
+ sort.Strings(nameservers)
+
+ now := time.Now().UTC()
+
+ prev, hasPrev := w.state.GetDomainState(domain)
+ if hasPrev && !w.firstRun {
+ w.detectNSChanges(ctx, domain, prev.Nameservers, nameservers)
+ }
+
+ w.state.SetDomainState(domain, &state.DomainState{
+ 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(
+ ctx context.Context,
+ domain string,
+ oldNS, newNS []string,
+) {
+ oldSet := toSet(oldNS)
+ newSet := toSet(newNS)
+
+ var added, removed []string
+
+ for ns := range newSet {
+ if !oldSet[ns] {
+ added = append(added, ns)
+ }
+ }
+
+ for ns := range oldSet {
+ if !newSet[ns] {
+ removed = append(removed, ns)
+ }
+ }
+
+ if len(added) == 0 && len(removed) == 0 {
+ return
+ }
+
+ msg := fmt.Sprintf(
+ "Domain: %s\nAdded: %s\nRemoved: %s",
+ domain,
+ strings.Join(added, ", "),
+ strings.Join(removed, ", "),
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "NS Change: "+domain,
+ msg,
+ "warning",
+ )
+}
+
+func (w *Watcher) checkHostname(
+ ctx context.Context,
+ hostname string,
+) {
+ records, err := w.resolver.LookupAllRecords(ctx, hostname)
+ if err != nil {
+ w.log.Error(
+ "failed to lookup records",
+ "hostname", hostname,
+ "error", err,
+ )
+
+ return
+ }
+
+ now := time.Now().UTC()
+ prev, hasPrev := w.state.GetHostnameState(hostname)
+
+ if hasPrev && !w.firstRun {
+ w.detectHostnameChanges(ctx, hostname, prev, records)
+ }
+
+ newState := buildHostnameState(records, now)
+ w.state.SetHostnameState(hostname, newState)
+}
+
+func buildHostnameState(
+ records map[string]map[string][]string,
+ now time.Time,
+) *state.HostnameState {
+ hs := &state.HostnameState{
+ RecordsByNameserver: make(
+ map[string]*state.NameserverRecordState,
+ ),
+ LastChecked: now,
+ }
+
+ for ns, recs := range records {
+ hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
+ Records: recs,
+ Status: "ok",
+ LastChecked: now,
+ }
+ }
+
+ return hs
+}
+
+func (w *Watcher) detectHostnameChanges(
+ ctx context.Context,
+ hostname string,
+ prev *state.HostnameState,
+ current map[string]map[string][]string,
+) {
+ w.detectRecordChanges(ctx, hostname, prev, current)
+ w.detectNSDisappearances(ctx, hostname, prev, current)
+ w.detectInconsistencies(ctx, hostname, current)
+}
+
+func (w *Watcher) detectRecordChanges(
+ ctx context.Context,
+ hostname string,
+ prev *state.HostnameState,
+ current map[string]map[string][]string,
+) {
+ for ns, recs := range current {
+ prevNS, ok := prev.RecordsByNameserver[ns]
+ if !ok {
+ continue
+ }
+
+ if recordsEqual(prevNS.Records, recs) {
+ continue
+ }
+
+ msg := fmt.Sprintf(
+ "Hostname: %s\nNameserver: %s\n"+
+ "Old: %v\nNew: %v",
+ hostname, ns,
+ prevNS.Records, recs,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "Record Change: "+hostname,
+ msg,
+ "warning",
+ )
+ }
+}
+
+func (w *Watcher) detectNSDisappearances(
+ ctx context.Context,
+ hostname string,
+ prev *state.HostnameState,
+ current map[string]map[string][]string,
+) {
+ for ns, prevNS := range prev.RecordsByNameserver {
+ if _, ok := current[ns]; ok || prevNS.Status != "ok" {
+ continue
+ }
+
+ msg := fmt.Sprintf(
+ "Hostname: %s\nNameserver: %s disappeared",
+ hostname, ns,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "NS Failure: "+hostname,
+ msg,
+ "error",
+ )
+ }
+
+ for ns := range current {
+ prevNS, ok := prev.RecordsByNameserver[ns]
+ if !ok || prevNS.Status != "error" {
+ continue
+ }
+
+ msg := fmt.Sprintf(
+ "Hostname: %s\nNameserver: %s recovered",
+ hostname, ns,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "NS Recovery: "+hostname,
+ msg,
+ "success",
+ )
+ }
+}
+
+func (w *Watcher) detectInconsistencies(
+ ctx context.Context,
+ hostname string,
+ current map[string]map[string][]string,
+) {
+ nameservers := make([]string, 0, len(current))
+ for ns := range current {
+ nameservers = append(nameservers, ns)
+ }
+
+ sort.Strings(nameservers)
+
+ for i := range len(nameservers) - 1 {
+ ns1 := nameservers[i]
+ ns2 := nameservers[i+1]
+
+ if recordsEqual(current[ns1], current[ns2]) {
+ continue
+ }
+
+ msg := fmt.Sprintf(
+ "Hostname: %s\n%s: %v\n%s: %v",
+ hostname,
+ ns1, current[ns1],
+ ns2, current[ns2],
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "Inconsistency: "+hostname,
+ msg,
+ "warning",
+ )
+ }
+}
+
+func (w *Watcher) checkAllPorts(ctx context.Context) {
+ // Phase 1: Build current IP:port → hostname associations
+ // from fresh DNS data.
+ associations := w.buildPortAssociations()
+
+ // Phase 2: Check each unique IP:port and update state
+ // with the full set of associated hostnames.
+ for key, hostnames := range associations {
+ ip, port := parsePortKey(key)
+ if port == 0 {
+ continue
+ }
+
+ w.checkSinglePort(ctx, ip, port, hostnames)
+ }
+
+ // Phase 3: Remove port state entries that no longer have
+ // any hostname referencing them.
+ w.cleanupStalePorts(associations)
+}
+
+// buildPortAssociations constructs a map from IP:port keys to
+// the sorted set of hostnames currently resolving to that IP.
+func (w *Watcher) buildPortAssociations() map[string][]string {
+ assoc := make(map[string]map[string]bool)
+
+ allNames := make(
+ []string, 0,
+ len(w.config.Hostnames)+len(w.config.Domains),
+ )
+ allNames = append(allNames, w.config.Hostnames...)
+ allNames = append(allNames, w.config.Domains...)
+
+ for _, name := range allNames {
+ ips := w.collectIPs(name)
+ for _, ip := range ips {
+ for _, port := range monitoredPorts {
+ key := fmt.Sprintf("%s:%d", ip, port)
+ if assoc[key] == nil {
+ assoc[key] = make(map[string]bool)
+ }
+
+ assoc[key][name] = true
+ }
+ }
+ }
+
+ result := make(map[string][]string, len(assoc))
+ for key, set := range assoc {
+ hostnames := make([]string, 0, len(set))
+ for h := range set {
+ hostnames = append(hostnames, h)
+ }
+
+ sort.Strings(hostnames)
+
+ result[key] = hostnames
+ }
+
+ return result
+}
+
+// parsePortKey splits an "ip:port" key into its components.
+func parsePortKey(key string) (string, int) {
+ lastColon := strings.LastIndex(key, ":")
+ if lastColon < 0 {
+ return key, 0
+ }
+
+ ip := key[:lastColon]
+
+ var p int
+
+ _, err := fmt.Sscanf(key[lastColon+1:], "%d", &p)
+ if err != nil {
+ return ip, 0
+ }
+
+ return ip, p
+}
+
+// cleanupStalePorts removes port state entries that are no
+// longer referenced by any hostname in the current DNS data.
+func (w *Watcher) cleanupStalePorts(
+ currentAssociations map[string][]string,
+) {
+ for _, key := range w.state.GetAllPortKeys() {
+ if _, exists := currentAssociations[key]; !exists {
+ w.state.DeletePortState(key)
+ }
+ }
+}
+
+func (w *Watcher) collectIPs(hostname string) []string {
+ hs, ok := w.state.GetHostnameState(hostname)
+ if !ok {
+ return nil
+ }
+
+ ipSet := make(map[string]bool)
+
+ for _, nsState := range hs.RecordsByNameserver {
+ for _, ip := range nsState.Records["A"] {
+ ipSet[ip] = true
+ }
+
+ for _, ip := range nsState.Records["AAAA"] {
+ ipSet[ip] = true
+ }
+ }
+
+ result := make([]string, 0, len(ipSet))
+ for ip := range ipSet {
+ result = append(result, ip)
+ }
+
+ sort.Strings(result)
+
+ return result
+}
+
+func (w *Watcher) checkSinglePort(
+ ctx context.Context,
+ ip string,
+ port int,
+ hostnames []string,
+) {
+ result, err := w.portCheck.CheckPort(ctx, ip, port)
+ if err != nil {
+ w.log.Error(
+ "port check failed",
+ "ip", ip,
+ "port", port,
+ "error", err,
+ )
+
+ return
+ }
+
+ key := fmt.Sprintf("%s:%d", ip, port)
+ now := time.Now().UTC()
+ prev, hasPrev := w.state.GetPortState(key)
+
+ if hasPrev && !w.firstRun && prev.Open != result.Open {
+ stateStr := "closed"
+ if result.Open {
+ stateStr = "open"
+ }
+
+ msg := fmt.Sprintf(
+ "Hosts: %s\nAddress: %s\nPort now %s",
+ strings.Join(hostnames, ", "), key, stateStr,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "Port Change: "+key,
+ msg,
+ "warning",
+ )
+ }
+
+ w.state.SetPortState(key, &state.PortState{
+ Open: result.Open,
+ Hostnames: hostnames,
+ LastChecked: now,
+ })
+}
+
+func (w *Watcher) runTLSChecks(ctx context.Context) {
+ for _, hostname := range w.config.Hostnames {
+ w.checkTLSForHostname(ctx, hostname)
+ }
+
+ for _, domain := range w.config.Domains {
+ w.checkTLSForHostname(ctx, domain)
+ }
+}
+
+func (w *Watcher) checkTLSForHostname(
+ ctx context.Context,
+ hostname string,
+) {
+ ips := w.collectIPs(hostname)
+
+ for _, ip := range ips {
+ portKey := fmt.Sprintf("%s:%d", ip, tlsPort)
+
+ ps, ok := w.state.GetPortState(portKey)
+ if !ok || !ps.Open {
+ continue
+ }
+
+ w.checkTLSCert(ctx, ip, hostname)
+ }
+}
+
+func (w *Watcher) checkTLSCert(
+ ctx context.Context,
+ ip string,
+ hostname string,
+) {
+ cert, err := w.tlsCheck.CheckCertificate(ctx, ip, hostname)
+ certKey := fmt.Sprintf("%s:%d:%s", ip, tlsPort, hostname)
+ now := time.Now().UTC()
+ prev, hasPrev := w.state.GetCertificateState(certKey)
+
+ if err != nil {
+ w.handleTLSError(
+ ctx, certKey, hostname, ip,
+ hasPrev, prev, now, err,
+ )
+
+ return
+ }
+
+ w.handleTLSSuccess(
+ ctx, certKey, hostname, ip,
+ hasPrev, prev, now, cert,
+ )
+}
+
+func (w *Watcher) handleTLSError(
+ ctx context.Context,
+ certKey, hostname, ip string,
+ hasPrev bool,
+ prev *state.CertificateState,
+ now time.Time,
+ err error,
+) {
+ if hasPrev && !w.firstRun && prev.Status == "ok" {
+ msg := fmt.Sprintf(
+ "Host: %s\nIP: %s\nError: %s",
+ hostname, ip, err,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "TLS Failure: "+hostname,
+ msg,
+ "error",
+ )
+ }
+
+ w.state.SetCertificateState(
+ certKey, &state.CertificateState{
+ Status: "error",
+ Error: err.Error(),
+ LastChecked: now,
+ },
+ )
+}
+
+func (w *Watcher) handleTLSSuccess(
+ ctx context.Context,
+ certKey, hostname, ip string,
+ hasPrev bool,
+ prev *state.CertificateState,
+ now time.Time,
+ cert *tlscheck.CertificateInfo,
+) {
+ if hasPrev && !w.firstRun {
+ w.detectTLSChanges(ctx, hostname, ip, prev, cert)
+ }
+
+ w.checkTLSExpiry(ctx, hostname, ip, cert)
+
+ w.state.SetCertificateState(
+ certKey, &state.CertificateState{
+ CommonName: cert.CommonName,
+ Issuer: cert.Issuer,
+ NotAfter: cert.NotAfter,
+ SubjectAlternativeNames: cert.SubjectAlternativeNames,
+ Status: "ok",
+ LastChecked: now,
+ },
+ )
+}
+
+func (w *Watcher) detectTLSChanges(
+ ctx context.Context,
+ hostname, ip string,
+ prev *state.CertificateState,
+ cert *tlscheck.CertificateInfo,
+) {
+ if prev.Status == "error" {
+ msg := fmt.Sprintf(
+ "Host: %s\nIP: %s\nTLS recovered",
+ hostname, ip,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "TLS Recovery: "+hostname,
+ msg,
+ "success",
+ )
+
+ return
+ }
+
+ changed := prev.CommonName != cert.CommonName ||
+ prev.Issuer != cert.Issuer ||
+ !sliceEqual(
+ prev.SubjectAlternativeNames,
+ cert.SubjectAlternativeNames,
+ )
+
+ if !changed {
+ return
+ }
+
+ msg := fmt.Sprintf(
+ "Host: %s\nIP: %s\n"+
+ "Old CN: %s, Issuer: %s\n"+
+ "New CN: %s, Issuer: %s",
+ hostname, ip,
+ prev.CommonName, prev.Issuer,
+ cert.CommonName, cert.Issuer,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "TLS Certificate Change: "+hostname,
+ msg,
+ "warning",
+ )
+}
+
+func (w *Watcher) checkTLSExpiry(
+ ctx context.Context,
+ hostname, ip string,
+ cert *tlscheck.CertificateInfo,
+) {
+ daysLeft := time.Until(cert.NotAfter).Hours() / hoursPerDay
+ warningDays := float64(w.config.TLSExpiryWarning)
+
+ if daysLeft > warningDays {
+ 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)",
+ hostname, ip, cert.CommonName,
+ cert.NotAfter.Format(time.RFC3339),
+ daysLeft,
+ )
+
+ w.notify.SendNotification(
+ ctx,
+ "TLS Expiry Warning: "+hostname,
+ msg,
+ "warning",
+ )
+}
+
+func (w *Watcher) saveState() {
+ err := w.state.Save()
+ if err != nil {
+ w.log.Error("failed to save state", "error", err)
+ }
+}
+
+// maybeSendTestNotification sends a startup status notification
+// after the first full scan completes, if SEND_TEST_NOTIFICATION
+// is enabled. The message is clearly informational ("all ok")
+// and not an error or anomaly alert.
+func (w *Watcher) maybeSendTestNotification(ctx context.Context) {
+ if !w.config.SendTestNotification {
+ return
+ }
+
+ snap := w.state.GetSnapshot()
+
+ msg := fmt.Sprintf(
+ "dnswatcher has started and completed its initial scan.\n"+
+ "Monitoring %d domain(s) and %d hostname(s).\n"+
+ "Tracking %d port endpoint(s) and %d TLS certificate(s).\n"+
+ "All notification channels are working.",
+ len(snap.Domains),
+ len(snap.Hostnames),
+ len(snap.Ports),
+ len(snap.Certificates),
+ )
+
+ w.log.Info("sending startup test notification")
+
+ w.notify.SendNotification(
+ ctx,
+ "✅ dnswatcher startup complete",
+ msg,
+ "success",
+ )
+}
+
+// --- Utility functions ---
+
+func toSet(items []string) map[string]bool {
+ set := make(map[string]bool, len(items))
+ for _, item := range items {
+ set[item] = true
+ }
+
+ return set
+}
+
+func recordsEqual(
+ a, b map[string][]string,
+) bool {
+ if len(a) != len(b) {
+ return false
+ }
+
+ for k, av := range a {
+ bv, ok := b[k]
+ if !ok || !sliceEqual(av, bv) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func sliceEqual(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+
+ aSorted := make([]string, len(a))
+ bSorted := make([]string, len(b))
+
+ copy(aSorted, a)
+ copy(bSorted, b)
+
+ sort.Strings(aSorted)
+ sort.Strings(bSorted)
+
+ for i := range aSorted {
+ if aSorted[i] != bSorted[i] {
+ return false
+ }
+ }
+
+ return true
}
diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go
new file mode 100644
index 0000000..ea6dbef
--- /dev/null
+++ b/internal/watcher/watcher_test.go
@@ -0,0 +1,904 @@
+package watcher_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "sneak.berlin/go/dnswatcher/internal/config"
+ "sneak.berlin/go/dnswatcher/internal/portcheck"
+ "sneak.berlin/go/dnswatcher/internal/state"
+ "sneak.berlin/go/dnswatcher/internal/tlscheck"
+ "sneak.berlin/go/dnswatcher/internal/watcher"
+)
+
+// errNotFound is returned when mock data is missing.
+var errNotFound = errors.New("not found")
+
+// --- Mock implementations ---
+
+type mockResolver struct {
+ mu sync.Mutex
+ nsRecords map[string][]string
+ allRecords map[string]map[string]map[string][]string
+ ipAddresses map[string][]string
+ lookupNSErr error
+ allRecordsErr error
+ resolveIPErr error
+ lookupNSCalls int
+ allRecordCalls int
+}
+
+func (m *mockResolver) LookupNS(
+ _ context.Context,
+ domain string,
+) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.lookupNSCalls++
+
+ if m.lookupNSErr != nil {
+ return nil, m.lookupNSErr
+ }
+
+ ns, ok := m.nsRecords[domain]
+ if !ok {
+ return nil, fmt.Errorf(
+ "%w: NS for %s", errNotFound, domain,
+ )
+ }
+
+ return ns, nil
+}
+
+func (m *mockResolver) LookupAllRecords(
+ _ context.Context,
+ hostname string,
+) (map[string]map[string][]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.allRecordCalls++
+
+ if m.allRecordsErr != nil {
+ return nil, m.allRecordsErr
+ }
+
+ recs, ok := m.allRecords[hostname]
+ if !ok {
+ return nil, fmt.Errorf(
+ "%w: records for %s", errNotFound, hostname,
+ )
+ }
+
+ return recs, nil
+}
+
+func (m *mockResolver) ResolveIPAddresses(
+ _ context.Context,
+ hostname string,
+) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.resolveIPErr != nil {
+ return nil, m.resolveIPErr
+ }
+
+ ips, ok := m.ipAddresses[hostname]
+ if !ok {
+ return nil, fmt.Errorf(
+ "%w: IPs for %s", errNotFound, hostname,
+ )
+ }
+
+ return ips, nil
+}
+
+type mockPortChecker struct {
+ mu sync.Mutex
+ results map[string]bool
+ err error
+ calls int
+}
+
+func (m *mockPortChecker) CheckPort(
+ _ context.Context,
+ address string,
+ port int,
+) (*portcheck.PortResult, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.calls++
+
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ key := fmt.Sprintf("%s:%d", address, port)
+ open := m.results[key]
+
+ return &portcheck.PortResult{Open: open}, nil
+}
+
+type mockTLSChecker struct {
+ mu sync.Mutex
+ certs map[string]*tlscheck.CertificateInfo
+ err error
+ calls int
+}
+
+func (m *mockTLSChecker) CheckCertificate(
+ _ context.Context,
+ ip string,
+ hostname string,
+) (*tlscheck.CertificateInfo, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.calls++
+
+ if m.err != nil {
+ return nil, m.err
+ }
+
+ key := fmt.Sprintf("%s:%s", ip, hostname)
+ cert, ok := m.certs[key]
+
+ if !ok {
+ return nil, fmt.Errorf(
+ "%w: cert for %s", errNotFound, key,
+ )
+ }
+
+ return cert, nil
+}
+
+type notification struct {
+ Title string
+ Message string
+ Priority string
+}
+
+type mockNotifier struct {
+ mu sync.Mutex
+ notifications []notification
+}
+
+func (m *mockNotifier) SendNotification(
+ _ context.Context,
+ title, message, priority string,
+) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.notifications = append(m.notifications, notification{
+ Title: title,
+ Message: message,
+ Priority: priority,
+ })
+}
+
+func (m *mockNotifier) getNotifications() []notification {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ result := make([]notification, len(m.notifications))
+ copy(result, m.notifications)
+
+ return result
+}
+
+// --- Helper to build a Watcher for testing ---
+
+type testDeps struct {
+ resolver *mockResolver
+ portChecker *mockPortChecker
+ tlsChecker *mockTLSChecker
+ notifier *mockNotifier
+ state *state.State
+ config *config.Config
+}
+
+func newTestWatcher(
+ t *testing.T,
+ cfg *config.Config,
+) (*watcher.Watcher, *testDeps) {
+ t.Helper()
+
+ deps := &testDeps{
+ resolver: &mockResolver{
+ nsRecords: make(map[string][]string),
+ allRecords: make(map[string]map[string]map[string][]string),
+ ipAddresses: make(map[string][]string),
+ },
+ portChecker: &mockPortChecker{
+ results: make(map[string]bool),
+ },
+ tlsChecker: &mockTLSChecker{
+ certs: make(map[string]*tlscheck.CertificateInfo),
+ },
+ notifier: &mockNotifier{},
+ config: cfg,
+ }
+
+ deps.state = state.NewForTest()
+
+ w := watcher.NewForTest(
+ deps.config,
+ deps.state,
+ deps.resolver,
+ deps.portChecker,
+ deps.tlsChecker,
+ deps.notifier,
+ )
+
+ return w, deps
+}
+
+func defaultTestConfig(t *testing.T) *config.Config {
+ t.Helper()
+
+ return &config.Config{
+ DNSInterval: time.Hour,
+ TLSInterval: 12 * time.Hour,
+ TLSExpiryWarning: 7,
+ DataDir: t.TempDir(),
+ }
+}
+
+func TestFirstRunBaseline(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+ cfg.Hostnames = []string{"www.example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+ setupBaselineMocks(deps)
+
+ w.RunOnce(t.Context())
+
+ assertNoNotifications(t, deps)
+ assertStatePopulated(t, deps)
+}
+
+func setupBaselineMocks(deps *testDeps) {
+ deps.resolver.nsRecords["example.com"] = []string{
+ "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"}},
+ }
+ 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: "www.example.com",
+ Issuer: "DigiCert",
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{
+ "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(
+ t *testing.T,
+ deps *testDeps,
+) {
+ t.Helper()
+
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) != 0 {
+ t.Errorf(
+ "expected 0 notifications on first run, got %d",
+ len(notifications),
+ )
+ }
+}
+
+func assertStatePopulated(
+ t *testing.T,
+ deps *testDeps,
+) {
+ t.Helper()
+
+ snap := deps.state.GetSnapshot()
+
+ if len(snap.Domains) != 1 {
+ t.Errorf(
+ "expected 1 domain in state, got %d",
+ len(snap.Domains),
+ )
+ }
+
+ // 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 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()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+
+ deps.resolver.nsRecords["example.com"] = []string{
+ "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)
+
+ deps.resolver.mu.Lock()
+ deps.resolver.nsRecords["example.com"] = []string{
+ "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)
+
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) == 0 {
+ t.Error("expected notification for NS change")
+ }
+
+ found := false
+
+ for _, n := range notifications {
+ if n.Priority == "warning" {
+ found = true
+ }
+ }
+
+ if !found {
+ t.Error("expected warning-priority NS change notification")
+ }
+}
+
+func TestRecordChangeDetection(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+
+ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"93.184.216.34"}},
+ }
+ 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
+
+ ctx := t.Context()
+ w.RunOnce(ctx)
+
+ deps.resolver.mu.Lock()
+ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"93.184.216.35"}},
+ }
+ deps.resolver.ipAddresses["www.example.com"] = []string{
+ "93.184.216.35",
+ }
+ deps.resolver.mu.Unlock()
+
+ deps.portChecker.mu.Lock()
+ deps.portChecker.results["93.184.216.35:80"] = false
+ deps.portChecker.results["93.184.216.35:443"] = false
+ deps.portChecker.mu.Unlock()
+
+ w.RunOnce(ctx)
+
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) == 0 {
+ t.Error("expected notification for record change")
+ }
+}
+
+func TestPortStateChange(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+
+ 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(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{
+ "www.example.com",
+ },
+ }
+
+ ctx := t.Context()
+ w.RunOnce(ctx)
+
+ deps.portChecker.mu.Lock()
+ deps.portChecker.results["1.2.3.4:443"] = false
+ deps.portChecker.mu.Unlock()
+
+ w.RunOnce(ctx)
+
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) == 0 {
+ t.Error("expected notification for port state change")
+ }
+}
+
+func TestTLSExpiryWarning(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+
+ 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
+ w.RunOnce(ctx)
+
+ // Second run should warn about expiry
+ w.RunOnce(ctx)
+
+ notifications := deps.notifier.getNotifications()
+
+ found := false
+
+ for _, n := range notifications {
+ if n.Priority == "warning" {
+ found = true
+ }
+ }
+
+ if !found {
+ t.Errorf(
+ "expected expiry warning, got: %v",
+ notifications,
+ )
+ }
+}
+
+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()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+ cfg.DNSInterval = 100 * time.Millisecond
+ cfg.TLSInterval = 100 * time.Millisecond
+
+ 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": {"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())
+
+ done := make(chan struct{})
+
+ go func() {
+ w.Run(ctx)
+ close(done)
+ }()
+
+ time.Sleep(250 * time.Millisecond)
+ cancel()
+
+ select {
+ case <-done:
+ // Shut down cleanly
+ case <-time.After(5 * time.Second):
+ t.Error("watcher did not shut down within timeout")
+ }
+}
+
+func setupHostnameIP(
+ deps *testDeps,
+ hostname, ip string,
+) {
+ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
+ "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: "DigiCert",
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{hostname},
+ }
+}
+
+func updateHostnameIP(deps *testDeps, hostname, ip string) {
+ deps.resolver.mu.Lock()
+ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {ip}},
+ }
+ deps.resolver.mu.Unlock()
+
+ deps.portChecker.mu.Lock()
+ deps.portChecker.results[ip+":80"] = true
+ deps.portChecker.results[ip+":443"] = true
+ deps.portChecker.mu.Unlock()
+
+ deps.tlsChecker.mu.Lock()
+ deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
+ CommonName: hostname,
+ Issuer: "DigiCert",
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{hostname},
+ }
+ deps.tlsChecker.mu.Unlock()
+}
+
+func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+
+ setupHostnameIP(deps, "www.example.com", "10.0.0.1")
+
+ ctx := t.Context()
+ w.RunOnce(ctx)
+
+ snap := deps.state.GetSnapshot()
+ if _, ok := snap.Ports["10.0.0.1:80"]; !ok {
+ t.Fatal("expected port state for 10.0.0.1:80")
+ }
+
+ // DNS changes to a new IP; port and TLS must pick it up.
+ updateHostnameIP(deps, "www.example.com", "10.0.0.2")
+
+ w.RunOnce(ctx)
+
+ snap = deps.state.GetSnapshot()
+
+ if _, ok := snap.Ports["10.0.0.2:80"]; !ok {
+ t.Error("port check used stale DNS: missing 10.0.0.2:80")
+ }
+
+ certKey := "10.0.0.2:443:www.example.com"
+ if _, ok := snap.Certificates[certKey]; !ok {
+ t.Error("TLS check used stale DNS: missing " + certKey)
+ }
+}
+
+func TestSendTestNotification_Enabled(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+ cfg.Hostnames = []string{"www.example.com"}
+ cfg.SendTestNotification = true
+
+ w, deps := newTestWatcher(t, cfg)
+ setupBaselineMocks(deps)
+
+ w.RunOnce(t.Context())
+
+ // RunOnce does not send the test notification — it is
+ // sent by Run after RunOnce completes. Call the exported
+ // RunOnce then check that no test notification was sent
+ // (only Run triggers it). We test the full path via Run.
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) != 0 {
+ t.Errorf(
+ "RunOnce should not send test notification, got %d",
+ len(notifications),
+ )
+ }
+}
+
+func TestSendTestNotification_ViaRun(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+ cfg.Hostnames = []string{"www.example.com"}
+ cfg.SendTestNotification = true
+ cfg.DNSInterval = 24 * time.Hour
+ cfg.TLSInterval = 24 * time.Hour
+
+ w, deps := newTestWatcher(t, cfg)
+ setupBaselineMocks(deps)
+
+ ctx, cancel := context.WithCancel(t.Context())
+
+ done := make(chan struct{})
+
+ go func() {
+ w.Run(ctx)
+ close(done)
+ }()
+
+ // Wait for the initial scan and test notification.
+ time.Sleep(500 * time.Millisecond)
+ cancel()
+
+ <-done
+
+ notifications := deps.notifier.getNotifications()
+
+ found := false
+
+ for _, n := range notifications {
+ if n.Priority == "success" &&
+ n.Title == "✅ dnswatcher startup complete" {
+ found = true
+ }
+ }
+
+ if !found {
+ t.Errorf(
+ "expected startup test notification, got: %v",
+ notifications,
+ )
+ }
+}
+
+func TestSendTestNotification_Disabled(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+ cfg.Hostnames = []string{"www.example.com"}
+ cfg.SendTestNotification = false
+ cfg.DNSInterval = 24 * time.Hour
+ cfg.TLSInterval = 24 * time.Hour
+
+ w, deps := newTestWatcher(t, cfg)
+ setupBaselineMocks(deps)
+
+ ctx, cancel := context.WithCancel(t.Context())
+
+ done := make(chan struct{})
+
+ go func() {
+ w.Run(ctx)
+ close(done)
+ }()
+
+ time.Sleep(500 * time.Millisecond)
+ cancel()
+
+ <-done
+
+ notifications := deps.notifier.getNotifications()
+
+ for _, n := range notifications {
+ if n.Title == "✅ dnswatcher startup complete" {
+ t.Error(
+ "test notification should not be sent when disabled",
+ )
+ }
+ }
+}
+
+func TestNSFailureAndRecovery(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+
+ 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["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
+
+ ctx := t.Context()
+
+ w.RunOnce(ctx)
+
+ deps.resolver.mu.Lock()
+ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"1.2.3.4"}},
+ }
+ deps.resolver.mu.Unlock()
+
+ w.RunOnce(ctx)
+
+ notifications := deps.notifier.getNotifications()
+ if len(notifications) == 0 {
+ t.Error("expected notification for NS disappearance")
+ }
+}
diff --git a/script/bootstrap b/script/bootstrap
new file mode 100755
index 0000000..ffcb29a
--- /dev/null
+++ b/script/bootstrap
@@ -0,0 +1,82 @@
+#!/bin/sh
+# script/bootstrap: install all dependencies needed to build and develop
+# this repo. Idempotent: every install is guarded by a check so already
+# installed tools are skipped. Base tooling comes from nix, apt, brew,
+# or apk (detected in that order); assumes nothing is present.
+# golangci-lint and goimports are installed via `go install` at the same
+# pinned commits the Dockerfile uses (never "latest").
+set -eu
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+
+# 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"
+
+PKGMGR=""
+SUDO=""
+APT_UPDATED=""
+
+detect_pkgmgr() {
+ [ -n "$PKGMGR" ] && return 0
+ if command -v nix-env >/dev/null 2>&1; then
+ PKGMGR="nix"
+ elif command -v apt-get >/dev/null 2>&1; then
+ PKGMGR="apt"
+ elif command -v brew >/dev/null 2>&1; then
+ PKGMGR="brew"
+ elif command -v apk >/dev/null 2>&1; then
+ PKGMGR="apk"
+ else
+ echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
+ exit 1
+ fi
+ if [ "$PKGMGR" = "apt" ]; then
+ export DEBIAN_FRONTEND=noninteractive
+ if [ "$(id -u)" != "0" ]; then
+ SUDO="sudo"
+ fi
+ fi
+}
+
+# pkg_install