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

No files matched your search

+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) - Listens on port 8080 by default (override with `PORT` env var)
- Takes the client address from `X-Forwarded-For` only on requests from the - 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 reverse proxies named in `TRUSTED_PROXIES`, and by default from none.
`10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`. `bin/entrypoint.sh` checks `bin/entrypoint.sh` checks each entry with `netwatch-server check-cidr`, which
each entry with `netwatch-server check-cidr`, which parses it as the backend parses it as the backend parses its own `TRUSTED_PROXIES`
parses its own `TRUSTED_PROXIES`
- Sends access logs to stdout - Sends access logs to stdout
- Caches static assets with immutable headers - Caches static assets with immutable headers
- Sends the security headers `REPO_POLICIES.md` requires on every response, as - 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`. - **Port:** container port `8080`.
- **Volume:** container path `/data`; the reports are kept in `/data/reports`. - **Volume:** container path `/data`; the reports are kept in `/data/reports`.
- **Environment variables:** none is required. An empty one counts as unset, - **Environment variables:** none is required. An empty one counts as unset, and
except `TRUSTED_PROXIES`, and one set to a value netwatch cannot use stops the one set to a value netwatch cannot use stops the container at start, with the
container at start, with the reason in its log. reason in its log.
- `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot - `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot
be used: the backend listens on it inside the container be used: the backend listens on it inside the container
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a - `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 `/`. 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 The container also stops if the path goes through a symbolic link that
leads out of `/data` or is written as a full path 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 - `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
addresses the reverse proxy in front of the container connects from, as IP in front of the container connects from, as an IP address or CIDR; several
addresses or CIDRs separated by commas. nginx takes the client address are separated by commas. nginx takes the client address from
from `X-Forwarded-For` only on a request from one of them, and the rate `X-Forwarded-For` only on a request from one of them, and the rate limit
limit counts that address. A list replaces the default. Set and empty, counts that address. Unset, `X-Forwarded-For` is ignored and every client
`X-Forwarded-For` is ignored and every client behind the proxy shares the behind the proxy shares the proxy's one allowance of `REPORTS_PER_MINUTE`.
proxy's one allowance of `REPORTS_PER_MINUTE`. Any client that connects Name only addresses nothing but the proxy connects from: any client that
from one of these addresses can write its own `X-Forwarded-For`, and connects from one can write its own `X-Forwarded-For`, and through a port
through a port Docker publishes, every client may connect from the Docker Docker publishes, every client may connect from the Docker network's
network's gateway, such as `172.17.0.1`, which the default includes; where gateway, such as `172.17.0.1`.
that matters, name only the addresses nothing but the proxy connects from.
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set, - `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
the backend records Prometheus metrics of its requests and serves them at the backend records Prometheus metrics of its requests and serves them at
`/metrics` on the container port, to requests with this user name and `/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 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 write's own, when the write fails, and that the cap still holds exactly once a
failed write's reports are written 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 - 2026-10-14: `POST /api/v1/reports` takes only a report sent with
`Content-Type: application/json`, parameters such as `charset` allowed, and `Content-Type: application/json`, parameters such as `charset` allowed, and
refuses any other with 415 and the usual error body, storing nothing 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, # TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client # 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. # address from X-Forwarded-For only on a request from one of them, so
# Unset, they are the three private ranges of RFC 1918; set and empty, # unset or empty, it trusts no one. configs/nginx.conf includes the file
# nginx trusts no one, which is why the default below follows - and not # written here, one set_real_ip_from line per entry.
# :-. 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, # 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 # 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 # 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 # below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names. # 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 set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in case "$proxy" in
+3 -4
View File
@@ -18,10 +18,9 @@ server {
# The client address comes from X-Forwarded-For only on a request # The client address comes from X-Forwarded-For only on a request
# from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh # from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh
# writes one set_real_ip_from line for each into this file. Unset, # writes one set_real_ip_from line for each into this file, and
# TRUSTED_PROXIES is the three private ranges of RFC 1918; set and # leaves it empty when TRUSTED_PROXIES is unset, so that by default
# empty, the file is empty and the client address is the one each # the client address is the one each request comes from.
# request comes from.
include /etc/nginx/trusted-proxies.conf; include /etc/nginx/trusted-proxies.conf;
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
real_ip_recursive on; 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 // Recoverer returns middleware that recovers from a panic in a
// downstream handler, logs the panic and stack trace through // downstream handler, logs the panic and stack trace through
// slog, and responds 500 with no body, unless a status was already // slog, and responds 500 with no body. http.ErrAbortHandler is
// written, as Write500OnPanic writes one for a handler's panic. // re-panicked so the server can abort the response as intended.
// http.ErrAbortHandler is re-panicked so the server can abort the
// response as intended.
func (s *Middleware) Recoverer() func(http.Handler) http.Handler { func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() { defer func() {
rec := recover() rec := recover()
if rec == nil { if rec == nil {
@@ -324,43 +320,10 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
"stack", string(debug.Stack()), "stack", string(debug.Stack()),
) )
if ww.Status() == 0 { w.WriteHeader(http.StatusInternalServerError)
ww.WriteHeader(http.StatusInternalServerError)
}
}() }()
next.ServeHTTP(ww, r) next.ServeHTTP(w, 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)
}, },
) )
} }
-169
View File
@@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"log"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -17,9 +16,7 @@ import (
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware" "sneak.berlin/go/netwatch/internal/middleware"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware" chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus"
) )
const ( 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 // TestLoggingCutsRequestStringsToBound sends an over-long URL and
// over-long header values, and checks the request log writes each // over-long header values, and checks the request log writes each
// one cut to logger.MaxLoggedFieldBytes. // 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(s.mw.Recoverer())
s.router.Use(middleware.RequestID) s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging()) 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.SecurityHeaders())
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins)) s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes)) s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
s.router.Use(middleware.Timeout(requestTimeout)) s.router.Use(middleware.Timeout(requestTimeout))
// Sentry reports a panic, then panics again, so that it still // Sentry reports a panic, then panics again, so that s.mw.Recoverer
// reaches s.mw.Recoverer. // still answers 500.
if s.params.Config.SentryDSN != "" { if s.params.Config.SentryDSN != "" {
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle) s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
} }
@@ -67,8 +64,6 @@ func (s *Server) SetupRoutes() {
// other. // other.
if s.params.Config.MetricsUsername != "" { if s.params.Config.MetricsUsername != "" {
r.Use(s.mw.Metrics(registry)) r.Use(s.mw.Metrics(registry))
// Likewise inside the metrics.
r.Use(s.mw.Write500OnPanic())
} }
r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck()) 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 # 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. # default.conf, with the same port and limit bin/entrypoint.sh uses.
# The empty file it includes trusts no proxy, as bin/entrypoint.sh # The empty file it includes trusts no proxy, as bin/entrypoint.sh
# writes it when TRUSTED_PROXIES is set and empty. It also includes # writes it when TRUSTED_PROXIES is unset. It also includes the
# the security headers, so the page runs under the shipped policy. # security headers, so the page runs under the shipped policy.
docker run -d --rm --name "$SERVER" \ docker run -d --rm --name "$SERVER" \
--network "$NETWORK" --network-alias netwatch \ --network "$NETWORK" --network-alias netwatch \
-e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \ -e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \