+ {{/* ---- 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 ea57155..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,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"net/http"
"net/url"
@@ -34,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
@@ -47,11 +107,14 @@ type Params struct {
// Service provides notification functionality.
type Service struct {
log *slog.Logger
- client *http.Client
+ 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.
@@ -60,33 +123,42 @@ func New(
params Params,
) (*Service, error) {
svc := &Service{
- log: params.Logger.Get(),
- client: &http.Client{
- Timeout: httpClientTimeout,
- },
- config: params.Config,
+ log: params.Logger.Get(),
+ transport: http.DefaultTransport,
+ config: params.Config,
+ history: NewAlertHistory(),
}
if params.Config.NtfyTopic != "" {
- u, err := url.ParseRequestURI(params.Config.NtfyTopic)
+ u, err := ValidateWebhookURL(
+ params.Config.NtfyTopic,
+ )
if err != nil {
- return nil, fmt.Errorf("invalid ntfy topic URL: %w", err)
+ return nil, fmt.Errorf(
+ "invalid ntfy topic URL: %w", err,
+ )
}
svc.ntfyURL = u
}
if params.Config.SlackWebhook != "" {
- u, err := url.ParseRequestURI(params.Config.SlackWebhook)
+ u, err := ValidateWebhookURL(
+ params.Config.SlackWebhook,
+ )
if err != nil {
- return nil, fmt.Errorf("invalid slack webhook URL: %w", err)
+ return nil, fmt.Errorf(
+ "invalid slack webhook URL: %w", err,
+ )
}
svc.slackWebhookURL = u
}
if params.Config.MattermostWebhook != "" {
- u, err := url.ParseRequestURI(params.Config.MattermostWebhook)
+ u, err := ValidateWebhookURL(
+ params.Config.MattermostWebhook,
+ )
if err != nil {
return nil, fmt.Errorf(
"invalid mattermost webhook URL: %w", err,
@@ -99,64 +171,117 @@ func New(
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.ntfyURL != nil {
- 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.ntfyURL,
- 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.slackWebhookURL != nil {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.slackWebhookURL,
- 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.mattermostWebhookURL != nil {
- go func() {
- notifyCtx := context.WithoutCancel(ctx)
+ go func() {
+ notifyCtx := context.WithoutCancel(ctx)
- err := svc.sendSlack(
- notifyCtx,
- svc.mattermostWebhookURL,
- 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(
@@ -170,20 +295,20 @@ func (svc *Service) sendNtfy(
"title", title,
)
- request, err := http.NewRequestWithContext(
- ctx,
- http.MethodPost,
- topicURL.String(),
- 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) //nolint:gosec // URL validated at Service construction time
+ resp, err := svc.transport.RoundTrip(request)
if err != nil {
return fmt.Errorf("sending ntfy request: %w", err)
}
@@ -192,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,
)
}
@@ -232,6 +358,11 @@ func (svc *Service) sendSlack(
webhookURL *url.URL,
title, message, priority string,
) error {
+ ctx, cancel := context.WithTimeout(
+ ctx, httpClientTimeout,
+ )
+ defer cancel()
+
svc.log.Debug(
"sending webhook notification",
"url", webhookURL.String(),
@@ -250,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.String(),
+ 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) //nolint:gosec // URL validated at Service construction time
+ 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
new file mode 100644
index 0000000..3f203d4
--- /dev/null
+++ b/internal/resolver/errors.go
@@ -0,0 +1,22 @@
+package resolver
+
+import "errors"
+
+// Sentinel errors returned by the resolver.
+var (
+ // ErrNoNameservers is returned when no authoritative NS
+ // could be discovered for a domain.
+ ErrNoNameservers = errors.New(
+ "no authoritative nameservers found",
+ )
+
+ // ErrCNAMEDepthExceeded is returned when a CNAME chain
+ // exceeds MaxCNAMEDepth.
+ ErrCNAMEDepthExceeded = errors.New(
+ "CNAME chain depth exceeded",
+ )
+
+ // ErrContextCanceled wraps context cancellation for the
+ // resolver's iterative queries.
+ ErrContextCanceled = errors.New("context canceled")
+)
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 76432d9..aec9b89 100644
--- a/internal/resolver/resolver.go
+++ b/internal/resolver/resolver.go
@@ -1,9 +1,9 @@
// Package resolver provides iterative DNS resolution from root nameservers.
+// It traces the full delegation chain from IANA root servers through TLD
+// and domain nameservers, never relying on upstream recursive resolvers.
package resolver
import (
- "context"
- "errors"
"log/slog"
"go.uber.org/fx"
@@ -11,8 +11,17 @@ import (
"sneak.berlin/go/dnswatcher/internal/logger"
)
-// ErrNotImplemented indicates the resolver is not yet implemented.
-var ErrNotImplemented = errors.New("resolver not yet implemented")
+// Query status constants matching the state model.
+const (
+ StatusOK = "ok"
+ StatusError = "error"
+ StatusNXDomain = "nxdomain"
+ StatusNoData = "nodata"
+ StatusTimeout = "timeout"
+)
+
+// MaxCNAMEDepth is the maximum CNAME chain depth to follow.
+const MaxCNAMEDepth = 10
// Params contains dependencies for Resolver.
type Params struct {
@@ -21,44 +30,54 @@ type Params struct {
Logger *logger.Logger
}
-// Resolver performs iterative DNS resolution from root servers.
-type Resolver struct {
- log *slog.Logger
+// NameserverResponse holds one nameserver's response for a query.
+type NameserverResponse struct {
+ Nameserver string
+ Records map[string][]string
+ Status string
+ Error string
}
-// New creates a new Resolver instance.
+// Resolver performs iterative DNS resolution from root servers.
+type Resolver struct {
+ log *slog.Logger
+ client DNSClient
+ tcp DNSClient
+}
+
+// New creates a new Resolver instance for use with uber/fx.
func New(
_ fx.Lifecycle,
params Params,
) (*Resolver, error) {
return &Resolver{
- log: params.Logger.Get(),
+ log: params.Logger.Get(),
+ client: &udpClient{timeout: queryTimeoutDuration},
+ tcp: &tcpClient{timeout: queryTimeoutDuration},
}, nil
}
-// LookupNS performs iterative resolution to find authoritative
-// nameservers for the given domain.
-func (r *Resolver) LookupNS(
- _ context.Context,
- _ string,
-) ([]string, error) {
- return nil, ErrNotImplemented
+// 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,
+ client: &udpClient{timeout: queryTimeoutDuration},
+ tcp: &tcpClient{timeout: queryTimeoutDuration},
+ }
}
-// LookupAllRecords performs iterative resolution to find all DNS
-// records for the given hostname, keyed by authoritative nameserver.
-func (r *Resolver) LookupAllRecords(
- _ context.Context,
- _ string,
-) (map[string]map[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,
+ }
}
-// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
-// addresses, following CNAME chains.
-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
new file mode 100644
index 0000000..bcebfb9
--- /dev/null
+++ b/internal/resolver/resolver_test.go
@@ -0,0 +1,688 @@
+package resolver_test
+
+import (
+ "context"
+ "log/slog"
+ "net"
+ "os"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sneak.berlin/go/dnswatcher/internal/resolver"
+)
+
+// ----------------------------------------------------------------
+// Test helpers
+// ----------------------------------------------------------------
+
+func newTestResolver(t *testing.T) *resolver.Resolver {
+ t.Helper()
+
+ log := slog.New(slog.NewTextHandler(
+ os.Stderr,
+ &slog.HandlerOptions{Level: slog.LevelDebug},
+ ))
+
+ return resolver.NewFromLogger(log)
+}
+
+func testContext(t *testing.T) context.Context {
+ t.Helper()
+
+ ctx, cancel := context.WithTimeout(
+ context.Background(), 60*time.Second,
+ )
+ t.Cleanup(cancel)
+
+ return ctx
+}
+
+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,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+ require.NotEmpty(t, nameservers)
+
+ hasGoogleNS := false
+
+ for _, ns := range nameservers {
+ if strings.Contains(ns, "google") {
+ hasGoogleNS = true
+
+ break
+ }
+ }
+
+ assert.True(t, hasGoogleNS,
+ "expected google nameservers, got: %v", nameservers,
+ )
+}
+
+func TestFindAuthoritativeNameservers_Subdomain(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.FindAuthoritativeNameservers(
+ ctx, "www.google.com",
+ )
+ require.NoError(t, err)
+ require.NotEmpty(t, nameservers)
+}
+
+func TestFindAuthoritativeNameservers_ReturnsSorted(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+
+ assert.True(
+ t,
+ sort.StringsAreSorted(nameservers),
+ "nameservers should be sorted, got: %v", nameservers,
+ )
+}
+
+func TestFindAuthoritativeNameservers_Deterministic(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ first, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+
+ second, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, first, second)
+}
+
+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,
+ )
+ }
+}
+
+// ----------------------------------------------------------------
+// QueryNameserver tests
+// ----------------------------------------------------------------
+
+func TestQueryNameserver_BasicA(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ 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)
+
+ hasRecords := len(resp.Records["A"]) > 0 ||
+ len(resp.Records["CNAME"]) > 0
+ assert.True(t, hasRecords,
+ "expected A or CNAME records for www.google.com",
+ )
+}
+
+func TestQueryNameserver_AAAA(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns, "cloudflare.com",
+ )
+ require.NoError(t, err)
+
+ aaaaRecords := resp.Records["AAAA"]
+ require.NotEmpty(t, aaaaRecords,
+ "cloudflare.com should have AAAA records",
+ )
+
+ for _, ip := range aaaaRecords {
+ parsed := net.ParseIP(ip)
+ require.NotNil(t, parsed,
+ "should be valid IP: %s", ip,
+ )
+ }
+}
+
+func TestQueryNameserver_MX(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
+ require.NoError(t, err)
+
+ mxRecords := resp.Records["MX"]
+ require.NotEmpty(t, mxRecords,
+ "google.com should have MX records",
+ )
+}
+
+func TestQueryNameserver_TXT(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
+ require.NoError(t, err)
+
+ txtRecords := resp.Records["TXT"]
+ require.NotEmpty(t, txtRecords,
+ "google.com should have TXT records",
+ )
+
+ hasSPF := false
+
+ for _, txt := range txtRecords {
+ if strings.Contains(txt, "v=spf1") {
+ hasSPF = true
+
+ break
+ }
+ }
+
+ assert.True(t, hasSPF,
+ "google.com should have SPF TXT record",
+ )
+}
+
+func TestQueryNameserver_NXDomain(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ 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) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns, "google.com",
+ )
+ require.NoError(t, err)
+
+ for recordType, values := range resp.Records {
+ assert.True(
+ t,
+ sort.StringsAreSorted(values),
+ "%s records should be sorted", recordType,
+ )
+ }
+}
+
+func TestQueryNameserver_ResponseIncludesNameserver(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns, "cloudflare.com",
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, ns, resp.Nameserver)
+}
+
+func TestQueryNameserver_EmptyRecordsOnNXDomain(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+ ns := findOneNSForDomain(t, r, ctx, "google.com")
+
+ resp, err := r.QueryNameserver(
+ ctx, ns,
+ "this-surely-does-not-exist-xyz.google.com",
+ )
+ require.NoError(t, err)
+
+ totalRecords := 0
+ for _, values := range resp.Records {
+ totalRecords += len(values)
+ }
+
+ 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)
+}
+
+// ----------------------------------------------------------------
+// QueryAllNameservers tests
+// ----------------------------------------------------------------
+
+func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ results, err := r.QueryAllNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+ require.NotEmpty(t, results)
+
+ assert.GreaterOrEqual(t, len(results), 2)
+
+ for ns, resp := range results {
+ assert.Equal(t, ns, resp.Nameserver)
+ }
+}
+
+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_NXDomainFromAllNS(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ results, err := r.QueryAllNameservers(
+ ctx,
+ "this-surely-does-not-exist-xyz.google.com",
+ )
+ require.NoError(t, err)
+
+ for ns, resp := range results {
+ assert.Equal(
+ t, resolver.StatusNXDomain, resp.Status,
+ "NS %s should return nxdomain", ns,
+ )
+ }
+}
+
+// ----------------------------------------------------------------
+// LookupNS tests
+// ----------------------------------------------------------------
+
+func TestLookupNS_ValidDomain(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.LookupNS(ctx, "google.com")
+ require.NoError(t, err)
+ require.NotEmpty(t, nameservers)
+
+ for _, ns := range nameservers {
+ assert.True(t, strings.HasSuffix(ns, "."),
+ "NS should have trailing dot: %s", ns,
+ )
+ }
+}
+
+func TestLookupNS_Sorted(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ nameservers, err := r.LookupNS(ctx, "google.com")
+ require.NoError(t, err)
+
+ assert.True(t, sort.StringsAreSorted(nameservers))
+}
+
+func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ fromLookup, err := r.LookupNS(ctx, "google.com")
+ require.NoError(t, err)
+
+ fromFind, err := r.FindAuthoritativeNameservers(
+ ctx, "google.com",
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, fromFind, fromLookup)
+}
+
+// ----------------------------------------------------------------
+// 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_Deduplicated(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ips, err := r.ResolveIPAddresses(ctx, "google.com")
+ require.NoError(t, err)
+
+ seen := make(map[string]bool)
+
+ for _, ip := range ips {
+ assert.False(t, seen[ip], "duplicate IP: %s", ip)
+ seen[ip] = true
+ }
+}
+
+func TestResolveIPAddresses_Sorted(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ips, err := r.ResolveIPAddresses(ctx, "google.com")
+ require.NoError(t, err)
+
+ assert.True(t, sort.StringsAreSorted(ips))
+}
+
+func TestResolveIPAddresses_NXDomainReturnsEmpty(
+ t *testing.T,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx := testContext(t)
+
+ ips, err := r.ResolveIPAddresses(
+ ctx,
+ "this-surely-does-not-exist-xyz.google.com",
+ )
+ require.NoError(t, err)
+ assert.Empty(t, ips)
+}
+
+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,
+) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := r.FindAuthoritativeNameservers(ctx, "google.com")
+ assert.Error(t, err)
+}
+
+func TestQueryNameserver_ContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := r.QueryNameserver(
+ ctx, "ns1.google.com.", "google.com",
+ )
+ assert.Error(t, err)
+}
+
+func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := r.QueryAllNameservers(ctx, "google.com")
+ assert.Error(t, err)
+}
+
+// ----------------------------------------------------------------
+// Timeout tests
+// ----------------------------------------------------------------
+
+func TestQueryNameserverIP_Timeout(t *testing.T) {
+ t.Parallel()
+
+ log := slog.New(slog.NewTextHandler(
+ os.Stderr,
+ &slog.HandlerOptions{Level: slog.LevelDebug},
+ ))
+
+ r := resolver.NewFromLoggerWithClient(
+ log, &timeoutClient{},
+ )
+
+ ctx, cancel := context.WithTimeout(
+ context.Background(), 10*time.Second,
+ )
+ t.Cleanup(cancel)
+
+ // Query any IP — the client always returns a timeout error.
+ resp, err := r.QueryNameserverIP(
+ ctx, "unreachable.test.", "192.0.2.1",
+ "example.com",
+ )
+ require.NoError(t, err)
+
+ assert.Equal(t, resolver.StatusTimeout, resp.Status)
+ assert.NotEmpty(t, resp.Error)
+}
+
+// timeoutClient simulates DNS timeout errors for testing.
+type timeoutClient struct{}
+
+func (c *timeoutClient) ExchangeContext(
+ _ context.Context,
+ _ *dns.Msg,
+ _ string,
+) (*dns.Msg, time.Duration, error) {
+ return nil, 0, &net.OpError{
+ Op: "read",
+ Net: "udp",
+ Err: &timeoutError{},
+ }
+}
+
+type timeoutError struct{}
+
+func (e *timeoutError) Error() string { return "i/o timeout" }
+func (e *timeoutError) Timeout() bool { return true }
+func (e *timeoutError) Temporary() bool { return true }
+
+func TestResolveIPAddresses_ContextCanceled(t *testing.T) {
+ t.Parallel()
+
+ r := newTestResolver(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, err := r.ResolveIPAddresses(ctx, "google.com")
+ assert.Error(t, err)
+}
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
index 2142bfb..7e9aac7 100644
--- a/internal/state/state_test_helper.go
+++ b/internal/state/state_test_helper.go
@@ -20,3 +20,19 @@ func NewForTest() *State {
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
index 695139d..dd68017 100644
--- a/internal/watcher/interfaces.go
+++ b/internal/watcher/interfaces.go
@@ -4,6 +4,7 @@ package watcher
import (
"context"
+ "sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/tlscheck"
)
@@ -36,7 +37,7 @@ type PortChecker interface {
ctx context.Context,
address string,
port int,
- ) (bool, error)
+ ) (*portcheck.PortResult, error)
}
// TLSChecker inspects TLS certificates.
diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go
index 2493264..bdf4f22 100644
--- a/internal/watcher/watcher.go
+++ b/internal/watcher/watcher.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"sort"
"strings"
+ "sync"
"time"
"go.uber.org/fx"
@@ -40,15 +41,17 @@ type Params struct {
// Watcher orchestrates all monitoring checks on a schedule.
type Watcher struct {
- log *slog.Logger
- config *config.Config
- state *state.State
- resolver DNSResolver
- portCheck PortChecker
- tlsCheck TLSChecker
- notify Notifier
- cancel context.CancelFunc
- firstRun bool
+ log *slog.Logger
+ config *config.Config
+ state *state.State
+ resolver DNSResolver
+ portCheck PortChecker
+ tlsCheck TLSChecker
+ notify Notifier
+ cancel context.CancelFunc
+ firstRun bool
+ expiryNotifiedMu sync.Mutex
+ expiryNotified map[string]time.Time
}
// New creates a new Watcher instance wired into the fx lifecycle.
@@ -57,24 +60,27 @@ func New(
params Params,
) (*Watcher, error) {
w := &Watcher{
- log: params.Logger.Get(),
- config: params.Config,
- state: params.State,
- resolver: params.Resolver,
- portCheck: params.PortCheck,
- tlsCheck: params.TLSCheck,
- notify: params.Notify,
- firstRun: true,
+ log: params.Logger.Get(),
+ config: params.Config,
+ state: params.State,
+ resolver: params.Resolver,
+ portCheck: params.PortCheck,
+ tlsCheck: params.TLSCheck,
+ notify: params.Notify,
+ firstRun: true,
+ expiryNotified: make(map[string]time.Time),
}
lifecycle.Append(fx.Hook{
- OnStart: func(startCtx context.Context) error {
- ctx, cancel := context.WithCancel(
- context.WithoutCancel(startCtx),
- )
+ 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 w.Run(ctx)
+ go w.Run(ctx) //nolint:contextcheck // intentionally not derived from startCtx
return nil
},
@@ -100,14 +106,15 @@ func NewForTest(
n Notifier,
) *Watcher {
return &Watcher{
- log: slog.Default(),
- config: cfg,
- state: st,
- resolver: res,
- portCheck: pc,
- tlsCheck: tc,
- notify: n,
- firstRun: true,
+ log: slog.Default(),
+ config: cfg,
+ state: st,
+ resolver: res,
+ portCheck: pc,
+ tlsCheck: tc,
+ notify: n,
+ firstRun: true,
+ expiryNotified: make(map[string]time.Time),
}
}
@@ -122,6 +129,7 @@ func (w *Watcher) Run(ctx context.Context) {
)
w.RunOnce(ctx)
+ w.maybeSendTestNotification(ctx)
dnsTicker := time.NewTicker(w.config.DNSInterval)
tlsTicker := time.NewTicker(w.config.TLSInterval)
@@ -136,9 +144,16 @@ func (w *Watcher) Run(ctx context.Context) {
return
case <-dnsTicker.C:
- w.runDNSAndPortChecks(ctx)
+ 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()
}
@@ -146,10 +161,26 @@ func (w *Watcher) Run(ctx context.Context) {
}
// 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()
- w.runDNSAndPortChecks(ctx)
+
+ // 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
}
@@ -166,7 +197,11 @@ func (w *Watcher) detectFirstRun() {
}
}
-func (w *Watcher) runDNSAndPortChecks(ctx context.Context) {
+// 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)
}
@@ -174,8 +209,6 @@ func (w *Watcher) runDNSAndPortChecks(ctx context.Context) {
for _, hostname := range w.config.Hostnames {
w.checkHostname(ctx, hostname)
}
-
- w.checkAllPorts(ctx)
}
func (w *Watcher) checkDomain(
@@ -206,6 +239,28 @@ func (w *Watcher) checkDomain(
Nameservers: nameservers,
LastChecked: now,
})
+
+ // Also look up A/AAAA records for the apex domain so that
+ // port and TLS checks (which read HostnameState) can find
+ // the domain's IP addresses.
+ records, err := w.resolver.LookupAllRecords(ctx, domain)
+ if err != nil {
+ w.log.Error(
+ "failed to lookup records for domain",
+ "domain", domain,
+ "error", err,
+ )
+
+ return
+ }
+
+ prevHS, hasPrevHS := w.state.GetHostnameState(domain)
+ if hasPrevHS && !w.firstRun {
+ w.detectHostnameChanges(ctx, domain, prevHS, records)
+ }
+
+ newState := buildHostnameState(records, now)
+ w.state.SetHostnameState(domain, newState)
}
func (w *Watcher) detectNSChanges(
@@ -421,24 +476,94 @@ func (w *Watcher) detectInconsistencies(
}
func (w *Watcher) checkAllPorts(ctx context.Context) {
- for _, hostname := range w.config.Hostnames {
- w.checkPortsForHostname(ctx, hostname)
+ // 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)
}
- for _, domain := range w.config.Domains {
- w.checkPortsForHostname(ctx, domain)
- }
+ // Phase 3: Remove port state entries that no longer have
+ // any hostname referencing them.
+ w.cleanupStalePorts(associations)
}
-func (w *Watcher) checkPortsForHostname(
- ctx context.Context,
- hostname string,
-) {
- ips := w.collectIPs(hostname)
+// 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)
- for _, ip := range ips {
- for _, port := range monitoredPorts {
- w.checkSinglePort(ctx, ip, port, hostname)
+ 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)
}
}
}
@@ -475,9 +600,9 @@ func (w *Watcher) checkSinglePort(
ctx context.Context,
ip string,
port int,
- hostname string,
+ hostnames []string,
) {
- open, err := w.portCheck.CheckPort(ctx, ip, port)
+ result, err := w.portCheck.CheckPort(ctx, ip, port)
if err != nil {
w.log.Error(
"port check failed",
@@ -493,15 +618,15 @@ func (w *Watcher) checkSinglePort(
now := time.Now().UTC()
prev, hasPrev := w.state.GetPortState(key)
- if hasPrev && !w.firstRun && prev.Open != open {
+ if hasPrev && !w.firstRun && prev.Open != result.Open {
stateStr := "closed"
- if open {
+ if result.Open {
stateStr = "open"
}
msg := fmt.Sprintf(
- "Host: %s\nAddress: %s\nPort now %s",
- hostname, key, stateStr,
+ "Hosts: %s\nAddress: %s\nPort now %s",
+ strings.Join(hostnames, ", "), key, stateStr,
)
w.notify.SendNotification(
@@ -513,8 +638,8 @@ func (w *Watcher) checkSinglePort(
}
w.state.SetPortState(key, &state.PortState{
- Open: open,
- Hostname: hostname,
+ Open: result.Open,
+ Hostnames: hostnames,
LastChecked: now,
})
}
@@ -691,6 +816,22 @@ func (w *Watcher) checkTLSExpiry(
return
}
+ // Deduplicate expiry warnings: don't re-notify for the same
+ // hostname within the TLS check interval.
+ dedupKey := fmt.Sprintf("expiry:%s:%s", hostname, ip)
+
+ w.expiryNotifiedMu.Lock()
+
+ lastNotified, seen := w.expiryNotified[dedupKey]
+ if seen && time.Since(lastNotified) < w.config.TLSInterval {
+ w.expiryNotifiedMu.Unlock()
+
+ return
+ }
+
+ w.expiryNotified[dedupKey] = time.Now()
+ w.expiryNotifiedMu.Unlock()
+
msg := fmt.Sprintf(
"Host: %s\nIP: %s\nCN: %s\n"+
"Expires: %s (%.0f days)",
@@ -714,6 +855,38 @@ func (w *Watcher) saveState() {
}
}
+// 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 {
diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go
index 69772aa..ea6dbef 100644
--- a/internal/watcher/watcher_test.go
+++ b/internal/watcher/watcher_test.go
@@ -9,6 +9,7 @@ import (
"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"
@@ -109,24 +110,20 @@ func (m *mockPortChecker) CheckPort(
_ context.Context,
address string,
port int,
-) (bool, error) {
+) (*portcheck.PortResult, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls++
if m.err != nil {
- return false, m.err
+ return nil, m.err
}
key := fmt.Sprintf("%s:%d", address, port)
- open, ok := m.results[key]
+ open := m.results[key]
- if !ok {
- return false, nil
- }
-
- return open, nil
+ return &portcheck.PortResult{Open: open}, nil
}
type mockTLSChecker struct {
@@ -276,6 +273,10 @@ func setupBaselineMocks(deps *testDeps) {
"ns1.example.com.",
"ns2.example.com.",
}
+ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"93.184.216.34"}},
+ "ns2.example.com.": {"A": {"93.184.216.34"}},
+ }
deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
"ns1.example.com.": {"A": {"93.184.216.34"}},
"ns2.example.com.": {"A": {"93.184.216.34"}},
@@ -293,6 +294,14 @@ func setupBaselineMocks(deps *testDeps) {
"www.example.com",
},
}
+ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
+ CommonName: "example.com",
+ Issuer: "DigiCert",
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{
+ "example.com",
+ },
+ }
}
func assertNoNotifications(
@@ -325,14 +334,74 @@ func assertStatePopulated(
)
}
- if len(snap.Hostnames) != 1 {
+ // Hostnames includes both explicit hostnames and domains
+ // (domains now also get hostname state for port/TLS checks).
+ if len(snap.Hostnames) < 1 {
t.Errorf(
- "expected 1 hostname in state, got %d",
+ "expected at least 1 hostname in state, got %d",
len(snap.Hostnames),
)
}
}
+func TestDomainPortAndTLSChecks(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Domains = []string{"example.com"}
+
+ w, deps := newTestWatcher(t, cfg)
+
+ deps.resolver.nsRecords["example.com"] = []string{
+ "ns1.example.com.",
+ }
+ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"93.184.216.34"}},
+ }
+ deps.portChecker.results["93.184.216.34:80"] = true
+ deps.portChecker.results["93.184.216.34:443"] = true
+ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
+ CommonName: "example.com",
+ Issuer: "DigiCert",
+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{
+ "example.com",
+ },
+ }
+
+ w.RunOnce(t.Context())
+
+ snap := deps.state.GetSnapshot()
+
+ // Domain should have port state populated
+ if len(snap.Ports) == 0 {
+ t.Error("expected port state for domain, got none")
+ }
+
+ // Domain should have certificate state populated
+ if len(snap.Certificates) == 0 {
+ t.Error("expected certificate state for domain, got none")
+ }
+
+ // Verify port checker was actually called
+ deps.portChecker.mu.Lock()
+ calls := deps.portChecker.calls
+ deps.portChecker.mu.Unlock()
+
+ if calls == 0 {
+ t.Error("expected port checker to be called for domain")
+ }
+
+ // Verify TLS checker was actually called
+ deps.tlsChecker.mu.Lock()
+ tlsCalls := deps.tlsChecker.calls
+ deps.tlsChecker.mu.Unlock()
+
+ if tlsCalls == 0 {
+ t.Error("expected TLS checker to be called for domain")
+ }
+}
+
func TestNSChangeDetection(t *testing.T) {
t.Parallel()
@@ -345,6 +414,12 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.example.com.",
"ns2.example.com.",
}
+ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"1.2.3.4"}},
+ "ns2.example.com.": {"A": {"1.2.3.4"}},
+ }
+ deps.portChecker.results["1.2.3.4:80"] = false
+ deps.portChecker.results["1.2.3.4:443"] = false
ctx := t.Context()
w.RunOnce(ctx)
@@ -354,6 +429,10 @@ func TestNSChangeDetection(t *testing.T) {
"ns1.example.com.",
"ns3.example.com.",
}
+ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"1.2.3.4"}},
+ "ns3.example.com.": {"A": {"1.2.3.4"}},
+ }
deps.resolver.mu.Unlock()
w.RunOnce(ctx)
@@ -509,6 +588,61 @@ func TestTLSExpiryWarning(t *testing.T) {
}
}
+func TestTLSExpiryWarningDedup(t *testing.T) {
+ t.Parallel()
+
+ cfg := defaultTestConfig(t)
+ cfg.Hostnames = []string{"www.example.com"}
+ cfg.TLSInterval = 24 * time.Hour
+
+ w, deps := newTestWatcher(t, cfg)
+
+ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"1.2.3.4"}},
+ }
+ deps.resolver.ipAddresses["www.example.com"] = []string{
+ "1.2.3.4",
+ }
+ deps.portChecker.results["1.2.3.4:80"] = true
+ deps.portChecker.results["1.2.3.4:443"] = true
+ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
+ CommonName: "www.example.com",
+ Issuer: "DigiCert",
+ NotAfter: time.Now().Add(3 * 24 * time.Hour),
+ SubjectAlternativeNames: []string{
+ "www.example.com",
+ },
+ }
+
+ ctx := t.Context()
+
+ // First run = baseline, no notifications
+ w.RunOnce(ctx)
+
+ // Second run should fire one expiry warning
+ w.RunOnce(ctx)
+
+ // Third run should NOT fire another warning (dedup)
+ w.RunOnce(ctx)
+
+ notifications := deps.notifier.getNotifications()
+
+ expiryCount := 0
+
+ for _, n := range notifications {
+ if n.Title == "TLS Expiry Warning: www.example.com" {
+ expiryCount++
+ }
+ }
+
+ if expiryCount != 1 {
+ t.Errorf(
+ "expected exactly 1 expiry warning (dedup), got %d",
+ expiryCount,
+ )
+ }
+}
+
func TestGracefulShutdown(t *testing.T) {
t.Parallel()
@@ -522,6 +656,11 @@ func TestGracefulShutdown(t *testing.T) {
deps.resolver.nsRecords["example.com"] = []string{
"ns1.example.com.",
}
+ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
+ "ns1.example.com.": {"A": {"1.2.3.4"}},
+ }
+ deps.portChecker.results["1.2.3.4:80"] = false
+ deps.portChecker.results["1.2.3.4:443"] = false
ctx, cancel := context.WithCancel(t.Context())
@@ -543,6 +682,191 @@ func TestGracefulShutdown(t *testing.T) {
}
}
+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()
diff --git a/static/css/tailwind.min.css b/static/css/tailwind.min.css
new file mode 100644
index 0000000..ade9507
--- /dev/null
+++ b/static/css/tailwind.min.css
@@ -0,0 +1 @@
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-8{margin-bottom:2rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-8{margin-top:2rem}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:72rem}.max-w-xs{max-width:20rem}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.items-center{align-items:center}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-slate-800>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(30 41 59/var(--tw-divide-opacity,1))}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-amber-700\/30{border-color:rgba(180,83,9,.3)}.border-amber-700\/40{border-color:rgba(180,83,9,.4)}.border-blue-700\/30{border-color:rgba(29,78,216,.3)}.border-blue-700\/40{border-color:rgba(29,78,216,.4)}.border-red-700\/30{border-color:rgba(185,28,28,.3)}.border-red-700\/40{border-color:rgba(185,28,28,.4)}.border-slate-700\/50{border-color:rgba(51,65,85,.5)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-teal-700\/30{border-color:rgba(15,118,110,.3)}.border-teal-700\/40{border-color:rgba(15,118,110,.4)}.bg-amber-900\/50{background-color:rgba(120,53,15,.5)}.bg-blue-900\/50{background-color:rgba(30,58,138,.5)}.bg-red-900\/50{background-color:rgba(127,29,29,.5)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-surface-800{--tw-bg-opacity:1;background-color:rgb(26 35 50/var(--tw-bg-opacity,1))}.bg-surface-950{--tw-bg-opacity:1;background-color:rgb(12 18 34/var(--tw-bg-opacity,1))}.bg-teal-900\/50{background-color:rgba(19,78,74,.5)}.p-4{padding:1rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-0\.5{padding-left:.125rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tracking-tight{letter-spacing:-.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-teal-400{--tw-text-opacity:1;color:rgb(45 212 191/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.hover\:bg-surface-800\/50:hover{background-color:rgba(26,35,50,.5)}@media (min-width:640px){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/static/static.go b/static/static.go
new file mode 100644
index 0000000..28ee133
--- /dev/null
+++ b/static/static.go
@@ -0,0 +1,10 @@
+// Package static provides embedded static assets.
+package static
+
+import "embed"
+
+// Static contains the embedded static assets (CSS, JS) served
+// at the /s/ URL prefix.
+//
+//go:embed css
+var Static embed.FS