feat: add new Prometheus metrics and bearer token auth for /metrics
Add 6 new Prometheus metrics for richer observability: - auth_attempts_by_country_total (counter by country) - commands_executed_total (counter by shell via OnCommand callback) - human_score (histogram of final detection scores) - storage_login_attempts_total, storage_unique_ips, storage_sessions_total (gauges via custom collector querying GetDashboardStats on each scrape) Add optional bearer token authentication for the /metrics endpoint via web.metrics_token config option. Uses crypto/subtle.ConstantTimeCompare. Empty token (default) means no auth for backwards compatibility. Also adds "cisco" to pre-initialized session/command metric labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
@@ -21,7 +23,8 @@ type Server struct {
|
||||
|
||||
// NewServer creates a new web Server with routes registered.
|
||||
// If metricsHandler is non-nil, a /metrics endpoint is registered.
|
||||
func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Handler) (*Server, error) {
|
||||
// If metricsToken is non-empty, the metrics endpoint requires Bearer token auth.
|
||||
func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Handler, metricsToken string) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -42,7 +45,11 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
|
||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||
|
||||
if metricsHandler != nil {
|
||||
s.mux.Handle("GET /metrics", metricsHandler)
|
||||
h := metricsHandler
|
||||
if metricsToken != "" {
|
||||
h = requireBearerToken(metricsToken, h)
|
||||
}
|
||||
s.mux.Handle("GET /metrics", h)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
@@ -52,3 +59,20 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// requireBearerToken wraps a handler to require a valid Bearer token.
|
||||
func requireBearerToken(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
provided := auth[len("Bearer "):]
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
store := storage.NewMemoryStore()
|
||||
logger := slog.Default()
|
||||
srv, err := NewServer(store, logger, nil)
|
||||
srv, err := NewServer(store, logger, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("creating server: %v", err)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func newSeededTestServer(t *testing.T) *Server {
|
||||
}
|
||||
|
||||
logger := slog.Default()
|
||||
srv, err := NewServer(store, logger, nil)
|
||||
srv, err := NewServer(store, logger, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("creating server: %v", err)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestSessionDetailHandler(t *testing.T) {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
srv, err := NewServer(store, slog.Default(), nil)
|
||||
srv, err := NewServer(store, slog.Default(), nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestAPISessionEvents(t *testing.T) {
|
||||
t.Fatalf("AppendSessionEvents: %v", err)
|
||||
}
|
||||
|
||||
srv, err := NewServer(store, slog.Default(), nil)
|
||||
srv, err := NewServer(store, slog.Default(), nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func TestMetricsEndpoint(t *testing.T) {
|
||||
t.Run("enabled", func(t *testing.T) {
|
||||
m := metrics.New("test")
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler())
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func TestMetricsEndpoint(t *testing.T) {
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), nil)
|
||||
srv, err := NewServer(store, slog.Default(), nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
@@ -278,6 +278,68 @@ func TestMetricsEndpoint(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricsBearerToken(t *testing.T) {
|
||||
m := metrics.New("test")
|
||||
|
||||
t.Run("valid token", func(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler(), "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong token", func(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler(), "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
req.Header.Set("Authorization", "Bearer wrong")
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing header", func(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler(), "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no token configured", func(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
srv, err := NewServer(store, slog.Default(), m.Handler(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
srv.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStaticAssets(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user