check / check (push) Successful in 1m57s
When a report would take the report files past DATA_DIR_MAX_BYTES, reportbuf now deletes the oldest report files until it fits, and does the same at start when files left by an earlier run are already past it. A file joins the files that may be deleted, at its place by name, only once it is completely written, so a file still being written is never deleted. A report is refused with 507 only when the reports waiting to be written fill the cap on their own, and then no file is deleted. The reports of a failed write stop counting, and the part of its file written is removed. A file whose deletion fails keeps counting; one already deleted by hand counts as freed. Model: opus-5-5
122 lines
3.0 KiB
Go
122 lines
3.0 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.
|
|
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: 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", 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.
|
|
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),
|
|
)
|
|
}
|