Compare commits
1
Commits
next
..
71b8d46f7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b8d46f7b |
+6
-26
@@ -2,7 +2,6 @@
|
||||
# The linter is invoked directly rather than through `make lint`: that
|
||||
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
||||
# no docker daemon inside a docker build.
|
||||
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||
|
||||
@@ -16,7 +15,6 @@ RUN make fmt-check
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
||||
# golang 1.25-alpine, 2026-02-28
|
||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||
|
||||
@@ -43,33 +41,15 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
|
||||
WORKDIR /app
|
||||
|
||||
# Run as an unprivileged user that owns the data directory. A fresh named
|
||||
# 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 \
|
||||
&& adduser -S -G dnswatcher -u 10001 dnswatcher \
|
||||
&& mkdir -p /var/lib/dnswatcher \
|
||||
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
|
||||
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /var/lib/dnswatcher
|
||||
|
||||
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
|
||||
|
||||
# Config loading also reads a `.env` file and a file named `dnswatcher`
|
||||
# (any config extension, or none) from the working directory. `/` holds
|
||||
# neither, so every setting comes from the environment. Do not make the
|
||||
# data directory, or the binary's directory, the working directory.
|
||||
WORKDIR /
|
||||
|
||||
USER dnswatcher
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# busybox wget (already in alpine) probes the health endpoint every 10
|
||||
# seconds, so the container is healthy well before upaas reads its health
|
||||
# 60 seconds after a deploy and fails the deploy unless it is healthy.
|
||||
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
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
|
||||
ENTRYPOINT ["/app/dnswatcher"]
|
||||
|
||||
@@ -61,10 +61,6 @@ rejected.
|
||||
record types: A, AAAA, CNAME, MX, TXT, SRV, CAA, NS.
|
||||
- Stores results **per nameserver**. The state for a hostname is not a
|
||||
merged view — it is a map from nameserver to record set.
|
||||
- DNS names inside record values (CNAME, MX, SRV and NS targets) are
|
||||
stored in lower case, because names are case-insensitive and
|
||||
nameservers may answer in any letter case. TXT and CAA values keep
|
||||
their letter case; they are not lower-cased.
|
||||
- Any observable change in any nameserver's response triggers a
|
||||
notification. This includes:
|
||||
- **Record change**: A nameserver returns different records than it
|
||||
@@ -74,15 +70,8 @@ rejected.
|
||||
This is distinct from "responded with no records."
|
||||
- **NS recovery**: A previously-unreachable nameserver starts
|
||||
responding again.
|
||||
- **Inconsistency detected**: Two nameservers return different record
|
||||
sets for the same hostname and did not already differ on the previous
|
||||
check. Every pair of nameservers is compared. The alert is sent once
|
||||
for each such pair, on the check where they start to disagree, and not
|
||||
again while they keep disagreeing, including after a restart. A
|
||||
nameserver that was not in the previous check (newly added, or back
|
||||
after dropping out) and answers differently is reported on the check
|
||||
where it appears. If a pair agrees again and later disagrees, the
|
||||
alert is sent again.
|
||||
- **Inconsistency detected**: Two nameservers that previously agreed
|
||||
now return different record sets for the same hostname.
|
||||
|
||||
### TCP Port Monitoring
|
||||
|
||||
@@ -214,46 +203,6 @@ are read, so a smaller value would sever the connection before a handler
|
||||
using its full budget could respond. `IdleTimeout` exceeds common
|
||||
Prometheus scrape intervals so the scraper reuses its connection.
|
||||
|
||||
### Security Headers
|
||||
|
||||
Every response — the dashboard, the static assets under `/s/...`, the
|
||||
healthchecks, the JSON API, and `/metrics` — carries the following
|
||||
headers, set by a global middleware:
|
||||
|
||||
| Header | Value |
|
||||
|-----------------------------|---------------------------------------|
|
||||
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` |
|
||||
| `Content-Security-Policy` | see below |
|
||||
| `X-Frame-Options` | `DENY` |
|
||||
| `X-Content-Type-Options` | `nosniff` |
|
||||
| `Referrer-Policy` | `no-referrer` |
|
||||
| `Permissions-Policy` | all unused browser features denied |
|
||||
|
||||
The content security policy is:
|
||||
|
||||
```
|
||||
default-src 'self'; script-src 'none'; style-src 'self'; img-src 'self';
|
||||
font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none';
|
||||
form-action 'none'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
The dashboard ships no JavaScript (the 30-second refresh is a
|
||||
`<meta http-equiv="refresh">`), no inline styles, no inline event
|
||||
handlers, and no images; its only subresource is the embedded stylesheet
|
||||
at `/s/css/tailwind.min.css`, which `style-src 'self'` permits. The
|
||||
policy therefore needs neither `unsafe-inline` nor `unsafe-eval`.
|
||||
`frame-ancestors 'none'` is the primary anti-framing control, with
|
||||
`X-Frame-Options: DENY` retained as the legacy fallback.
|
||||
|
||||
HSTS is emitted unconditionally, including over plain HTTP. dnswatcher is
|
||||
expected to run behind a TLS-terminating reverse proxy, and the browser
|
||||
must still be told to enforce HTTPS end to end, so the header is never
|
||||
gated on whether the request itself arrived over TLS.
|
||||
|
||||
`Referrer-Policy: no-referrer` is stricter than the
|
||||
`strict-origin-when-cross-origin` baseline: the dashboard has no
|
||||
cross-origin navigation needs, and its URL may name internal hosts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -266,8 +215,7 @@ internal/
|
||||
globals/globals.go Build-time variables (version)
|
||||
logger/logger.go slog structured logging (TTY detection)
|
||||
healthcheck/healthcheck.go Health check service
|
||||
middleware/middleware.go HTTP middleware (logging, CORS, security
|
||||
headers, metrics auth)
|
||||
middleware/middleware.go HTTP middleware (logging, CORS, metrics auth)
|
||||
handlers/handlers.go HTTP request handlers
|
||||
server/
|
||||
server.go HTTP server lifecycle
|
||||
@@ -476,13 +424,9 @@ them. We provide:
|
||||
- `script/fmt` — format all code (gofmt -s, goimports)
|
||||
- `script/fmt-check` — check formatting (read-only)
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
||||
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
|
||||
which runs the tests, run on every invocation
|
||||
- `script/cibuild` — CI entrypoint: `docker build` with
|
||||
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
|
||||
which runs the tests, run on every invocation, because a cached build lints
|
||||
nothing and queries no DNS
|
||||
- `script/docker` — build the Docker image tagged via
|
||||
`script/projectname`
|
||||
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||||
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
||||
guard, then `script/check`
|
||||
- `script/install-precommit` — install the git pre-commit hook
|
||||
@@ -523,57 +467,11 @@ docker run -d \
|
||||
|
||||
---
|
||||
|
||||
## Running under upaas
|
||||
|
||||
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
|
||||
repository's `Dockerfile` and runs it. The app needs:
|
||||
|
||||
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
|
||||
`prod` pull request is a deploy.
|
||||
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
|
||||
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
|
||||
watched name and recent alert, and upaas publishes every mapped port on
|
||||
all interfaces of the host
|
||||
([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
|
||||
port mapping to container port `8080` only if the dashboard should be
|
||||
public. Otherwise add none: set the app's Docker network in upaas to
|
||||
your reverse proxy's Docker network, and the proxy reaches the app at
|
||||
`upaas-` followed by the app name, port `8080`.
|
||||
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
|
||||
of the domains and hostnames to watch. dnswatcher refuses to start
|
||||
without it.
|
||||
- **Recommended environment:** at least one notification endpoint
|
||||
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
|
||||
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
|
||||
dashboard. `DNSWATCHER_METRICS_USERNAME` and
|
||||
`DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
|
||||
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
|
||||
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
|
||||
setting comes from the environment; the image holds no config file.
|
||||
- **Health check:** the image's own, which requests
|
||||
`/.well-known/healthcheck` every 10 seconds. upaas reads the
|
||||
container's health 60 seconds after a deploy and marks the deploy
|
||||
failed unless it is `healthy`.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Lifecycle
|
||||
|
||||
1. **Startup**: Check that the data directory can be written, and exit
|
||||
with an error naming it if not. Load state from disk. If no state
|
||||
file exists, start with empty state (first check will establish
|
||||
baseline without triggering change notifications).
|
||||
1. **Startup**: Load state from disk. If no state file exists, start
|
||||
with empty state (first check will establish baseline without
|
||||
triggering change notifications).
|
||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
|
||||
on startup.
|
||||
3. **Periodic checks** (DNS always runs first):
|
||||
|
||||
@@ -23,27 +23,13 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-28: the inconsistency alert is sent once, on the check where two
|
||||
nameservers start to disagree or where a nameserver that disagrees first
|
||||
appears, instead of on every check while they disagree, and not again after
|
||||
a restart. Every pair of nameservers is compared, not only neighbours in
|
||||
sorted order of name (closes #158).
|
||||
- 2026-09-28: DNS names in record values (CNAME, MX, SRV and NS targets) are
|
||||
lower-cased, so nameservers that answer in different letter case no longer
|
||||
count as inconsistent or as a record change (closes #157).
|
||||
- 2026-09-28: `script/cibuild` and `script/docker` now pass
|
||||
`--no-cache-filter=lint,builder` so lint and tests run every build (closes
|
||||
#115).
|
||||
- 2026-09-28: the server timeout test now drives `Run` and checks the
|
||||
`http.Server` it serves carries the timeouts; corrected the `ReadTimeout`
|
||||
note in that test (closes #120).
|
||||
- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged
|
||||
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
|
||||
not writable, README "Running under upaas" (closes #147).
|
||||
- 2026-09-21: added behavioural tests for `internal/globals`,
|
||||
`internal/healthcheck`, and `internal/logger` (closes #110).
|
||||
- 2026-09-21: server timeout test now drives `Run` and asserts the
|
||||
served `http.Server` carries the timeouts; corrected the inverted
|
||||
`ReadTimeout` rationale note (#120).
|
||||
|
||||
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
|
||||
`// indirect` line so `script/bootstrap` leaves a clean tree (#132)
|
||||
|
||||
- 2026-08-10: comment-only corrections to `script/bootstrap`,
|
||||
`script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in
|
||||
`script/bootstrap` was justified by a claim that `script/fmt-check`
|
||||
@@ -130,16 +116,6 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
||||
than the 60s `chimw.Timeout` handler budget so that budget stays
|
||||
reachable, and tests in `internal/server` pin both the non-zero
|
||||
values and that relationship (#99)
|
||||
- 2026-08-09: security response headers middleware
|
||||
(`SecurityHeaders()` in `internal/middleware/middleware.go`)
|
||||
registered globally in `internal/server/routes.go`, so HSTS, CSP,
|
||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and
|
||||
`Permissions-Policy` are set on every response including `/s/...` and
|
||||
`/metrics`; the CSP needs no `unsafe-inline`/`unsafe-eval` because the
|
||||
dashboard ships no JavaScript and no inline styles; HSTS is emitted
|
||||
unconditionally per policy (TLS-terminating proxy in front). Remaining
|
||||
1.0 hardening items — `http.Server` timeouts, request body limits,
|
||||
rate limiting, CORS scoping — are tracked separately
|
||||
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
||||
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
||||
org-standard v2-schema config used across the org's repos
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package globals_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
)
|
||||
|
||||
// TestGlobals exercises the package-level version and appname
|
||||
// variables through their setters and read-back via New. These are
|
||||
// shared package state, so the test mutates a global and must run
|
||||
// sequentially; it cannot use t.Parallel().
|
||||
//
|
||||
//nolint:paralleltest // mutates shared package-level globals, must run sequentially
|
||||
func TestGlobals(t *testing.T) {
|
||||
versions := []string{"v1.2.3", "dev", "", "v1.2.3-4-gabcdef"}
|
||||
for _, want := range versions {
|
||||
globals.SetVersion(want)
|
||||
|
||||
g, err := globals.New(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, g.Version,
|
||||
"New must surface the version set by SetVersion")
|
||||
}
|
||||
|
||||
names := []string{"dnswatcher", "other", ""}
|
||||
for _, want := range names {
|
||||
globals.SetAppname(want)
|
||||
|
||||
g, err := globals.New(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, g.Appname,
|
||||
"New must surface the appname set by SetAppname")
|
||||
}
|
||||
|
||||
// New returns a snapshot: a later SetVersion must not mutate a
|
||||
// Globals handed out earlier.
|
||||
globals.SetVersion("first")
|
||||
|
||||
g, err := globals.New(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
globals.SetVersion("second")
|
||||
assert.Equal(t, "first", g.Version,
|
||||
"a Globals returned by New must not change when the "+
|
||||
"package variable is set again")
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package healthcheck_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
// recordingLifecycle is a minimal fx.Lifecycle that records the hooks
|
||||
// appended to it, so healthcheck.New can be exercised through its real
|
||||
// constructor without standing up a whole fx application.
|
||||
type recordingLifecycle struct {
|
||||
hooks []fx.Hook
|
||||
}
|
||||
|
||||
func (l *recordingLifecycle) Append(hook fx.Hook) {
|
||||
l.hooks = append(l.hooks, hook)
|
||||
}
|
||||
|
||||
// newHealthcheck builds a Healthcheck through the real constructor and
|
||||
// runs the registered OnStart hook so StartupTime is set the same way
|
||||
// the fx lifecycle would set it.
|
||||
func newHealthcheck(
|
||||
t *testing.T,
|
||||
maintenance bool,
|
||||
version string,
|
||||
) *healthcheck.Healthcheck {
|
||||
t.Helper()
|
||||
|
||||
g := &globals.Globals{Appname: "dnswatcher", Version: version}
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: g})
|
||||
require.NoError(t, err)
|
||||
|
||||
lifecycle := &recordingLifecycle{}
|
||||
|
||||
hc, err := healthcheck.New(lifecycle, healthcheck.Params{
|
||||
Globals: g,
|
||||
Config: &config.Config{MaintenanceMode: maintenance},
|
||||
Logger: log,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, lifecycle.hooks, 1,
|
||||
"New must register exactly one lifecycle hook")
|
||||
require.NotNil(t, lifecycle.hooks[0].OnStart)
|
||||
require.NoError(t, lifecycle.hooks[0].OnStart(context.Background()))
|
||||
|
||||
return hc
|
||||
}
|
||||
|
||||
func TestCheckStatusAndPayloadShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc := newHealthcheck(t, false, "v9.9.9")
|
||||
resp := hc.Check()
|
||||
|
||||
assert.Equal(t, "ok", resp.Status)
|
||||
|
||||
// The JSON shape and field names are part of the contract for the
|
||||
// /health and /.well-known/healthcheck routes, so assert on the
|
||||
// exact set of keys the response marshals to.
|
||||
raw, err := json.Marshal(resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
var fields map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(raw, &fields))
|
||||
|
||||
wantKeys := []string{
|
||||
"status",
|
||||
"now",
|
||||
"uptimeSeconds",
|
||||
"uptimeHuman",
|
||||
"version",
|
||||
"appname",
|
||||
"maintenanceMode",
|
||||
}
|
||||
assert.Len(t, fields, len(wantKeys),
|
||||
"response must marshal to exactly the documented fields")
|
||||
|
||||
for _, key := range wantKeys {
|
||||
assert.Contains(t, fields, key, "missing JSON field %q", key)
|
||||
}
|
||||
|
||||
// The Now field is documented as RFC3339Nano; a change to the
|
||||
// format constant should turn this red.
|
||||
_, err = time.Parse(time.RFC3339Nano, resp.Now)
|
||||
assert.NoError(t, err, "Now must be RFC3339Nano")
|
||||
}
|
||||
|
||||
func TestCheckMaintenanceModeReflectsConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
maintenance bool
|
||||
}{
|
||||
{"maintenance off", false},
|
||||
{"maintenance on", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc := newHealthcheck(t, tt.maintenance, "test")
|
||||
resp := hc.Check()
|
||||
assert.Equal(t, tt.maintenance, resp.Maintenance,
|
||||
"maintenanceMode must mirror Config.MaintenanceMode")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSurfacesVersionAndAppname(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hc := newHealthcheck(t, false, "surfaced-version-123")
|
||||
resp := hc.Check()
|
||||
|
||||
assert.Equal(t, "surfaced-version-123", resp.Version,
|
||||
"version from globals must appear in the payload")
|
||||
assert.Equal(t, "dnswatcher", resp.Appname)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package logger_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
)
|
||||
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
|
||||
g := &globals.Globals{Appname: "dnswatcher", Version: "test"}
|
||||
|
||||
l, err := logger.New(nil, logger.Params{Globals: g})
|
||||
require.NoError(t, err)
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// TestNewReturnsUsableLogger checks that the constructor yields a
|
||||
// working *slog.Logger.
|
||||
func TestNewReturnsUsableLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
l := newTestLogger(t)
|
||||
require.NotNil(t, l.Get(), "Get must return a non-nil logger")
|
||||
}
|
||||
|
||||
// TestDefaultLevelExcludesDebug verifies the default configuration
|
||||
// logs at info: debug records are suppressed, info records pass.
|
||||
func TestDefaultLevelExcludesDebug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := newTestLogger(t).Get()
|
||||
ctx := context.Background()
|
||||
|
||||
assert.False(t, log.Enabled(ctx, slog.LevelDebug),
|
||||
"debug must be suppressed at the default level")
|
||||
assert.True(t, log.Enabled(ctx, slog.LevelInfo),
|
||||
"info must be enabled at the default level")
|
||||
}
|
||||
|
||||
// TestEnableDebugLoggingChangesLevel verifies the debug and non-debug
|
||||
// configurations differ as intended: enabling debug makes debug
|
||||
// records pass where they previously did not.
|
||||
func TestEnableDebugLoggingChangesLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
l := newTestLogger(t)
|
||||
log := l.Get()
|
||||
ctx := context.Background()
|
||||
|
||||
require.False(t, log.Enabled(ctx, slog.LevelDebug),
|
||||
"debug must start disabled")
|
||||
|
||||
l.EnableDebugLogging()
|
||||
|
||||
assert.True(t, log.Enabled(ctx, slog.LevelDebug),
|
||||
"debug must be enabled after EnableDebugLogging")
|
||||
}
|
||||
@@ -21,60 +21,6 @@ import (
|
||||
// corsMaxAge is the maximum age for CORS preflight responses.
|
||||
const corsMaxAge = 300
|
||||
|
||||
// Security response header values applied to every response.
|
||||
//
|
||||
// The CSP is as strict as the dashboard allows: the template ships no
|
||||
// JavaScript, no inline styles, no inline event handlers and no images,
|
||||
// and its only subresource is the embedded stylesheet at
|
||||
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
|
||||
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
|
||||
// primary anti-framing control; X-Frame-Options is the legacy fallback.
|
||||
const (
|
||||
// hstsValue is emitted unconditionally, including over plain HTTP,
|
||||
// because the service runs behind a TLS-terminating proxy and the
|
||||
// browser must still enforce HTTPS end to end.
|
||||
hstsValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
cspValue = "default-src 'self'; " +
|
||||
"script-src 'none'; " +
|
||||
"style-src 'self'; " +
|
||||
"img-src 'self'; " +
|
||||
"font-src 'none'; " +
|
||||
"connect-src 'none'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
"form-action 'none'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
frameOptionsValue = "DENY"
|
||||
|
||||
contentTypeOptionsValue = "nosniff"
|
||||
|
||||
// referrerPolicyValue is stricter than the policy minimum of
|
||||
// strict-origin-when-cross-origin: the dashboard has no
|
||||
// cross-origin navigation needs and its URL may name internal
|
||||
// hosts.
|
||||
referrerPolicyValue = "no-referrer"
|
||||
|
||||
permissionsPolicyValue = "accelerometer=(), " +
|
||||
"autoplay=(), " +
|
||||
"camera=(), " +
|
||||
"display-capture=(), " +
|
||||
"encrypted-media=(), " +
|
||||
"fullscreen=(), " +
|
||||
"geolocation=(), " +
|
||||
"gyroscope=(), " +
|
||||
"magnetometer=(), " +
|
||||
"microphone=(), " +
|
||||
"midi=(), " +
|
||||
"payment=(), " +
|
||||
"picture-in-picture=(), " +
|
||||
"publickey-credentials-get=(), " +
|
||||
"screen-wake-lock=(), " +
|
||||
"usb=(), " +
|
||||
"xr-spatial-tracking=()"
|
||||
)
|
||||
|
||||
// Params contains dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -240,37 +186,6 @@ func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// SecurityHeaders returns middleware that sets the security response
|
||||
// headers required for production internet exposure on every response.
|
||||
//
|
||||
// The headers are set before the request reaches the next handler so
|
||||
// that they are present on every response, including panics recovered
|
||||
// by chi's Recoverer and timeouts produced by chi's Timeout.
|
||||
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
) {
|
||||
header := writer.Header()
|
||||
header.Set("Strict-Transport-Security", hstsValue)
|
||||
header.Set("Content-Security-Policy", cspValue)
|
||||
header.Set("X-Frame-Options", frameOptionsValue)
|
||||
header.Set(
|
||||
"X-Content-Type-Options",
|
||||
contentTypeOptionsValue,
|
||||
)
|
||||
header.Set("Referrer-Policy", referrerPolicyValue)
|
||||
header.Set(
|
||||
"Permissions-Policy",
|
||||
permissionsPolicyValue,
|
||||
)
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MetricsAuth returns basic auth middleware for /metrics.
|
||||
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
||||
if m.params.Config.MetricsUsername == "" {
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
package middleware_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/middleware"
|
||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// Expected security header values, spelled out literally so that any
|
||||
// change to the middleware has to be made deliberately here as well.
|
||||
const (
|
||||
wantHSTS = "max-age=31536000; includeSubDomains"
|
||||
|
||||
wantCSP = "default-src 'self'; " +
|
||||
"script-src 'none'; " +
|
||||
"style-src 'self'; " +
|
||||
"img-src 'self'; " +
|
||||
"font-src 'none'; " +
|
||||
"connect-src 'none'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
"form-action 'none'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
wantFrameOptions = "DENY"
|
||||
|
||||
wantContentTypeOptions = "nosniff"
|
||||
|
||||
wantReferrerPolicy = "no-referrer"
|
||||
|
||||
wantPermissionsPolicy = "accelerometer=(), " +
|
||||
"autoplay=(), " +
|
||||
"camera=(), " +
|
||||
"display-capture=(), " +
|
||||
"encrypted-media=(), " +
|
||||
"fullscreen=(), " +
|
||||
"geolocation=(), " +
|
||||
"gyroscope=(), " +
|
||||
"magnetometer=(), " +
|
||||
"microphone=(), " +
|
||||
"midi=(), " +
|
||||
"payment=(), " +
|
||||
"picture-in-picture=(), " +
|
||||
"publickey-credentials-get=(), " +
|
||||
"screen-wake-lock=(), " +
|
||||
"usb=(), " +
|
||||
"xr-spatial-tracking=()"
|
||||
)
|
||||
|
||||
// stylesheetPath is the only subresource the dashboard loads.
|
||||
const stylesheetPath = "/s/css/tailwind.min.css"
|
||||
|
||||
// newTestLogger builds a logger for direct component construction.
|
||||
func newTestLogger(t *testing.T) *logger.Logger {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
log, err := logger.New(nil, logger.Params{Globals: glob})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New: %v", err)
|
||||
}
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
// newTestMiddleware builds a Middleware without an fx application.
|
||||
func newTestMiddleware(t *testing.T) *middleware.Middleware {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
mw, err := middleware.New(nil, middleware.Params{
|
||||
Logger: newTestLogger(t),
|
||||
Globals: glob,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("middleware.New: %v", err)
|
||||
}
|
||||
|
||||
return mw
|
||||
}
|
||||
|
||||
// serveWithSecurityHeaders runs a GET through SecurityHeaders and
|
||||
// returns the recorded response.
|
||||
func serveWithSecurityHeaders(
|
||||
t *testing.T,
|
||||
target string,
|
||||
handler http.Handler,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, target, nil,
|
||||
)
|
||||
|
||||
mw.SecurityHeaders()(handler).ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// okHandler writes a trivial 200 response.
|
||||
func okHandler() http.Handler {
|
||||
return http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"hsts",
|
||||
"Strict-Transport-Security",
|
||||
wantHSTS,
|
||||
},
|
||||
{
|
||||
"csp",
|
||||
"Content-Security-Policy",
|
||||
wantCSP,
|
||||
},
|
||||
{
|
||||
"frame options",
|
||||
"X-Frame-Options",
|
||||
wantFrameOptions,
|
||||
},
|
||||
{
|
||||
"content type options",
|
||||
"X-Content-Type-Options",
|
||||
wantContentTypeOptions,
|
||||
},
|
||||
{
|
||||
"referrer policy",
|
||||
"Referrer-Policy",
|
||||
wantReferrerPolicy,
|
||||
},
|
||||
{
|
||||
"permissions policy",
|
||||
"Permissions-Policy",
|
||||
wantPermissionsPolicy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||
|
||||
got := rec.Header().Get(tt.header)
|
||||
if got != tt.want {
|
||||
t.Errorf(
|
||||
"%s = %q, want %q",
|
||||
tt.header, got, tt.want,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersCSPDirectives guards the properties the repo
|
||||
// policy requires of the content security policy itself.
|
||||
func TestSecurityHeadersCSPDirectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/", okHandler())
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
|
||||
forbidden := []string{"unsafe-inline", "unsafe-eval"}
|
||||
for _, directive := range forbidden {
|
||||
if strings.Contains(csp, directive) {
|
||||
t.Errorf("CSP must not contain %q: %q", directive, csp)
|
||||
}
|
||||
}
|
||||
|
||||
required := []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'none'",
|
||||
"style-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
for _, directive := range required {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Errorf("CSP must contain %q: %q", directive, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecurityHeadersOnErrorResponse verifies the headers are emitted
|
||||
// even when the wrapped handler fails, since they are set before the
|
||||
// handler runs.
|
||||
func TestSecurityHeadersOnErrorResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
failing := http.HandlerFunc(func(
|
||||
writer http.ResponseWriter,
|
||||
_ *http.Request,
|
||||
) {
|
||||
http.Error(
|
||||
writer,
|
||||
"boom",
|
||||
http.StatusInternalServerError,
|
||||
)
|
||||
})
|
||||
|
||||
rec := serveWithSecurityHeaders(t, "/api/v1/status", failing)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get(
|
||||
"X-Content-Type-Options",
|
||||
); got != wantContentTypeOptions {
|
||||
t.Errorf(
|
||||
"X-Content-Type-Options = %q, want %q",
|
||||
got, wantContentTypeOptions,
|
||||
)
|
||||
}
|
||||
|
||||
if got := rec.Header().Get(
|
||||
"Strict-Transport-Security",
|
||||
); got != wantHSTS {
|
||||
t.Errorf(
|
||||
"Strict-Transport-Security = %q, want %q",
|
||||
got, wantHSTS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestHandlers builds real Handlers with empty monitoring state.
|
||||
func newTestHandlers(t *testing.T) *handlers.Handlers {
|
||||
t.Helper()
|
||||
|
||||
glob, err := globals.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("globals.New: %v", err)
|
||||
}
|
||||
|
||||
log := newTestLogger(t)
|
||||
|
||||
notifier, err := notify.New(fxtest.NewLifecycle(t), notify.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify.New: %v", err)
|
||||
}
|
||||
|
||||
hnd, err := handlers.New(nil, handlers.Params{
|
||||
Logger: log,
|
||||
Globals: glob,
|
||||
State: state.NewForTest(),
|
||||
Notify: notifier,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("handlers.New: %v", err)
|
||||
}
|
||||
|
||||
return hnd
|
||||
}
|
||||
|
||||
// TestDashboardRendersWithSecurityHeaders renders the real dashboard
|
||||
// through the middleware and checks that the policy still permits the
|
||||
// one stylesheet the page loads.
|
||||
func TestDashboardRendersWithSecurityHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mw := newTestMiddleware(t)
|
||||
hnd := newTestHandlers(t)
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(mw.SecurityHeaders())
|
||||
router.Get("/", hnd.HandleDashboard())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, "/", nil,
|
||||
)
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, stylesheetPath) {
|
||||
t.Errorf("dashboard does not reference %q", stylesheetPath)
|
||||
}
|
||||
|
||||
if !strings.Contains(body, "dnswatcher") {
|
||||
t.Errorf("dashboard body looks empty: %d bytes", len(body))
|
||||
}
|
||||
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if csp != wantCSP {
|
||||
t.Errorf("CSP = %q, want %q", csp, wantCSP)
|
||||
}
|
||||
|
||||
// The stylesheet is same-origin, so style-src 'self' allows it.
|
||||
if !strings.Contains(csp, "style-src 'self'") {
|
||||
t.Errorf("CSP would block %q: %q", stylesheetPath, csp)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package resolver
|
||||
|
||||
import "github.com/miekg/dns"
|
||||
|
||||
// ExtractRecordValue exports extractRecordValue for testing.
|
||||
func ExtractRecordValue(rr dns.RR) string {
|
||||
return extractRecordValue(rr)
|
||||
}
|
||||
@@ -608,10 +608,7 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
|
||||
}
|
||||
}
|
||||
|
||||
// extractRecordValue formats a DNS RR value as a string. DNS names
|
||||
// are case-insensitive and nameservers may answer in any letter case,
|
||||
// so names are lower-cased to compare equal. TXT and CAA values keep
|
||||
// their letter case.
|
||||
// extractRecordValue formats a DNS RR value as a string.
|
||||
func extractRecordValue(rr dns.RR) string {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
@@ -619,22 +616,22 @@ func extractRecordValue(rr dns.RR) string {
|
||||
case *dns.AAAA:
|
||||
return r.AAAA.String()
|
||||
case *dns.CNAME:
|
||||
return strings.ToLower(r.Target)
|
||||
return r.Target
|
||||
case *dns.MX:
|
||||
return fmt.Sprintf("%d %s", r.Preference, strings.ToLower(r.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, strings.ToLower(r.Target),
|
||||
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 strings.ToLower(r.Ns)
|
||||
return r.Ns
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package resolver_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
||||
)
|
||||
|
||||
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rr dns.RR
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "MX target lower-cased",
|
||||
rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."},
|
||||
want: "1 aspmx.l.google.com.",
|
||||
},
|
||||
{
|
||||
name: "NS target lower-cased",
|
||||
rr: &dns.NS{Ns: "x.ns.joker.COM."},
|
||||
want: "x.ns.joker.com.",
|
||||
},
|
||||
{
|
||||
name: "CNAME target lower-cased",
|
||||
rr: &dns.CNAME{Target: "WWW.Example.Com."},
|
||||
want: "www.example.com.",
|
||||
},
|
||||
{
|
||||
name: "SRV target lower-cased",
|
||||
rr: &dns.SRV{
|
||||
Priority: 10, Weight: 5, Port: 443,
|
||||
Target: "SIP.Example.Com.",
|
||||
},
|
||||
want: "10 5 443 sip.example.com.",
|
||||
},
|
||||
{
|
||||
name: "TXT value keeps its case",
|
||||
rr: &dns.TXT{Txt: []string{"Verify=AbC123"}},
|
||||
want: "Verify=AbC123",
|
||||
},
|
||||
{
|
||||
name: "CAA value keeps its case",
|
||||
rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"},
|
||||
want: `0 issue "LetsEncrypt.org"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ func (s *Server) SetupRoutes() {
|
||||
// Global middleware
|
||||
s.router.Use(chimw.Recoverer)
|
||||
s.router.Use(chimw.RequestID)
|
||||
s.router.Use(s.mw.SecurityHeaders())
|
||||
s.router.Use(s.mw.Logging())
|
||||
s.router.Use(s.mw.CORS())
|
||||
s.router.Use(chimw.Timeout(requestTimeout))
|
||||
|
||||
@@ -55,7 +55,10 @@ func buildServer(t *testing.T) *server.Server {
|
||||
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
||||
// server actually serves — the one Run builds and hands to
|
||||
// ListenAndServe — carries every socket-level timeout, plus the two
|
||||
// relationships the values must satisfy.
|
||||
// relationships the values must satisfy. Earlier tests asserted these
|
||||
// on newHTTPServer directly, which left the call site unguarded: a Run
|
||||
// that built its http.Server inline would drop every timeout with the
|
||||
// suite still green (https://git.eeqj.de/sneak/dnswatcher/issues/120).
|
||||
//
|
||||
// Run is driven to completion with an unbindable port: it builds and
|
||||
// stores s.httpServer, then ListenAndServe fails at once and Run
|
||||
@@ -63,13 +66,16 @@ func buildServer(t *testing.T) *server.Server {
|
||||
// goroutine after Run returns, so reading s.httpServer is free of any
|
||||
// data race. Nothing here measures elapsed time.
|
||||
//
|
||||
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
||||
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
||||
// rest of the request to ReadTimeout, counted from when it started
|
||||
// reading the request. If ReadTimeout were smaller, a request whose
|
||||
// headers arrived after ReadTimeout but within ReadHeaderTimeout would
|
||||
// get a read deadline that had already passed, so reading its body
|
||||
// would fail at once.
|
||||
// On the ReadTimeout >= ReadHeaderTimeout relationship: a smaller
|
||||
// ReadTimeout does NOT make the header phase unreachable. net/http's
|
||||
// (*Server).readHeaderTimeout applies ReadHeaderTimeout directly, so
|
||||
// the header read keeps its full budget. What breaks is the
|
||||
// whole-request deadline: once the headers are read, readRequest
|
||||
// installs a read deadline of t0+ReadTimeout, which is already in the
|
||||
// past when ReadTimeout is the smaller value, severing the request.
|
||||
// Verified against the pinned go1.25 net/http (Dockerfile golang
|
||||
// 1.25-alpine; go.mod go 1.25.5): src/net/http/server.go readRequest
|
||||
// and (*Server).readHeaderTimeout.
|
||||
func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||
// Sets an env var and touches viper global state, so like the
|
||||
// config tests it cannot use t.Parallel.
|
||||
|
||||
@@ -148,11 +148,6 @@ func New(
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
err := state.checkDataDirWritable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return state.Load()
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
@@ -350,27 +345,3 @@ func (s *State) GetCertificateState(
|
||||
|
||||
return cs, ok
|
||||
}
|
||||
|
||||
// checkDataDirWritable creates the data directory if needed, then writes
|
||||
// and removes the temp file that Save uses. It runs at startup so that an
|
||||
// unwritable directory stops the process, instead of the process running
|
||||
// with every save failing and only logged.
|
||||
func (s *State) checkDataDirWritable() error {
|
||||
dir := s.config.DataDir
|
||||
tmpPath := s.config.StatePath() + ".tmp"
|
||||
|
||||
err := os.MkdirAll(dir, dirPermissions)
|
||||
if err == nil {
|
||||
err = os.WriteFile(tmpPath, nil, filePermissions)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = os.Remove(tmpPath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("data directory %s is not writable: %w", dir, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,16 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx/fxtest"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/config"
|
||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
@@ -499,107 +493,6 @@ func TestSaveWritePermissionError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// startState builds a State through the real constructor and runs its
|
||||
// startup hook against dataDir, returning the startup error.
|
||||
func startState(t *testing.T, dataDir string) error {
|
||||
t.Helper()
|
||||
|
||||
g, err := globals.New(nil)
|
||||
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)
|
||||
}
|
||||
|
||||
lifecycle := fxtest.NewLifecycle(t)
|
||||
|
||||
_, err = state.New(lifecycle, state.Params{
|
||||
Logger: log,
|
||||
Config: &config.Config{DataDir: dataDir},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("state.New: %v", err)
|
||||
}
|
||||
|
||||
return lifecycle.Start(t.Context())
|
||||
}
|
||||
|
||||
// TestStartupFailsWhenDataDirNotWritable verifies that startup stops
|
||||
// with an error naming the data directory when it cannot be written.
|
||||
// The directory's parent is a regular file, which also fails as root.
|
||||
func TestStartupFailsWhenDataDirNotWritable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := filepath.Join(t.TempDir(), "file")
|
||||
|
||||
err := os.WriteFile(parent, nil, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing file: %v", err)
|
||||
}
|
||||
|
||||
dataDir := filepath.Join(parent, "data")
|
||||
|
||||
err = startState(t, dataDir)
|
||||
if err == nil {
|
||||
t.Fatal("startup should fail when the data directory is not writable")
|
||||
}
|
||||
|
||||
want := "data directory " + dataDir + " is not writable"
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("startup error %q does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupFailsWhenExistingDataDirNotWritable verifies that startup
|
||||
// stops when the data directory exists but the temp file that saving uses
|
||||
// cannot be written in it. A directory sitting at the temp file's path
|
||||
// makes that write fail, which also holds as root.
|
||||
func TestStartupFailsWhenExistingDataDirNotWritable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
|
||||
err := os.Mkdir(filepath.Join(dataDir, "state.json.tmp"), 0o700)
|
||||
if err != nil {
|
||||
t.Fatalf("creating directory: %v", err)
|
||||
}
|
||||
|
||||
err = startState(t, dataDir)
|
||||
if err == nil {
|
||||
t.Fatal("startup should fail when the data directory is not writable")
|
||||
}
|
||||
|
||||
want := "data directory " + dataDir + " is not writable"
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("startup error %q does not contain %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartupCreatesDataDir verifies that startup creates a missing
|
||||
// data directory and leaves nothing behind in it.
|
||||
func TestStartupCreatesDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
err := startState(t, dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("startup error: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("reading data directory: %v", err)
|
||||
}
|
||||
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("startup left %d entries in the data directory", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
|
||||
// current multi-hostname format.
|
||||
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
)
|
||||
|
||||
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
|
||||
func NewlyDisagreeingPairs(
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
) [][2]string {
|
||||
return newlyDisagreeingPairs(prev, current)
|
||||
}
|
||||
|
||||
// DetectHostnameChanges exports detectHostnameChanges for testing.
|
||||
func (w *Watcher) DetectHostnameChanges(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
) {
|
||||
w.detectHostnameChanges(ctx, hostname, prev, current)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/dnswatcher/internal/state"
|
||||
"sneak.berlin/go/dnswatcher/internal/watcher"
|
||||
)
|
||||
|
||||
const (
|
||||
host = "www.example.net"
|
||||
nsA = "a.ns.example.net."
|
||||
nsB = "b.ns.example.net."
|
||||
nsC = "c.ns.example.net."
|
||||
ip1 = "192.0.2.1"
|
||||
ip2 = "192.0.2.2"
|
||||
ip3 = "192.0.2.3"
|
||||
)
|
||||
|
||||
// hostnameState builds the state a check with these records leaves behind.
|
||||
func hostnameState(
|
||||
records map[string]map[string][]string,
|
||||
) *state.HostnameState {
|
||||
hs := &state.HostnameState{
|
||||
RecordsByNameserver: make(map[string]*state.NameserverRecordState),
|
||||
}
|
||||
|
||||
for ns, recs := range records {
|
||||
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
|
||||
Records: recs,
|
||||
Status: "ok",
|
||||
}
|
||||
}
|
||||
|
||||
return hs
|
||||
}
|
||||
|
||||
func TestNewlyDisagreeingPairs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
||||
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
||||
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
||||
alert := [][2]string{{nsA, nsB}}
|
||||
|
||||
// b already disagrees with a and c; then c changes, so a and c,
|
||||
// which agreed, now differ.
|
||||
bDiffers := map[string]map[string][]string{
|
||||
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip1}},
|
||||
}
|
||||
cChanges := map[string]map[string][]string{
|
||||
nsA: {"A": {ip1}}, nsB: {"A": {ip2}}, nsC: {"A": {ip3}},
|
||||
}
|
||||
|
||||
// Each case starts from the state loaded at startup and runs the
|
||||
// checks in order; want[i] is what check i alerts for.
|
||||
tests := []struct {
|
||||
name string
|
||||
loaded map[string]map[string][]string
|
||||
checks []map[string]map[string][]string
|
||||
want [][][2]string
|
||||
}{
|
||||
{
|
||||
name: "disagreement persisting across checks alerts once",
|
||||
loaded: agree,
|
||||
checks: []map[string]map[string][]string{disagree, disagree, disagree},
|
||||
want: [][][2]string{alert, nil, nil},
|
||||
},
|
||||
{
|
||||
name: "disagreement starting on a later check alerts on it",
|
||||
loaded: agree,
|
||||
checks: []map[string]map[string][]string{agree, agree, disagree},
|
||||
want: [][][2]string{nil, nil, alert},
|
||||
},
|
||||
{
|
||||
name: "disagreement in the loaded state does not alert",
|
||||
loaded: disagree,
|
||||
checks: []map[string]map[string][]string{disagree, disagree},
|
||||
want: [][][2]string{nil, nil},
|
||||
},
|
||||
{
|
||||
name: "nameserver new on the first check and disagreeing alerts once",
|
||||
loaded: onlyA,
|
||||
checks: []map[string]map[string][]string{disagree, disagree},
|
||||
want: [][][2]string{alert, nil},
|
||||
},
|
||||
{
|
||||
name: "disagreement after agreeing again alerts again",
|
||||
loaded: agree,
|
||||
checks: []map[string]map[string][]string{disagree, agree, disagree},
|
||||
want: [][][2]string{alert, nil, alert},
|
||||
},
|
||||
{
|
||||
name: "new disagreement while another nameserver differs alerts",
|
||||
loaded: bDiffers,
|
||||
checks: []map[string]map[string][]string{cChanges, cChanges},
|
||||
want: [][][2]string{{{nsA, nsC}}, nil},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prev := hostnameState(tt.loaded)
|
||||
|
||||
for i, current := range tt.checks {
|
||||
got := watcher.NewlyDisagreeingPairs(prev, current)
|
||||
if !slices.Equal(got, tt.want[i]) {
|
||||
t.Errorf(
|
||||
"check %d: alerted for %v, want %v",
|
||||
i, got, tt.want[i],
|
||||
)
|
||||
}
|
||||
|
||||
prev = hostnameState(current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInconsistencyAlert(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
onlyA := map[string]map[string][]string{nsA: {"A": {ip1}}}
|
||||
agree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip1}}}
|
||||
disagree := map[string]map[string][]string{nsA: {"A": {ip1}}, nsB: {"A": {ip2}}}
|
||||
|
||||
// Each case starts from the state loaded at startup and then sees
|
||||
// the nameservers disagree on three checks in a row.
|
||||
tests := []struct {
|
||||
name string
|
||||
loaded map[string]map[string][]string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "disagreement lasting several checks alerts once",
|
||||
loaded: agree,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "disagreement in the loaded state does not alert",
|
||||
loaded: disagree,
|
||||
want: 0,
|
||||
},
|
||||
{
|
||||
name: "nameserver new on the first check and disagreeing alerts once",
|
||||
loaded: onlyA,
|
||||
want: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The hostname change detection uses only the notifier.
|
||||
notifier := &mockNotifier{}
|
||||
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
|
||||
|
||||
prev := hostnameState(tt.loaded)
|
||||
|
||||
for range 3 {
|
||||
w.DetectHostnameChanges(t.Context(), host, prev, disagree)
|
||||
prev = hostnameState(disagree)
|
||||
}
|
||||
|
||||
got := 0
|
||||
|
||||
for _, n := range notifier.getNotifications() {
|
||||
if n.Title == "Inconsistency: "+host {
|
||||
got++
|
||||
}
|
||||
}
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("sent %d inconsistency alerts, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-41
@@ -366,7 +366,7 @@ func (w *Watcher) detectHostnameChanges(
|
||||
) {
|
||||
w.detectRecordChanges(ctx, hostname, prev, current)
|
||||
w.detectNSDisappearances(ctx, hostname, prev, current)
|
||||
w.detectInconsistencies(ctx, hostname, prev, current)
|
||||
w.detectInconsistencies(ctx, hostname, current)
|
||||
}
|
||||
|
||||
func (w *Watcher) detectRecordChanges(
|
||||
@@ -448,11 +448,22 @@ func (w *Watcher) detectNSDisappearances(
|
||||
func (w *Watcher) detectInconsistencies(
|
||||
ctx context.Context,
|
||||
hostname string,
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
) {
|
||||
for _, pair := range newlyDisagreeingPairs(prev, current) {
|
||||
ns1, ns2 := pair[0], pair[1]
|
||||
nameservers := make([]string, 0, len(current))
|
||||
for ns := range current {
|
||||
nameservers = append(nameservers, ns)
|
||||
}
|
||||
|
||||
sort.Strings(nameservers)
|
||||
|
||||
for i := range len(nameservers) - 1 {
|
||||
ns1 := nameservers[i]
|
||||
ns2 := nameservers[i+1]
|
||||
|
||||
if recordsEqual(current[ns1], current[ns2]) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"Hostname: %s\n%s: %v\n%s: %v",
|
||||
@@ -470,43 +481,6 @@ func (w *Watcher) detectInconsistencies(
|
||||
}
|
||||
}
|
||||
|
||||
// newlyDisagreeingPairs returns every pair of nameservers whose records
|
||||
// differ in current, in sorted order of name, except pairs where both
|
||||
// nameservers were in prev and already differed there. A nameserver
|
||||
// missing from prev is paired with every nameserver it differs from.
|
||||
func newlyDisagreeingPairs(
|
||||
prev *state.HostnameState,
|
||||
current map[string]map[string][]string,
|
||||
) [][2]string {
|
||||
nameservers := make([]string, 0, len(current))
|
||||
for ns := range current {
|
||||
nameservers = append(nameservers, ns)
|
||||
}
|
||||
|
||||
sort.Strings(nameservers)
|
||||
|
||||
var pairs [][2]string
|
||||
|
||||
for i, ns1 := range nameservers {
|
||||
for _, ns2 := range nameservers[i+1:] {
|
||||
if recordsEqual(current[ns1], current[ns2]) {
|
||||
continue
|
||||
}
|
||||
|
||||
prev1, ok1 := prev.RecordsByNameserver[ns1]
|
||||
prev2, ok2 := prev.RecordsByNameserver[ns2]
|
||||
|
||||
if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) {
|
||||
continue
|
||||
}
|
||||
|
||||
pairs = append(pairs, [2]string{ns1, ns2})
|
||||
}
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
func (w *Watcher) checkAllPorts(ctx context.Context) {
|
||||
// Phase 1: Build current IP:port → hostname associations
|
||||
// from fresh DNS data.
|
||||
|
||||
+2
-6
@@ -1,18 +1,14 @@
|
||||
#!/bin/sh
|
||||
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
||||
# make fmt-check and golangci-lint; its builder stage runs make test
|
||||
# and make build.
|
||||
#
|
||||
# --no-cache-filter=lint,builder runs both stages on every invocation;
|
||||
# otherwise an unchanged tree is served from the layer cache and passes
|
||||
# without linting or querying live DNS.
|
||||
# and make build. A successful build implies all of those passed.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --no-cache-filter=lint,builder .
|
||||
docker build .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+2
-6
@@ -1,10 +1,6 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# The tag comes from script/projectname.
|
||||
#
|
||||
# --no-cache-filter=lint,builder runs the lint stage and the builder
|
||||
# stage (make test) on every invocation; otherwise an unchanged tree is
|
||||
# served from the layer cache without linting or querying live DNS.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
@@ -12,7 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
|
||||
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user