Quellcode durchsuchen

control: expose request rejection audit counters

tags/v0.9.0
Jan vor 1 Monat
Ursprung
Commit
06bf511391
2 geänderte Dateien mit 149 neuen und 4 gelöschten Zeilen
  1. +54
    -4
      internal/control/control.go
  2. +95
    -0
      internal/control/control_test.go

+ 54
- 4
internal/control/control.go Datei anzeigen

@@ -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


+ 95
- 0
internal/control/control_test.go Datei anzeigen

@@ -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")


Laden…
Abbrechen
Speichern