From eeeba4ef623b7dc8270b3ef7b36838954ee9009a Mon Sep 17 00:00:00 2001 From: Yanujz Date: Sun, 20 Sep 2026 11:40:31 +0200 Subject: [PATCH] Report build version on /healthz via X-Epmon-Version SECURITY.md tells reporters to read the version from /healthz headers, but no such header existed. Additive SetVersion on the API server, wired to the release vars in main; unset means omitted. Fixes #65 --- cmd/epmon/main.go | 4 +++- internal/api/api.go | 14 +++++++++++--- internal/api/middleware_test.go | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/cmd/epmon/main.go b/cmd/epmon/main.go index e61701c..743537b 100644 --- a/cmd/epmon/main.go +++ b/cmd/epmon/main.go @@ -116,7 +116,9 @@ func runDefault(args []string, stdout, stderr io.Writer) int { root := http.NewServeMux() root.Handle("/metrics", registry.Handler()) - root.Handle("/", api.New(cfg, st, nil).Handler()) + apiSrv := api.New(cfg, st, nil) + apiSrv.SetVersion(version) + root.Handle("/", apiSrv.Handler()) srv := newHTTPServer(cfg, api.Log(root)) // serveErr carries a bind/serve failure back to runDefault so boot can diff --git a/internal/api/api.go b/internal/api/api.go index c964f62..835a6cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -49,11 +49,16 @@ var openAPIYAML []byte // Server bundles config and storage for the handlers. Storage is the // store.Store port — any adapter (SQLite, Postgres, …) plugs in here. type Server struct { - cfg *config.Config - store store.Store - now func() time.Time + cfg *config.Config + store store.Store + now func() time.Time + version string } +// SetVersion stamps build identity for the /healthz version header +// (see SECURITY.md reporting flow). Empty means "omit the header". +func (s *Server) SetVersion(v string) { s.version = v } + // New builds a Server. now is injectable for tests (nil = time.Now). func New(cfg *config.Config, st store.Store, now func() time.Time) *Server { if now == nil { @@ -169,6 +174,9 @@ func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusServiceUnavailable, "unavailable", "store unreachable") return } + if s.version != "" { + w.Header().Set("X-Epmon-Version", s.version) + } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 8a13b8a..5f05d7b 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -199,6 +199,28 @@ func TestHealthzBoundedPing(t *testing.T) { } } +// TestHealthzVersionHeader asserts the SECURITY.md reporting flow: +// a stamped server reports its build on /healthz, an unstamped one +// omits the header entirely. +func TestHealthzVersionHeader(t *testing.T) { + srv, _ := testServer(t) + srv.SetVersion("v9.9.9") + h := srv.Handler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/healthz", nil)) + if got := rec.Header().Get("X-Epmon-Version"); got != "v9.9.9" { + t.Errorf("X-Epmon-Version = %q, want v9.9.9", got) + } + + plain, _ := testServer(t) + rec = httptest.NewRecorder() + plain.Handler().ServeHTTP(rec, httptest.NewRequest("GET", "/healthz", nil)) + if got := rec.Header().Get("X-Epmon-Version"); got != "" { + t.Errorf("X-Epmon-Version = %q, want absent when unset", got) + } +} + func TestHealthzReadiness(t *testing.T) { srv, st := testServer(t) h := srv.Handler()