check / check (push) Waiting to run
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
128 lines
3.2 KiB
Go
128 lines
3.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
|
)
|
|
|
|
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, sent as application/json.
|
|
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.respondJSON(w, r,
|
|
&response{Status: "error"},
|
|
s.appendErrorStatus(err),
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
s.respondJSON(w, r, &response{Status: "ok"}, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
// decodeErrorStatus logs a report decode failure and returns the
|
|
// status to send: 415 when the report was not sent as JSON, 413 when
|
|
// the body exceeded the size limit, otherwise 400 for malformed JSON.
|
|
func (s *Handlers) decodeErrorStatus(err error) int {
|
|
if errors.Is(err, errNotJSON) {
|
|
s.log.Warn("report refused: Content-Type is not application/json")
|
|
|
|
return http.StatusUnsupportedMediaType
|
|
}
|
|
|
|
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", logger.BoundedForLog(err.Error()),
|
|
)
|
|
|
|
return http.StatusBadRequest
|
|
}
|
|
|
|
// appendErrorStatus logs a failure to store a report and returns
|
|
// the status to send: 507 when the reports waiting to be written fill
|
|
// the size cap, otherwise 500, as when report files cannot be written.
|
|
func (s *Handlers) appendErrorStatus(err error) int {
|
|
if errors.Is(err, reportbuf.ErrFull) {
|
|
s.log.Warn("report refused: " +
|
|
"reports waiting to be written fill the size cap")
|
|
|
|
return http.StatusInsufficientStorage
|
|
}
|
|
|
|
s.log.Error("failed to buffer report", "error", err)
|
|
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// 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", logger.BoundedForLog(rpt.ClientID),
|
|
"timestamp", logger.BoundedForLog(rpt.Timestamp),
|
|
"host_count", len(rpt.Hosts),
|
|
"total_samples", totalSamples,
|
|
"geo_bytes", len(rpt.Geo),
|
|
)
|
|
}
|