1 Commits
Author SHA1 Message Date
sneak 9077ec3582 docs: bring TODO.md up to date (closes #146)
check / check (push) Successful in 1m42s
Next Step and Future Steps now list the open issues by full URL, in the
order of the review on #144,
without DNSSEC (post-1.0) or issues waiting on the owner; two issues filed
since the review sit next to the entries they relate to. Shipped work, the
dropped domains and hostnames endpoints and the line about mocked resolver
tests are gone. Every Completed Steps entry is at most two lines; none was
dropped. Workflow now branches from `next` and opens PRs against it.
Wrapped by hand at 80 columns, since `make fmt` does not format Markdown
yet (#119). The old Next Step,
README sections required by policy, is not marked done: it is now
#173.

Model: opus-5-5
2026-10-01 18:27:55 +00:00
15 changed files with 175 additions and 459 deletions
+10 -8
View File
@@ -41,15 +41,18 @@ RUN make build
# alpine 3.21, 2026-02-28 # alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates tzdata su-exec RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# dnswatcher runs as this unprivileged user. The entrypoint creates the # Run as an unprivileged user that owns the data directory. A fresh named
# data directory and gives it to this user on every start. # volume inherits this ownership; a bind-mounted host directory must be
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
# fails.
RUN addgroup -S -g 10001 dnswatcher \ RUN addgroup -S -g 10001 dnswatcher \
&& adduser -S -G dnswatcher -u 10001 dnswatcher && adduser -S -G dnswatcher -u 10001 dnswatcher \
&& mkdir -p /var/lib/dnswatcher \
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
@@ -59,8 +62,7 @@ ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
# data directory, or the binary's directory, the working directory. # data directory, or the binary's directory, the working directory.
WORKDIR / WORKDIR /
# No USER: the entrypoint must start as root to set up the data USER dnswatcher
# directory; it then runs dnswatcher as the dnswatcher user.
EXPOSE 8080 EXPOSE 8080
@@ -70,4 +72,4 @@ EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1 CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/dnswatcher"]
+11 -1
View File
@@ -533,7 +533,17 @@ repository's `Dockerfile` and runs it. The app needs:
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to - **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy. `prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where - **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives. the state file lives. upaas bind-mounts the host path it is given and
does not create it. The container runs as uid 10001 and does not start
unless it can write there. Create the directory before the first
deploy:
```sh
mkdir -p /path/to/data
chown 10001:10001 /path/to/data
chmod 700 /path/to/data
```
- **Network and port:** the dashboard is unauthenticated and shows every - **Network and port:** the dashboard is unauthenticated and shows every
watched name and recent alert, and upaas publishes every mapped port on watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host all interfaces of the host
+5 -14
View File
@@ -22,14 +22,6 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every - 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every
Completed Steps entry cut to at most two lines (closes #146). Completed Steps entry cut to at most two lines (closes #146).
- 2026-10-01: wildcard CORS now applies only to the public routes, not to
`/metrics`, and allows only the methods they serve (closes #100).
- 2026-10-01: `internal/state` and `internal/watcher` no longer export test-only
constructors: two moved to `export_test.go`, one is deleted (closes #111).
- 2026-10-01: notify shutdown tests use one timing constant per meaning, name
the bound they check, and require the drain's debug line (closes #116).
- 2026-09-29: the entrypoint chowns the data directory to `dnswatcher` and runs
dnswatcher as that user, so a host bind mount needs no chown (closes #166).
- 2026-09-29: the live-DNS test package is renamed `internal/livednstest`; - 2026-09-29: the live-DNS test package is renamed `internal/livednstest`;
`make lint` fails when program code imports it (closes #164). `make lint` fails when program code imports it (closes #164).
- 2026-09-29: `.golangci.yml` re-fetched from `sneak/prompts`, with - 2026-09-29: `.golangci.yml` re-fetched from `sneak/prompts`, with
@@ -89,18 +81,19 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105 - nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105
- `DNSWATCHER_SENTRY_DSN` does nothing: - `DNSWATCHER_SENTRY_DSN` does nothing:
https://git.eeqj.de/sneak/dnswatcher/issues/107 https://git.eeqj.de/sneak/dnswatcher/issues/107
- invalid DNS or TLS interval silently replaced by the default: - CORS on `/metrics`: https://git.eeqj.de/sneak/dnswatcher/issues/100
https://git.eeqj.de/sneak/dnswatcher/issues/177
- rate limit on `/metrics` Basic Auth: - rate limit on `/metrics` Basic Auth:
https://git.eeqj.de/sneak/dnswatcher/issues/101 https://git.eeqj.de/sneak/dnswatcher/issues/101
- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109 - images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109
- trial run of the finished image: - trial run of the finished image:
https://git.eeqj.de/sneak/dnswatcher/issues/149 https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66
- `goimports` in `make fmt-check`, Markdown formatting: - `goimports` in `make fmt-check`, Markdown formatting:
https://git.eeqj.de/sneak/dnswatcher/issues/119 https://git.eeqj.de/sneak/dnswatcher/issues/119
- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114 - final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114
- test-only constructors in `internal/state`:
https://git.eeqj.de/sneak/dnswatcher/issues/111
- `internal/notify` shutdown tests:
https://git.eeqj.de/sneak/dnswatcher/issues/116
- `internal/notify` shutdown tests hang when a drain returns early: - `internal/notify` shutdown tests hang when a drain returns early:
https://git.eeqj.de/sneak/dnswatcher/issues/176 https://git.eeqj.de/sneak/dnswatcher/issues/176
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
@@ -108,5 +101,3 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
https://git.eeqj.de/sneak/dnswatcher/issues/173 https://git.eeqj.de/sneak/dnswatcher/issues/173
- `script/install-precommit` in a linked worktree: - `script/install-precommit` in a linked worktree:
https://git.eeqj.de/sneak/dnswatcher/issues/129 https://git.eeqj.de/sneak/dnswatcher/issues/129
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
# root only to give the data directory to the dnswatcher user: a host
# directory bind-mounted there keeps its host owner, often root, and may
# hold a state file left by another uid, which dnswatcher could neither
# read nor replace. dnswatcher itself always runs as the dnswatcher user.
set -eu
main() {
dir="${DNSWATCHER_DATA_DIR:-/var/lib/dnswatcher}"
mkdir -p "$dir"
chown -R dnswatcher:dnswatcher "$dir"
chmod 700 "$dir"
exec su-exec dnswatcher /usr/local/bin/dnswatcher "$@"
}
main "$@"
+9 -6
View File
@@ -223,14 +223,17 @@ func realIP(r *http.Request) string {
return addr return addr
} }
// CORS returns middleware that lets any origin read a response. It is // CORS returns CORS middleware.
// for the public, read-only routes only, so it allows only the
// methods those routes serve and no Authorization header.
func (m *Middleware) CORS() func(http.Handler) http.Handler { func (m *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{ return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"}, AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "OPTIONS"}, AllowedMethods: []string{
AllowedHeaders: []string{"Accept", "Content-Type"}, "GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept", "Authorization",
"Content-Type", "X-CSRF-Token",
},
ExposedHeaders: []string{"Link"}, ExposedHeaders: []string{"Link"},
AllowCredentials: false, AllowCredentials: false,
MaxAge: corsMaxAge, MaxAge: corsMaxAge,
+1 -9
View File
@@ -276,18 +276,10 @@ func newTestHandlers(t *testing.T) *handlers.Handlers {
t.Fatalf("notify.New: %v", err) t.Fatalf("notify.New: %v", err)
} }
st, err := state.New(fxtest.NewLifecycle(t), state.Params{
Logger: log,
Config: &config.Config{DataDir: t.TempDir()},
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
hnd, err := handlers.New(nil, handlers.Params{ hnd, err := handlers.New(nil, handlers.Params{
Logger: log, Logger: log,
Globals: glob, Globals: glob,
State: st, State: state.NewForTest(),
Notify: notifier, Notify: notifier,
}) })
if err != nil { if err != nil {
+28 -83
View File
@@ -33,29 +33,10 @@ const (
// out. // out.
drainDeadline = 50 * time.Millisecond drainDeadline = 50 * time.Millisecond
// timeoutDrainBound is how long a drain given drainDeadline // drainSlack is the upper bound on how long a bounded
// may take to return before the test gives up on it. At // drain may take; generous enough for a loaded CI box,
// forty times drainDeadline it leaves ample room for // still far below the 20s test ceiling.
// scheduling delay on a loaded box under -race, yet it is far drainSlack = 2 * time.Second
// below the test binary's -timeout, so a drain that its
// deadline does not bound fails that one test instead of
// hanging the package.
timeoutDrainBound = 2 * time.Second
// longDrainDeadline is the deadline given to a drain that is
// expected to finish well before it: when the in-flight
// delivery completes after inFlightHold, or at once when
// nothing is in flight. It is far above inFlightHold, so
// those drains never reach it, and four times
// idleDrainBound, so an idle drain that waited for its
// deadline instead of returning fails that bound.
longDrainDeadline = 2 * time.Second
// reachEndpointTimeout is how long a submitted delivery may
// take to reach the test server. That normally takes a few
// milliseconds; the margin is for a loaded box under -race,
// and only a failing run ever waits this long.
reachEndpointTimeout = 2 * time.Second
// settleDelay is how long to wait before asserting that // settleDelay is how long to wait before asserting that
// something did *not* happen. // something did *not* happen.
@@ -65,11 +46,10 @@ const (
// nothing in flight. It is deliberately far above the cost // nothing in flight. It is deliberately far above the cost
// of the goroutine hop through inFlight.Wait() — which // of the goroutine hop through inFlight.Wait() — which
// reached 57ms on a loaded box under -race with the package's // reached 57ms on a loaded box under -race with the package's
// parallel tests — and far below longDrainDeadline, the // parallel tests — and far below drainSlack, the deadline
// deadline such a drain is given. A drain that blocked until // such a drain is given. A drain that blocked until its
// its deadline instead of returning on the WaitGroup // deadline instead of returning on the WaitGroup therefore
// therefore still fails this bound, but scheduling delay // still fails this bound, but scheduling delay alone cannot.
// alone cannot.
idleDrainBound = 500 * time.Millisecond idleDrainBound = 500 * time.Millisecond
) )
@@ -95,14 +75,12 @@ func (sb *syncBuffer) String() string {
} }
// newLoggingService returns a Service writing JSON logs into // newLoggingService returns a Service writing JSON logs into
// the returned buffer, debug level included. // the returned buffer.
func newLoggingService( func newLoggingService(
transport http.RoundTripper, transport http.RoundTripper,
) (*notify.Service, *syncBuffer) { ) (*notify.Service, *syncBuffer) {
logs := &syncBuffer{} logs := &syncBuffer{}
handler := slog.NewJSONHandler( handler := slog.NewJSONHandler(logs, nil)
logs, &slog.HandlerOptions{Level: slog.LevelDebug},
)
return notify.NewTestServiceWithLogger(transport, handler), return notify.NewTestServiceWithLogger(transport, handler),
logs logs
@@ -156,7 +134,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
// drain begins. // drain begins.
select { select {
case <-entered: case <-entered:
case <-time.After(reachEndpointTimeout): case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint") t.Fatal("delivery never reached the endpoint")
} }
@@ -173,7 +151,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
defer timer.Stop() defer timer.Stop()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline, context.Background(), drainSlack,
) )
defer cancel() defer cancel()
@@ -279,11 +257,11 @@ func TestDrainBoundedByContextDeadline(t *testing.T) {
select { select {
case <-returned: case <-returned:
case <-time.After(timeoutDrainBound): case <-time.After(drainSlack):
t.Fatalf( t.Fatalf(
"drain did not return within %v; its %v deadline "+ "drain did not return within %v; its %v deadline "+
"did not bound it", "did not bound it",
timeoutDrainBound, drainDeadline, drainSlack, drainDeadline,
) )
} }
@@ -355,7 +333,7 @@ func TestDrainRefusesNewDeliveries(t *testing.T) {
svc.SetMattermostWebhookURL(target) svc.SetMattermostWebhookURL(target)
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline, context.Background(), drainSlack,
) )
defer cancel() defer cancel()
@@ -468,7 +446,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
select { select {
case <-entered: case <-entered:
case <-time.After(reachEndpointTimeout): case <-time.After(drainSlack):
t.Fatal("delivery never reached the endpoint") t.Fatal("delivery never reached the endpoint")
} }
@@ -478,7 +456,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
defer timer.Stop() defer timer.Stop()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline, context.Background(), drainSlack,
) )
defer cancel() defer cancel()
@@ -509,7 +487,7 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
start := time.Now() start := time.Now()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), longDrainDeadline, context.Background(), drainSlack,
) )
defer cancel() defer cancel()
@@ -517,9 +495,9 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
if elapsed := time.Since(start); elapsed > idleDrainBound { if elapsed := time.Since(start); elapsed > idleDrainBound {
t.Errorf( t.Errorf(
"drain of an idle service took %v, want at most "+ "drain of an idle service took %v, want well "+
"%v; its deadline was %v", "under its %v deadline",
elapsed, idleDrainBound, longDrainDeadline, elapsed, drainSlack,
) )
} }
} }
@@ -527,11 +505,10 @@ func TestDrainWithoutDeliveriesReturnsImmediately(t *testing.T) {
// TestDrainWithCancelledContextDoesNotWarn verifies that an // TestDrainWithCancelledContextDoesNotWarn verifies that an
// OnStop context that is already dead on entry does not produce // OnStop context that is already dead on entry does not produce
// an "abandoning them" warning when there was nothing in flight // an "abandoning them" warning when there was nothing in flight
// to abandon, and that the drain returns and says at debug level // to abandon. The expired context wins the select immediately,
// that nothing was in flight. The expired context wins the // so only the outstanding count can tell the difference between
// select immediately, so only the outstanding count can tell the // a genuine timeout and a shutdown that had simply already run
// difference between a genuine timeout and a shutdown that had // out of time with no work left.
// simply already run out of time with no work left.
func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) { func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) {
t.Parallel() t.Parallel()
@@ -540,43 +517,11 @@ func TestDrainWithCancelledContextDoesNotWarn(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
// A watchdog, as in TestDrainBoundedByContextDeadline, so svc.Drain(ctx)
// that a drain which never returns fails here instead of
// hanging the package.
returned := make(chan struct{})
go func() { if output := logs.String(); strings.Contains(
defer close(returned) output, `"level":"WARN"`,
svc.Drain(ctx)
}()
select {
case <-returned:
case <-time.After(idleDrainBound):
t.Fatalf(
"drain with nothing in flight and a cancelled "+
"context did not return within %v",
idleDrainBound,
)
}
output := logs.String()
// The absence of a warning alone would also pass if the drain
// logged nothing at all, so require the debug line it writes
// when it finds nothing outstanding.
if !strings.Contains(
output, "all in-flight notifications completed",
) { ) {
t.Errorf(
"drain did not log that nothing was in flight; "+
"log output: %s",
output,
)
}
if strings.Contains(output, `"level":"WARN"`) {
t.Errorf( t.Errorf(
"drain with nothing in flight warned about "+ "drain with nothing in flight warned about "+
"abandoned deliveries; log output: %s", "abandoned deliveries; log output: %s",
+14 -23
View File
@@ -23,20 +23,14 @@ func (s *Server) SetupRoutes() {
s.router.Use(chimw.RequestID) s.router.Use(chimw.RequestID)
s.router.Use(s.mw.SecurityHeaders()) s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.Logging()) s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout)) s.router.Use(chimw.Timeout(requestTimeout))
// Public, unauthenticated, read-only routes, the only ones
// REPO_POLICIES.md allows wildcard CORS on. CORS is middleware of
// this whole router, not of a Group, so that it also answers
// OPTIONS preflight requests, which no route here registers.
public := chi.NewRouter()
public.Use(s.mw.CORS())
// Dashboard (read-only web UI) // Dashboard (read-only web UI)
public.Get("/", s.handlers.HandleDashboard()) s.router.Get("/", s.handlers.HandleDashboard())
// Static assets (embedded CSS/JS) // Static assets (embedded CSS/JS)
public.Mount( s.router.Mount(
"/s", "/s",
http.StripPrefix( http.StripPrefix(
"/s", "/s",
@@ -45,30 +39,27 @@ func (s *Server) SetupRoutes() {
) )
// Health check (standard well-known path) // Health check (standard well-known path)
public.Get( s.router.Get(
"/.well-known/healthcheck", "/.well-known/healthcheck",
s.handlers.HandleHealthCheck(), s.handlers.HandleHealthCheck(),
) )
// Legacy health check (keep for backward compatibility) // Legacy health check (keep for backward compatibility)
public.Get("/health", s.handlers.HandleHealthCheck()) s.router.Get("/health", s.handlers.HandleHealthCheck())
// API v1 routes // API v1 routes
public.Route("/api/v1", func(r chi.Router) { s.router.Route("/api/v1", func(r chi.Router) {
r.Get("/status", s.handlers.HandleStatus()) r.Get("/status", s.handlers.HandleStatus())
}) })
s.router.Mount("/", public) // Metrics endpoint (optional, with basic auth)
// Metrics endpoint (optional, with basic auth) and no CORS: a
// Prometheus scraper is not a browser. It is mounted rather than
// added with Get so that every method on /metrics, OPTIONS
// included, ends here instead of falling through to the public
// router and its CORS.
if s.params.Config.MetricsUsername != "" { if s.params.Config.MetricsUsername != "" {
metrics := chi.NewRouter() s.router.Group(func(r chi.Router) {
metrics.Use(s.mw.MetricsAuth()) r.Use(s.mw.MetricsAuth())
metrics.Get("/", promhttp.Handler().ServeHTTP) r.Get(
s.router.Mount("/metrics", metrics) "/metrics",
promhttp.Handler().ServeHTTP,
)
})
} }
} }
-221
View File
@@ -1,221 +0,0 @@
package server_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/spf13/viper"
"sneak.berlin/go/dnswatcher/internal/server"
)
// Credentials for /metrics, which is only routed when a username is set.
const (
metricsUsername = "scraper"
metricsPassword = "scrape-secret"
)
// The tests below set env vars and touch viper global state, so like
// the config tests they cannot use t.Parallel.
// routedServer builds the server with its routes set up, ready to serve
// test requests. The caller must first configure viper.
func routedServer(t *testing.T) *server.Server {
t.Helper()
srv := buildServer(t)
srv.SetupRoutes()
return srv
}
// crossOriginRequest builds a request as a browser sends it from a page
// on another site.
func crossOriginRequest(
t *testing.T,
method string,
target string,
) *http.Request {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, target, nil)
req.Header.Set("Origin", "https://example.net")
return req
}
// preflightRequest builds the OPTIONS request a browser sends before a
// cross-origin request with the given method and request headers.
func preflightRequest(
t *testing.T,
target string,
method string,
headers string,
) *http.Request {
t.Helper()
req := crossOriginRequest(t, http.MethodOptions, target)
req.Header.Set("Access-Control-Request-Method", method)
if headers != "" {
req.Header.Set("Access-Control-Request-Headers", headers)
}
return req
}
func serve(
srv *server.Server,
req *http.Request,
) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
return rec
}
// publicPaths returns one path on each public route.
func publicPaths() []string {
return []string{
"/",
"/s/css/tailwind.min.css",
"/api/v1/status",
"/health",
"/.well-known/healthcheck",
}
}
// TestPublicRoutesAllowAnyOrigin checks that every public route answers
// a cross-origin GET with the CORS wildcard.
func TestPublicRoutesAllowAnyOrigin(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
for _, path := range publicPaths() {
rec := serve(srv, crossOriginRequest(t, http.MethodGet, path))
if rec.Code != http.StatusOK {
t.Errorf("GET %s: status = %d, want 200", path, rec.Code)
}
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != "*" {
t.Errorf(
"GET %s: Access-Control-Allow-Origin = %q, want %q",
path, got, "*",
)
}
}
}
// TestMetricsHasNoCORS checks that no request to the Basic-Auth
// protected /metrics, preflight included, gets a CORS header.
func TestMetricsHasNoCORS(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
authenticated := crossOriginRequest(t, http.MethodGet, "/metrics")
authenticated.SetBasicAuth(metricsUsername, metricsPassword)
tests := []struct {
name string
req *http.Request
wantStatus int
}{
{
"authenticated GET",
authenticated,
http.StatusOK,
},
{
"unauthenticated GET",
crossOriginRequest(t, http.MethodGet, "/metrics"),
http.StatusUnauthorized,
},
{
"preflight",
preflightRequest(t, "/metrics", http.MethodGet, ""),
http.StatusUnauthorized,
},
}
for _, tt := range tests {
rec := serve(srv, tt.req)
if rec.Code != tt.wantStatus {
t.Errorf(
"%s: status = %d, want %d",
tt.name, rec.Code, tt.wantStatus,
)
}
got := rec.Header().Get("Access-Control-Allow-Origin")
if got != "" {
t.Errorf(
"%s: Access-Control-Allow-Origin = %q, want none",
tt.name, got,
)
}
}
}
// TestPreflightAllowsOnlyWhatPublicRoutesServe checks what each public
// route agrees to in a CORS preflight: GET, but not POST, PUT or
// DELETE, which no route serves, and not the Authorization or
// X-CSRF-Token headers, which no public route reads. It checks every
// public route because one added with Get, such as /health, answers a
// preflight only while CORS is middleware of a whole router; in a
// Group, chi would answer it with 405 and no CORS headers.
func TestPreflightAllowsOnlyWhatPublicRoutesServe(t *testing.T) {
viper.Reset()
t.Setenv("DNSWATCHER_TARGETS", "example.com")
t.Setenv("DNSWATCHER_METRICS_USERNAME", metricsUsername)
t.Setenv("DNSWATCHER_METRICS_PASSWORD", metricsPassword)
srv := routedServer(t)
tests := []struct {
method string
headers string
allowed bool
}{
{http.MethodGet, "", true},
{http.MethodGet, "Content-Type", true},
{http.MethodPost, "", false},
{http.MethodPut, "", false},
{http.MethodDelete, "", false},
{http.MethodGet, "Authorization", false},
{http.MethodGet, "X-CSRF-Token", false},
}
for _, path := range publicPaths() {
for _, tt := range tests {
rec := serve(srv, preflightRequest(
t, path, tt.method, tt.headers,
))
want := ""
if tt.allowed {
want = tt.method
}
got := rec.Header().Get("Access-Control-Allow-Methods")
if got != want {
t.Errorf(
"preflight to %s for %s with headers %q: "+
"Access-Control-Allow-Methods = %q, want %q",
path, tt.method, tt.headers, got, want,
)
}
}
}
}
-23
View File
@@ -1,23 +0,0 @@
package state
import (
"log/slog"
"sneak.berlin/go/dnswatcher/internal/config"
)
// NewForTestWithDataDir creates an empty State that saves to dataDir,
// without the fx lifecycle.
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},
}
}
+36 -7
View File
@@ -739,7 +739,7 @@ func TestPortStateUnmarshalJSON_BothFormats(t *testing.T) {
func TestGetSnapshot_ReturnsCopy(t *testing.T) { func TestGetSnapshot_ReturnsCopy(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
populateState(t, s) populateState(t, s)
@@ -761,7 +761,7 @@ func TestGetSnapshot_ReturnsCopy(t *testing.T) {
func TestDomainState_GetSet(t *testing.T) { func TestDomainState_GetSet(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
// Get on missing key returns false. // Get on missing key returns false.
_, ok := s.GetDomainState("nonexistent.com") _, ok := s.GetDomainState("nonexistent.com")
@@ -812,7 +812,7 @@ func TestDomainState_GetSet(t *testing.T) {
func TestHostnameState_GetSet(t *testing.T) { func TestHostnameState_GetSet(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
_, ok := s.GetHostnameState("missing.example.com") _, ok := s.GetHostnameState("missing.example.com")
if ok { if ok {
@@ -857,7 +857,7 @@ func TestHostnameState_GetSet(t *testing.T) {
func TestPortState_GetSetDelete(t *testing.T) { func TestPortState_GetSetDelete(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
_, ok := s.GetPortState("1.2.3.4:80") _, ok := s.GetPortState("1.2.3.4:80")
if ok { if ok {
@@ -895,7 +895,7 @@ func TestPortState_GetSetDelete(t *testing.T) {
func TestGetAllPortKeys(t *testing.T) { func TestGetAllPortKeys(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
keys := s.GetAllPortKeys() keys := s.GetAllPortKeys()
if len(keys) != 0 { if len(keys) != 0 {
@@ -937,7 +937,7 @@ func TestGetAllPortKeys(t *testing.T) {
func TestCertificateState_GetSet(t *testing.T) { func TestCertificateState_GetSet(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
_, ok := s.GetCertificateState("1.2.3.4:443:www.example.com") _, ok := s.GetCertificateState("1.2.3.4:443:www.example.com")
if ok { if ok {
@@ -1158,7 +1158,7 @@ func TestLoadPreservesExistingStateOnMissingFile(t *testing.T) {
func TestConcurrentGetSet(t *testing.T) { func TestConcurrentGetSet(t *testing.T) {
t.Parallel() t.Parallel()
s := state.NewForTestWithDataDir(t.TempDir()) s := state.NewForTest()
const goroutines = 20 const goroutines = 20
@@ -1368,6 +1368,35 @@ func TestMultipleSavesOverwrite(t *testing.T) {
} }
} }
// 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. // TestSaveFilePermissions verifies the saved file has restricted permissions.
func TestSaveFilePermissions(t *testing.T) { func TestSaveFilePermissions(t *testing.T) {
t.Parallel() t.Parallel()
+38
View File
@@ -0,0 +1,38 @@
package state
import (
"log/slog"
"sneak.berlin/go/dnswatcher/internal/config"
)
// NewForTest creates a State for unit testing with no persistence.
func NewForTest() *State {
return &State{
log: slog.Default(),
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
config: &config.Config{DataDir: ""},
}
}
// NewForTestWithDataDir creates a State backed by the given directory
// for tests that need file persistence.
func NewForTestWithDataDir(dataDir string) *State {
return &State{
log: slog.Default(),
snapshot: &Snapshot{
Version: stateVersion,
Domains: make(map[string]*DomainState),
Hostnames: make(map[string]*HostnameState),
Ports: make(map[string]*PortState),
Certificates: make(map[string]*CertificateState),
},
config: &config.Config{DataDir: dataDir},
}
}
-25
View File
@@ -2,35 +2,10 @@ package watcher
import ( import (
"context" "context"
"log/slog"
"time"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
) )
// NewForTest creates a Watcher without fx for unit testing.
func NewForTest(
cfg *config.Config,
st *state.State,
res DNSResolver,
pc PortChecker,
tc TLSChecker,
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs( func NewlyDisagreeingPairs(
prev *state.HostnameState, prev *state.HostnameState,
+22
View File
@@ -102,6 +102,28 @@ func New(
return w, nil return w, nil
} }
// NewForTest creates a Watcher without fx for unit testing.
func NewForTest(
cfg *config.Config,
st *state.State,
res DNSResolver,
pc PortChecker,
tc TLSChecker,
n Notifier,
) *Watcher {
return &Watcher{
log: slog.Default(),
config: cfg,
state: st,
resolver: res,
portCheck: pc,
tlsCheck: tc,
notify: n,
firstRun: true,
expiryNotified: make(map[string]time.Time),
}
}
// Run starts the monitoring loop with periodic scheduling. // Run starts the monitoring loop with periodic scheduling.
func (w *Watcher) Run(ctx context.Context) { func (w *Watcher) Run(ctx context.Context) {
w.log.Info( w.log.Info(
+1 -22
View File
@@ -9,12 +9,8 @@ import (
"testing" "testing"
"time" "time"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/globals"
"sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -152,24 +148,7 @@ func newTestWatcher(
config: cfg, config: cfg,
} }
g, err := globals.New(nil) deps.state = state.NewForTest()
if err != nil {
t.Fatalf("globals.New: %v", err)
}
log, err := logger.New(nil, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger.New: %v", err)
}
// The watcher saves state after every check, into cfg.DataDir.
deps.state, err = state.New(fxtest.NewLifecycle(t), state.Params{
Logger: log,
Config: cfg,
})
if err != nil {
t.Fatalf("state.New: %v", err)
}
w := watcher.NewForTest( w := watcher.NewForTest(
deps.config, deps.config,