diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index a45a5ed..9bc15ed 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "log" - "net/http" "os" "os/signal" "syscall" @@ -109,8 +108,9 @@ func main() { // --- default: HTTP only --- srv := ctrlpkg.NewServer(cfg) - log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", cfg.Control.ListenAddress) - log.Fatal(http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler())) + server := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) + log.Printf("fm-rds-tx listening on %s (TX default: off, use --tx for hardware)", server.Addr) + log.Fatal(server.ListenAndServe()) } // selectDriver picks the best available driver based on config and build tags. @@ -228,9 +228,10 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a log.Println("TX ready (idle) — POST /tx/start to begin") } + ctrlServer := ctrlpkg.NewHTTPServer(cfg, srv.Handler()) go func() { - log.Printf("control plane on %s", cfg.Control.ListenAddress) - if err := http.ListenAndServe(cfg.Control.ListenAddress, srv.Handler()); err != nil { + log.Printf("control plane on %s (read=%s write=%s idle=%s)", ctrlServer.Addr, ctrlServer.ReadTimeout, ctrlServer.WriteTimeout, ctrlServer.IdleTimeout) + if err := ctrlServer.ListenAndServe(); err != nil { log.Printf("http: %v", err) } }() diff --git a/docs/README.md b/docs/README.md index 9549c41..34424e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,6 +87,8 @@ All major TX parameters are hot-reloadable via `POST /config` during live transm Available endpoints: `/healthz`, `/status`, `/runtime`, `/config` (GET/POST), `/dry-run`, `/tx/start`, `/tx/stop` +Control-plane HTTP server is configured with 5s read, 10s write, and 60s idle timeouts plus a 1 MiB header limit to reduce slow-client abuse. + ### Internal DSP module - `cd internal` - `go test ./...` diff --git a/internal/control/server.go b/internal/control/server.go new file mode 100644 index 0000000..9fcd5cd --- /dev/null +++ b/internal/control/server.go @@ -0,0 +1,27 @@ +package control + +import ( + "net/http" + "time" + + "github.com/jan/fm-rds-tx/internal/config" +) + +const ( + defaultReadTimeout = 5 * time.Second + defaultWriteTimeout = 10 * time.Second + defaultIdleTimeout = 60 * time.Second + defaultMaxHeaderBytes = 1 << 20 // 1 MiB +) + +// NewHTTPServer returns a configured HTTP server for the control plane. +func NewHTTPServer(cfg config.Config, handler http.Handler) *http.Server { + return &http.Server{ + Addr: cfg.Control.ListenAddress, + Handler: handler, + ReadTimeout: defaultReadTimeout, + WriteTimeout: defaultWriteTimeout, + IdleTimeout: defaultIdleTimeout, + MaxHeaderBytes: defaultMaxHeaderBytes, + } +} diff --git a/internal/control/server_test.go b/internal/control/server_test.go new file mode 100644 index 0000000..9f8cb95 --- /dev/null +++ b/internal/control/server_test.go @@ -0,0 +1,33 @@ +package control + +import ( + "net/http" + "testing" + + cfgpkg "github.com/jan/fm-rds-tx/internal/config" +) + +func TestNewHTTPServerConfig(t *testing.T) { + cfg := cfgpkg.Default() + handler := http.NewServeMux() + srv := NewHTTPServer(cfg, handler) + + if srv.Addr != cfg.Control.ListenAddress { + t.Fatalf("expected server address %q, got %q", cfg.Control.ListenAddress, srv.Addr) + } + if srv.Handler != handler { + t.Fatalf("expected handler to be preserved") + } + if srv.ReadTimeout != defaultReadTimeout { + t.Fatalf("expected read timeout %s, got %s", defaultReadTimeout, srv.ReadTimeout) + } + if srv.WriteTimeout != defaultWriteTimeout { + t.Fatalf("expected write timeout %s, got %s", defaultWriteTimeout, srv.WriteTimeout) + } + if srv.IdleTimeout != defaultIdleTimeout { + t.Fatalf("expected idle timeout %s, got %s", defaultIdleTimeout, srv.IdleTimeout) + } + if srv.MaxHeaderBytes != defaultMaxHeaderBytes { + t.Fatalf("expected max header bytes %d, got %d", defaultMaxHeaderBytes, srv.MaxHeaderBytes) + } +}