From 06bf511391b5a96fa5387c9d77444501297c7b77 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 Apr 2026 08:27:56 +0200 Subject: [PATCH] control: expose request rejection audit counters --- internal/control/control.go | 58 +++++++++++++++++-- internal/control/control_test.go | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 4 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 25f5386..381a637 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -9,6 +9,7 @@ import ( "net/http" "strings" "sync" + "sync/atomic" "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" @@ -50,6 +51,23 @@ type Server struct { tx TXController drv platform.SoapyDriver // optional, for runtime stats streamSrc *audio.StreamSource // optional, for live audio ingest + audit auditCounters +} + +type auditEvent string + +const ( + auditMethodNotAllowed auditEvent = "methodNotAllowed" + auditUnsupportedMediaType auditEvent = "unsupportedMediaType" + auditBodyTooLarge auditEvent = "bodyTooLarge" + auditUnexpectedBody auditEvent = "unexpectedBody" +) + +type auditCounters struct { + methodNotAllowed uint64 + unsupportedMediaType uint64 + bodyTooLarge uint64 + unexpectedBody uint64 } const ( @@ -112,14 +130,37 @@ func hasRequestBody(r *http.Request) bool { return false } -func rejectBody(w http.ResponseWriter, r *http.Request) bool { +func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool { if !hasRequestBody(r) { return true } + s.recordAudit(auditUnexpectedBody) http.Error(w, noBodyErrMsg, http.StatusBadRequest) return false } +func (s *Server) recordAudit(evt auditEvent) { + switch evt { + case auditMethodNotAllowed: + atomic.AddUint64(&s.audit.methodNotAllowed, 1) + case auditUnsupportedMediaType: + atomic.AddUint64(&s.audit.unsupportedMediaType, 1) + case auditBodyTooLarge: + atomic.AddUint64(&s.audit.bodyTooLarge, 1) + case auditUnexpectedBody: + atomic.AddUint64(&s.audit.unexpectedBody, 1) + } +} + +func (s *Server) auditSnapshot() map[string]uint64 { + return map[string]uint64{ + "methodNotAllowed": atomic.LoadUint64(&s.audit.methodNotAllowed), + "unsupportedMediaType": atomic.LoadUint64(&s.audit.unsupportedMediaType), + "bodyTooLarge": atomic.LoadUint64(&s.audit.bodyTooLarge), + "unexpectedBody": atomic.LoadUint64(&s.audit.unexpectedBody), + } +} + func isAudioStreamContentType(r *http.Request) bool { ct := strings.TrimSpace(r.Header.Get("Content-Type")) if ct == "" { @@ -239,16 +280,18 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { if stream != nil { result["audioStream"] = stream.Stats() } + result["controlAudit"] = s.auditSnapshot() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) } func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !rejectBody(w, r) { + if !s.rejectBody(w, r) { return } s.mu.RLock() @@ -272,10 +315,12 @@ func (s *Server) handleRuntimeFaultReset(w http.ResponseWriter, r *http.Request) // ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !isAudioStreamContentType(r) { + s.recordAudit(auditUnsupportedMediaType) http.Error(w, audioStreamContentTypeError, http.StatusUnsupportedMediaType) return } @@ -304,6 +349,7 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { } var maxErr *http.MaxBytesError if errors.As(err, &maxErr) { + s.recordAudit(auditBodyTooLarge) http.Error(w, maxErr.Error(), http.StatusRequestEntityTooLarge) return } @@ -322,10 +368,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !rejectBody(w, r) { + if !s.rejectBody(w, r) { return } s.mu.RLock() @@ -345,10 +392,11 @@ func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTXStop(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { + s.recordAudit(auditMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !rejectBody(w, r) { + if !s.rejectBody(w, r) { return } s.mu.RLock() @@ -384,6 +432,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(cfg) case http.MethodPost: if !isJSONContentType(r) { + s.recordAudit(auditUnsupportedMediaType) http.Error(w, "Content-Type must be application/json", http.StatusUnsupportedMediaType) return } @@ -393,6 +442,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { statusCode := http.StatusBadRequest if strings.Contains(err.Error(), "http: request body too large") { statusCode = http.StatusRequestEntityTooLarge + s.recordAudit(auditBodyTooLarge) } http.Error(w, err.Error(), statusCode) return diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 8195b67..e25ea07 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -498,6 +498,101 @@ func TestConfigPatchEngineRejectsDoesNotUpdateSnapshot(t *testing.T) { } } +func TestRuntimeIncludesControlAudit(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + counts := controlAuditCounts(t, srv) + keys := []string{"methodNotAllowed", "unsupportedMediaType", "bodyTooLarge", "unexpectedBody"} + for _, key := range keys { + if counts[key] != 0 { + t.Fatalf("expected %s to start at 0, got %d", key, counts[key]) + } + } +} + +func TestControlAuditTracksMethodNotAllowed(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/audio/stream", nil)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 from audio stream GET, got %d", rec.Code) + } + counts := controlAuditCounts(t, srv) + if counts["methodNotAllowed"] != 1 { + t.Fatalf("expected methodNotAllowed=1, got %d", counts["methodNotAllowed"]) + } +} + +func TestControlAuditTracksUnsupportedMediaType(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetStreamSource(audio.NewStreamSource(256, 44100)) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/audio/stream", bytes.NewReader([]byte{0, 0})) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnsupportedMediaType { + t.Fatalf("expected 415 for audio stream content type, got %d", rec.Code) + } + counts := controlAuditCounts(t, srv) + if counts["unsupportedMediaType"] != 1 { + t.Fatalf("expected unsupportedMediaType=1, got %d", counts["unsupportedMediaType"]) + } +} + +func TestControlAuditTracksBodyTooLarge(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + limit := int(maxConfigBodyBytes) + body := []byte("{\"ps\":\"" + strings.Repeat("x", limit+1) + "\"}") + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413 for oversized config body, got %d", rec.Code) + } + counts := controlAuditCounts(t, srv) + if counts["bodyTooLarge"] != 1 { + t.Fatalf("expected bodyTooLarge=1, got %d", counts["bodyTooLarge"]) + } +} + +func TestControlAuditTracksUnexpectedBody(t *testing.T) { + srv := NewServer(cfgpkg.Default()) + srv.SetTXController(&fakeTXController{}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/tx/start", bytes.NewReader([]byte("body"))) + srv.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for unexpected body, got %d", rec.Code) + } + counts := controlAuditCounts(t, srv) + if counts["unexpectedBody"] != 1 { + t.Fatalf("expected unexpectedBody=1, got %d", counts["unexpectedBody"]) + } +} + +func controlAuditCounts(t *testing.T, srv *Server) map[string]uint64 { + t.Helper() + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/runtime", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("runtime request failed: %d", rec.Code) + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal runtime: %v", err) + } + raw, ok := payload["controlAudit"].(map[string]any) + if !ok { + t.Fatalf("controlAudit missing or wrong type: %T", payload["controlAudit"]) + } + counts := map[string]uint64{} + for key, value := range raw { + num, ok := value.(float64) + if !ok { + t.Fatalf("controlAudit %s not numeric: %T", key, value) + } + counts[key] = uint64(num) + } + return counts +} + func newConfigPostRequest(body []byte) *http.Request { req := httptest.NewRequest(http.MethodPost, "/config", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json")