1 Commits
Author SHA1 Message Date
sneak 95e0c30ad6 Reports refused while report files cannot be written (closes #123)
check / check (push) Canceled after 0s
A failed write of a report file used to drop its reports, and
POST /api/v1/reports kept answering 200 for as long as writes failed.
Now a failed write puts its reports back in the buffer, still counted
against DATA_DIR_MAX_BYTES, and Append refuses every report, which the
handler answers 500, until a write succeeds. The minute flush tries
the held reports again, so storing resumes with no restart once
writing works, and the buffer takes nothing new while writes fail.
Tests also cover a report appended while a write that fails runs, and
the cap once a failed write's reports are written.

Model: opus-5-5
2026-10-14 10:44:11 +00:00
8 changed files with 31 additions and 259 deletions
+16 -18
View File
@@ -420,10 +420,9 @@ address nginx passes on and no other. The image:
- Listens on port 8080 by default (override with `PORT` env var)
- Takes the client address from `X-Forwarded-For` only on requests from the
reverse proxies named in `TRUSTED_PROXIES`, by default from any address in
`10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`. `bin/entrypoint.sh` checks
each entry with `netwatch-server check-cidr`, which parses it as the backend
parses its own `TRUSTED_PROXIES`
reverse proxies named in `TRUSTED_PROXIES`, and by default from none.
`bin/entrypoint.sh` checks each entry with `netwatch-server check-cidr`, which
parses it as the backend parses its own `TRUSTED_PROXIES`
- Sends access logs to stdout
- Caches static assets with immutable headers
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
@@ -442,9 +441,9 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
- **Port:** container port `8080`.
- **Volume:** container path `/data`; the reports are kept in `/data/reports`.
- **Environment variables:** none is required. An empty one counts as unset,
except `TRUSTED_PROXIES`, and one set to a value netwatch cannot use stops the
container at start, with the reason in its log.
- **Environment variables:** none is required. An empty one counts as unset, and
one set to a value netwatch cannot use stops the container at start, with the
reason in its log.
- `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot
be used: the backend listens on it inside the container
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a
@@ -458,17 +457,16 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
The container also stops if the path goes through a symbolic link that
leads out of `/data` or is written as a full path
- `TRUSTED_PROXIES`, default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`: the
addresses the reverse proxy in front of the container connects from, as IP
addresses or CIDRs separated by commas. nginx takes the client address
from `X-Forwarded-For` only on a request from one of them, and the rate
limit counts that address. A list replaces the default. Set and empty,
`X-Forwarded-For` is ignored and every client behind the proxy shares the
proxy's one allowance of `REPORTS_PER_MINUTE`. Any client that connects
from one of these addresses can write its own `X-Forwarded-For`, and
through a port Docker publishes, every client may connect from the Docker
network's gateway, such as `172.17.0.1`, which the default includes; where
that matters, name only the addresses nothing but the proxy connects from.
- `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
in front of the container connects from, as an IP address or CIDR; several
are separated by commas. nginx takes the client address from
`X-Forwarded-For` only on a request from one of them, and the rate limit
counts that address. Unset, `X-Forwarded-For` is ignored and every client
behind the proxy shares the proxy's one allowance of `REPORTS_PER_MINUTE`.
Name only addresses nothing but the proxy connects from: any client that
connects from one can write its own `X-Forwarded-For`, and through a port
Docker publishes, every client may connect from the Docker network's
gateway, such as `172.17.0.1`.
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
the backend records Prometheus metrics of its requests and serves them at
`/metrics` on the container port, to requests with this user name and
-12
View File
@@ -32,18 +32,6 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
others check that a report appended while a write runs is kept, behind that
write's own, when the write fails, and that the cap still holds exactly once a
failed write's reports are written
- 2026-10-14: a request whose handler panics is logged with status 500 and
counted with `code` 500 in the metrics
([#125](https://git.eeqj.de/sneak/netwatch/issues/125)), not 200. `Recoverer`
is still the first middleware; `Write500OnPanic`, after the request log and
after the metrics, writes the 500 as the panic passes and lets it go on to
Sentry and `Recoverer`, which now writes a 500 only when no status was written
- 2026-10-14: with `TRUSTED_PROXIES` unset, the container's nginx trusts the
three private ranges of RFC 1918, `10.0.0.0/8`, `172.16.0.0/12` and
`192.168.0.0/16` ([#119](https://git.eeqj.de/sneak/netwatch/issues/119)), as
sneak ruled on 2026-09-22 for every app; it trusted no proxy before. A list
replaces them, and set and empty it trusts none. The backend inside the
container still trusts only `127.0.0.1/32`
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
`Content-Type: application/json`, parameters such as `charset` allowed, and
refuses any other with 415 and the usual error body, storing nothing
+4 -6
View File
@@ -34,11 +34,9 @@ fi
# TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client
# address from X-Forwarded-For only on a request from one of them.
# Unset, they are the three private ranges of RFC 1918; set and empty,
# nginx trusts no one, which is why the default below follows - and not
# :-. configs/nginx.conf includes the file written here, one
# set_real_ip_from line per entry.
# address from X-Forwarded-For only on a request from one of them, so
# unset or empty, it trusts no one. configs/nginx.conf includes the file
# written here, one set_real_ip_from line per entry.
#
# nginx looks up an entry it cannot read as an address as a hostname,
# and trusts what it finds (1.2.3 is found as 1.2.0.3). So each entry
@@ -47,7 +45,7 @@ fi
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
# below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names.
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
TRUSTED_PROXIES="${TRUSTED_PROXIES:-}"
set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in
+3 -4
View File
@@ -18,10 +18,9 @@ server {
# The client address comes from X-Forwarded-For only on a request
# from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh
# writes one set_real_ip_from line for each into this file. Unset,
# TRUSTED_PROXIES is the three private ranges of RFC 1918; set and
# empty, the file is empty and the client address is the one each
# request comes from.
# writes one set_real_ip_from line for each into this file, and
# leaves it empty when TRUSTED_PROXIES is unset, so that by default
# the client address is the one each request comes from.
include /etc/nginx/trusted-proxies.conf;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
+4 -41
View File
@@ -297,16 +297,12 @@ func (s *Middleware) MaxBodyBytes(
// Recoverer returns middleware that recovers from a panic in a
// downstream handler, logs the panic and stack trace through
// slog, and responds 500 with no body, unless a status was already
// written, as Write500OnPanic writes one for a handler's panic.
// http.ErrAbortHandler is re-panicked so the server can abort the
// response as intended.
// slog, and responds 500 with no body. http.ErrAbortHandler is
// re-panicked so the server can abort the response as intended.
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
rec := recover()
if rec == nil {
@@ -324,43 +320,10 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
"stack", string(debug.Stack()),
)
if ww.Status() == 0 {
ww.WriteHeader(http.StatusInternalServerError)
}
w.WriteHeader(http.StatusInternalServerError)
}()
next.ServeHTTP(ww, r)
},
)
}
}
// Write500OnPanic returns middleware that writes 500 when a downstream
// handler panics before writing a status, and lets the panic go on to
// Sentry and Recoverer. Recoverer, which comes first, would write its
// 500 only after the request log and the metrics had recorded the
// status they saw; placed after each of them, this makes them record
// the 500.
func (s *Middleware) Write500OnPanic() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
rec := recover()
if rec == nil {
return
}
if ww.Status() == 0 {
ww.WriteHeader(http.StatusInternalServerError)
}
panic(rec)
}()
next.ServeHTTP(ww, r)
next.ServeHTTP(w, r)
},
)
}
-169
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"encoding/json"
"errors"
"log"
"log/slog"
"net/http"
"net/http/httptest"
@@ -17,9 +16,7 @@ import (
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
)
const (
@@ -326,172 +323,6 @@ func TestRecovererRepanicsOnAbortHandler(t *testing.T) {
}
}
// TestHandlerPanicIsLoggedAndCountedAs500 sends a request to a handler
// that panics through the middleware SetupRoutes puts around a route
// with metrics, in its order, leaving out those that write no status
// for a panic and Sentry, which TestSentry covers. The request log line
// and the metrics must give the 500 the client gets, and the panic must
// still reach Recoverer, which logs it.
func TestHandlerPanicIsLoggedAndCountedAs500(t *testing.T) {
t.Parallel()
var logbuf bytes.Buffer
mw := middleware.NewWithLogger(
slog.New(slog.NewJSONHandler(&logbuf, nil)),
)
registry := prometheus.NewRegistry()
handler := chi.Chain(
mw.Recoverer(),
chimiddleware.RequestID,
mw.Logging(),
mw.Write500OnPanic(),
mw.Metrics(registry),
mw.Write500OnPanic(),
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("boom")
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d",
rec.Code, http.StatusInternalServerError)
}
request := findLogLine(t, logbuf.Bytes(), "request")
if request.Status != http.StatusInternalServerError {
t.Errorf("request log status = %d, want %d",
request.Status, http.StatusInternalServerError)
}
recovered := findLogLine(t, logbuf.Bytes(), "panic recovered")
if recovered.Panic != "boom" {
t.Errorf("panic recovered line has panic %q, want %q",
recovered.Panic, "boom")
}
for _, name := range []string{
"http_request_duration_seconds", "http_response_size_bytes",
} {
codes := metricCodes(t, registry, name)
if len(codes) != 1 || codes[0] != "500" {
t.Errorf("%s code labels = %v, want [500]", name, codes)
}
}
}
// loggedLine holds the fields the tests read from a JSON log line.
type loggedLine struct {
Msg string `json:"msg"`
Status int `json:"status"`
Panic string `json:"panic"`
}
// findLogLine returns the first of the JSON log lines in logged whose
// message is msg.
func findLogLine(t *testing.T, logged []byte, msg string) loggedLine {
t.Helper()
dec := json.NewDecoder(bytes.NewReader(logged))
for {
var line loggedLine
err := dec.Decode(&line)
if err != nil {
t.Fatalf("no %q line in the log: %v (%q)", msg, err, logged)
}
if line.Msg == msg {
return line
}
}
}
// metricCodes returns the code label of each series of the metric
// family name in registry.
func metricCodes(
t *testing.T,
registry *prometheus.Registry,
name string,
) []string {
t.Helper()
families, err := registry.Gather()
if err != nil {
t.Fatalf("gather metrics: %v", err)
}
var codes []string
for _, family := range families {
if family.GetName() != name {
continue
}
for _, metric := range family.GetMetric() {
for _, label := range metric.GetLabel() {
if label.GetName() == "code" {
codes = append(codes, label.GetValue())
}
}
}
}
return codes
}
// TestRecovererWritesNoSecondStatus checks, on a real server, that
// Recoverer does not write its 500 again after Write500OnPanic has
// written one, which the server would log as a superfluous
// WriteHeader call.
func TestRecovererWritesNoSecondStatus(t *testing.T) {
t.Parallel()
var serverLog bytes.Buffer
mw := middleware.NewWithLogger(slog.New(slog.DiscardHandler))
server := httptest.NewUnstartedServer(chi.Chain(
mw.Recoverer(),
mw.Write500OnPanic(),
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("boom")
}))
server.Config.ErrorLog = log.New(&serverLog, "", 0)
server.Start()
req, err := http.NewRequestWithContext(t.Context(),
http.MethodGet, server.URL, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := server.Client().Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
_ = resp.Body.Close()
// Close waits for the handler to return, so whatever the server
// logs for this request is in serverLog after it.
server.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Errorf("status = %d, want %d",
resp.StatusCode, http.StatusInternalServerError)
}
if serverLog.Len() != 0 {
t.Errorf("server logged %q", serverLog.String())
}
}
// TestLoggingCutsRequestStringsToBound sends an over-long URL and
// over-long header values, and checks the request log writes each
// one cut to logger.MaxLoggedFieldBytes.
+2 -7
View File
@@ -34,16 +34,13 @@ func (s *Server) SetupRoutes() {
s.router.Use(s.mw.Recoverer())
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging())
// s.mw.Recoverer would write a panic's 500 only after the panic has
// passed the request log, so it is written here, where the log sees it.
s.router.Use(s.mw.Write500OnPanic())
s.router.Use(s.mw.SecurityHeaders())
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
s.router.Use(middleware.Timeout(requestTimeout))
// Sentry reports a panic, then panics again, so that it still
// reaches s.mw.Recoverer.
// Sentry reports a panic, then panics again, so that s.mw.Recoverer
// still answers 500.
if s.params.Config.SentryDSN != "" {
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
}
@@ -67,8 +64,6 @@ func (s *Server) SetupRoutes() {
// other.
if s.params.Config.MetricsUsername != "" {
r.Use(s.mw.Metrics(registry))
// Likewise inside the metrics.
r.Use(s.mw.Write500OnPanic())
}
r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
+2 -2
View File
@@ -64,8 +64,8 @@ main() {
# configs/nginx.conf is a template: the image renders it over its own
# default.conf, with the same port and limit bin/entrypoint.sh uses.
# The empty file it includes trusts no proxy, as bin/entrypoint.sh
# writes it when TRUSTED_PROXIES is set and empty. It also includes
# the security headers, so the page runs under the shipped policy.
# writes it when TRUSTED_PROXIES is unset. It also includes the
# security headers, so the page runs under the shipped policy.
docker run -d --rm --name "$SERVER" \
--network "$NETWORK" --network-alias netwatch \
-e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \