1 Commits
Author SHA1 Message Date
sneak e5d708cefa fix(backend): report ingest correctness — propagate storage failure, 413 on oversize, global body cap (closes #23)
check / check (push) Successful in 45s
A buffer failure on POST /api/v1/reports now returns 500 instead of a
false `ok`, so clients can retry. Decode errors split: an over-limit
body returns 413 (via errors.As on `*http.MaxBytesError`), malformed
JSON stays 400. A new MaxBodyBytes middleware (1 MiB default) caps
every route — rejecting an oversized Content-Length up front and
capping the read otherwise — so the health check and future routes are
bounded too. The raw attacker-controlled geo blob is no longer logged,
only its length; client_id and timestamp are length-bounded before
logging. A decodeJSON handler helper is added. Panic recovery is now a
local middleware routing the stack through slog as structured JSON.
Storage failure uses 500: a full buffer or write error is server-side
and retryable.

Model: opus-4-8
2026-09-21 17:02:37 +00:00
13 changed files with 58 additions and 259 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Lint stage — fast feedback on formatting and lint issues. The
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
# nothing is installed here.
# golangci/golangci-lint:v2.12.2 (2026-08-10)
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
# golangci/golangci-lint:v2.7.2 (2026-08-09)
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
+11 -27
View File
@@ -10,27 +10,25 @@
# Status
pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
workflow, and the backend repo standard files are all on `main`. Frontend and
backend are both functional. Working toward the 1.0.0 milestone by closing the
remaining repo-compliance issues on the tracker.
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty:
src/main.js). Frontend is functional; backend is new and unmerged.
# Next Step
Confirm the `.gitea/workflows/check.yml` run is green (main always green
policy). The workflow file is already on `main`; what is unverified is that its
latest run passes.
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make
check green, and merge the branch to main. The branch adds the backend (buffered
zstd-compressed report storage), the CI workflow, and backend repo standard
files, so merging it also closes most compliance gaps.
# Completed Steps
- 2026-09-28: report ingest correctness (issue #23): a storage failure now
- 2026-09-21: report ingest correctness (issue #23): a storage failure now
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
route, not just the report route; the raw attacker-controlled `geo` blob is no
longer logged (only its length), and `client_id`, `timestamp` and decode error
text are length-bounded before logging; a `decodeJSON` handler helper was
added; and panic recovery now routes the stack through slog instead of chi's
plain-text stderr
longer logged (only its length) and `client_id`/`timestamp` are length-bounded
before logging; a `decodeJSON` handler helper was added; and panic recovery
now routes the stack through slog instead of chi's plain-text stderr
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
reports are flushed to disk on `SIGTERM` — previously a full flush window of
@@ -45,14 +43,6 @@ latest run passes.
frame/sniff/referrer/permissions headers) registered before CORS, and
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
digest); the previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result was meaningless.
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
canonical file first, so drift from the org standard fails the build instead
of silently degrading to defaults
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching
@@ -82,7 +72,7 @@ latest run passes.
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage)
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
@@ -104,9 +94,3 @@ latest run passes.
(main always green policy)
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
it
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
v2.12.0 and replaced by `gomodguard_v2`, so every backend lint run prints a
deprecation warning. The file is standardized and must never be edited in this
repo, so nothing can be done here beyond tracking it — tracked at
<https://git.eeqj.de/sneak/netwatch/issues/41>
+3 -5
View File
@@ -1,9 +1,5 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
@@ -18,7 +14,8 @@ linters:
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll:
line-length: 88
funlen:
@@ -30,5 +27,6 @@ linters:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
-19
View File
@@ -10,17 +10,6 @@ GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
# macOS ships shasum rather than sha256sum.
SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
# .golangci.yml is standardized org-wide and must never be edited here
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
# v1 keys, which left every threshold in the file inert while the build
# stayed green. The lint target therefore asserts the file still matches
# the canonical copy byte for byte. The check is a local hash comparison:
# no network, no remote schema, nothing unpinned in the build path.
GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
all: build
@@ -33,14 +22,6 @@ test:
timeout 30 go test ./...
lint:
@actual=$$($(SHA256SUM) .golangci.yml | cut -d' ' -f1); \
if [ "$$actual" != "$(GOLANGCI_CONFIG_SHA256)" ]; then \
echo ".golangci.yml has drifted from the org standard."; \
echo " expected $(GOLANGCI_CONFIG_SHA256)"; \
echo " actual $$actual"; \
echo "Restore it verbatim from sneak/prompts; do not edit it."; \
exit 1; \
fi
golangci-lint run ./...
fmt:
-3
View File
@@ -2,9 +2,6 @@ package handlers
import "log/slog"
// MaxLoggedFieldBytes exposes the log bound to the external tests.
const MaxLoggedFieldBytes = maxLoggedFieldBytes
// NewForTest builds a Handlers around a report sink and logger,
// bypassing the fx graph so handler behaviour (including the
// storage failure path) is exercisable in unit tests.
+4 -8
View File
@@ -6,9 +6,9 @@ import (
"net/http"
)
// maxLoggedFieldBytes bounds untrusted text (string fields,
// decode error text) before it is logged, so a caller cannot
// inflate log volume with an oversized value.
// maxLoggedFieldBytes bounds untrusted string fields before they
// are logged, so a caller cannot inflate log volume with an
// oversized value.
const maxLoggedFieldBytes = 128
type reportSample struct {
@@ -79,11 +79,7 @@ func (s *Handlers) decodeErrorStatus(err error) int {
return http.StatusRequestEntityTooLarge
}
// The decoder's error text can quote request bytes (a whole
// oversized number, for example), so it is bounded too.
s.log.Error("failed to decode report",
"error", boundedForLog(err.Error()),
)
s.log.Error("failed to decode report", "error", err)
return http.StatusBadRequest
}
+8 -82
View File
@@ -12,7 +12,6 @@ import (
"testing"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/middleware"
)
var errStorageFailed = errors.New("storage failed")
@@ -50,7 +49,7 @@ func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
h := newTestHandlers(stubAppender{err: errStorageFailed}, io.Discard)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
)
@@ -72,7 +71,7 @@ func TestHandleReportMalformedJSONIs400(t *testing.T) {
h := newTestHandlers(stubAppender{}, io.Discard)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{not json`),
)
@@ -91,20 +90,17 @@ func TestHandleReportOversizeIs413(t *testing.T) {
const limit = 32
h := newTestHandlers(stubAppender{}, io.Discard)
handler := (&middleware.Middleware{}).MaxBodyBytes(limit)(
h.HandleReport(),
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
)
// No declared length, so only the middleware's read cap can
// stop this body.
req.ContentLength = -1
// Emulate the body-size middleware capping the body so the
// handler observes a *http.MaxBytesError while decoding.
req.Body = http.MaxBytesReader(rec, req.Body, limit)
handler.ServeHTTP(rec, req)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversize status = %d, want %d",
@@ -122,7 +118,7 @@ func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/api/v1/reports",
strings.NewReader(
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
@@ -143,73 +139,3 @@ func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
t.Fatal("expected a bounded geo_bytes field in the log")
}
}
func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
t.Parallel()
long := strings.Repeat("c", 2*handlers.MaxLoggedFieldBytes)
var logbuf bytes.Buffer
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
),
)
h.HandleReport().ServeHTTP(rec, req)
var logged struct {
ClientID string `json:"client_id"`
Timestamp string `json:"timestamp"`
}
err := json.Unmarshal(logbuf.Bytes(), &logged)
if err != nil {
t.Fatalf("log line not JSON: %v (%q)", err, logbuf.String())
}
want := long[:handlers.MaxLoggedFieldBytes]
if logged.ClientID != want {
t.Fatalf("logged client_id is %d bytes, want %d",
len(logged.ClientID), len(want))
}
if logged.Timestamp != want {
t.Fatalf("logged timestamp is %d bytes, want %d",
len(logged.Timestamp), len(want))
}
}
func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) {
t.Parallel()
// A number too large for its int64 field makes the decoder's
// error text quote the whole number.
huge := strings.Repeat("9", 2*handlers.MaxLoggedFieldBytes)
var logbuf bytes.Buffer
h := newTestHandlers(stubAppender{}, &logbuf)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if strings.Contains(logbuf.String(), huge) {
t.Fatal("the whole oversized number was written to the log")
}
}
+2 -2
View File
@@ -261,8 +261,8 @@ func writeJSONError(w http.ResponseWriter, status int) {
// rejected immediately with 413. Bodies without a declared
// length (or that understate it) are capped as they are read, so
// a handler that reads the body sees a *http.MaxBytesError it can
// map to 413. Mounted again on a route group, it can only lower
// the limit: a cap applied earlier in the chain still holds.
// map to 413. Mount it with a different limit on a route group
// that needs a different bound.
func (s *Middleware) MaxBodyBytes(
limit int64,
) func(http.Handler) http.Handler {
+15 -24
View File
@@ -12,15 +12,6 @@ import (
"sneak.berlin/go/netwatch/internal/middleware"
)
const (
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
loopbackPeer = "127.0.0.1:5000"
// forwardedIP is the client address presented via X-Forwarded-For.
forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9"
)
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
t.Helper()
@@ -53,32 +44,32 @@ func clientIPCases() []clientIPCase {
return []clientIPCase{
{
name: "trusted proxy uses forwarded-for",
remoteAddr: loopbackPeer,
xff: forwardedIP,
want: forwardedIP,
remoteAddr: "127.0.0.1:5000",
xff: "203.0.113.7",
want: "203.0.113.7",
},
{
name: "trusted proxy uses left-most of chain",
remoteAddr: "10.1.2.3:5000",
xff: forwardedIP + ", 10.1.2.3",
want: forwardedIP,
xff: "203.0.113.7, 10.1.2.3",
want: "203.0.113.7",
},
{
name: "trusted proxy falls back to x-real-ip",
remoteAddr: loopbackPeer,
xRealIP: realIP,
want: realIP,
remoteAddr: "127.0.0.1:5000",
xRealIP: "203.0.113.9",
want: "203.0.113.9",
},
{
name: "untrusted peer ignores forwarded-for",
remoteAddr: "198.51.100.4:5000",
xff: forwardedIP,
xff: "203.0.113.7",
want: "198.51.100.4",
},
{
name: "untrusted peer ignores x-real-ip",
remoteAddr: "198.51.100.4:5000",
xRealIP: realIP,
xRealIP: "203.0.113.9",
want: "198.51.100.4",
},
{
@@ -88,7 +79,7 @@ func clientIPCases() []clientIPCase {
},
{
name: "trusted proxy with garbage header uses peer",
remoteAddr: loopbackPeer,
remoteAddr: "127.0.0.1:5000",
xff: "not-an-ip",
want: "127.0.0.1",
},
@@ -131,7 +122,7 @@ func TestSecurityHeaders(t *testing.T) {
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
want := map[string]string{
@@ -166,7 +157,7 @@ func TestMaxBodyBytesRejectsOversizeOnNonReadingRoute(t *testing.T) {
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/.well-known/healthcheck",
strings.NewReader(strings.Repeat("x", limit+1)),
)
@@ -194,7 +185,7 @@ func TestMaxBodyBytesAllowsWithinLimit(t *testing.T) {
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
req := httptest.NewRequest(
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1"}`),
)
@@ -221,7 +212,7 @@ func TestRecovererReturns500AndLogsThroughSlog(t *testing.T) {
)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
+1 -3
View File
@@ -169,9 +169,7 @@ func (b *Buffer) writeFile(data []byte) {
name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
path := filepath.Join(b.dataDir, name)
// path is built from the operator-supplied dataDir plus a
// generated timestamp, so it carries no external input.
f, err := os.OpenFile( //nolint:gosec // see comment above
f, err := os.OpenFile( //nolint:gosec // path built from controlled dataDir + timestamp
path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
filePerms,
-5
View File
@@ -1,5 +0,0 @@
package server
// MaxRequestBodyBytes exposes the router-wide body limit to the
// external tests.
const MaxRequestBodyBytes = maxRequestBodyBytes
+3 -3
View File
@@ -10,9 +10,9 @@ import (
const (
requestTimeout = 60 * time.Second
// maxRequestBodyBytes caps every request body. A route group
// can mount s.mw.MaxBodyBytes with a smaller value to lower
// its bound, but cannot raise it: this cap runs first.
// maxRequestBodyBytes caps every request body. A route that
// needs a different bound mounts s.mw.MaxBodyBytes with its
// own value on its group.
maxRequestBodyBytes int64 = 1 << 20 // 1 MiB
)
-67
View File
@@ -1,67 +0,0 @@
package server_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)
// TestHealthCheckRejectsOversizeBody sends the health check, which
// never reads its body, a body one byte over the limit. Only the
// router-wide body limit can reject it.
func TestHealthCheckRejectsOversizeBody(t *testing.T) {
t.Parallel()
var srv *server.Server
// The same constructors as main, never started: SetupRoutes is
// called directly, so nothing listens.
app := fxtest.New(t,
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Populate(&srv),
)
err := app.Err()
if err != nil {
t.Fatalf("build server: %v", err)
}
srv.SetupRoutes()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck",
strings.NewReader(
strings.Repeat("x", int(server.MaxRequestBodyBytes)+1),
),
)
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusRequestEntityTooLarge)
}
}