check / check (push) Failing after 34s
A buffer failure on POST /api/v1/reports now returns 500 instead of a false `ok`, so clients can retry. An over-limit body returns 413 (errors.As on `*http.MaxBytesError`); malformed JSON stays 400. A MaxBodyBytes middleware (1 MiB) caps every route, rejecting an oversized Content-Length up front and capping the read otherwise; a route group can only lower that limit. The raw geo blob is no longer logged, only its length; client_id, timestamp and decode error text are length-bounded before logging. A decodeJSON handler helper is added. Panic recovery routes the stack through slog as structured JSON. Storage failure uses 500: a full buffer or write error is server-side and retryable. Model: opus-5-5
119 lines
2.9 KiB
Go
119 lines
2.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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.
|
|
const maxLoggedFieldBytes = 128
|
|
|
|
type reportSample struct {
|
|
T int64 `json:"t"`
|
|
Latency *int `json:"latency"`
|
|
Error *string `json:"error"`
|
|
}
|
|
|
|
type reportHost struct {
|
|
History []reportSample `json:"history"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
type report struct {
|
|
ClientID string `json:"clientId"`
|
|
Geo json.RawMessage `json:"geo"`
|
|
Hosts []reportHost `json:"hosts"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
// HandleReport returns a handler that accepts telemetry
|
|
// reports from NetWatch clients.
|
|
func (s *Handlers) HandleReport() http.HandlerFunc {
|
|
type response struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var rpt report
|
|
|
|
err := s.decodeJSON(w, r, &rpt)
|
|
if err != nil {
|
|
s.respondJSON(w, r,
|
|
&response{Status: "error"},
|
|
s.decodeErrorStatus(err),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
s.logReportReceived(rpt)
|
|
|
|
err = s.buf.Append(rpt)
|
|
if err != nil {
|
|
s.log.Error("failed to buffer report", "error", err)
|
|
s.respondJSON(w, r,
|
|
&response{Status: "error"},
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// decodeErrorStatus logs a report decode failure and returns the
|
|
// status to send: 413 when the body exceeded the size limit,
|
|
// otherwise 400 for malformed JSON.
|
|
func (s *Handlers) decodeErrorStatus(err error) int {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
|
|
|
|
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()),
|
|
)
|
|
|
|
return http.StatusBadRequest
|
|
}
|
|
|
|
// logReportReceived logs an accepted report. Untrusted fields are
|
|
// bounded (client_id, timestamp) or reduced to a length
|
|
// (geo_bytes) so the raw attacker-controlled body never reaches
|
|
// the log.
|
|
func (s *Handlers) logReportReceived(rpt report) {
|
|
totalSamples := 0
|
|
for _, h := range rpt.Hosts {
|
|
totalSamples += len(h.History)
|
|
}
|
|
|
|
s.log.Info("report received",
|
|
"client_id", boundedForLog(rpt.ClientID),
|
|
"timestamp", boundedForLog(rpt.Timestamp),
|
|
"host_count", len(rpt.Hosts),
|
|
"total_samples", totalSamples,
|
|
"geo_bytes", len(rpt.Geo),
|
|
)
|
|
}
|
|
|
|
// boundedForLog truncates an untrusted string to a fixed byte
|
|
// bound so an attacker-controlled field cannot dominate the log.
|
|
func boundedForLog(s string) string {
|
|
if len(s) > maxLoggedFieldBytes {
|
|
return s[:maxLoggedFieldBytes]
|
|
}
|
|
|
|
return s
|
|
}
|