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:
2026-02-15 15:54:29 +01:00
parent 9aecc7ce02
commit df860b3061
16 changed files with 301 additions and 23 deletions

View File

@@ -45,6 +45,7 @@ Key settings:
- `web.listen_addr` — web dashboard listen address (default `:8080`) - `web.listen_addr` — web dashboard listen address (default `:8080`)
- Session detail pages at `/sessions/{id}` include terminal replay via xterm.js - Session detail pages at `/sessions/{id}` include terminal replay via xterm.js
- `web.metrics_enabled` — expose Prometheus metrics at `/metrics` (default `true`) - `web.metrics_enabled` — expose Prometheus metrics at `/metrics` (default `true`)
- `web.metrics_token` — bearer token to protect `/metrics`; empty means no auth (default empty)
- `detection.enabled` — enable human detection scoring (default `false`) - `detection.enabled` — enable human detection scoring (default `false`)
- `detection.threshold` — score threshold (0.01.0) for flagging sessions (default `0.6`) - `detection.threshold` — score threshold (0.01.0) for flagging sessions (default `0.6`)
- `detection.update_interval` — how often to recompute scores (default `5s`) - `detection.update_interval` — how often to recompute scores (default `5s`)

View File

@@ -79,6 +79,7 @@ func run() error {
go storage.RunRetention(ctx, store, cfg.Storage.RetentionDays, cfg.Storage.RetentionIntervalDuration, logger) go storage.RunRetention(ctx, store, cfg.Storage.RetentionDays, cfg.Storage.RetentionIntervalDuration, logger)
m := metrics.New(Version) m := metrics.New(Version)
m.RegisterStoreCollector(store)
srv, err := server.New(*cfg, store, logger, m) srv, err := server.New(*cfg, store, logger, m)
if err != nil { if err != nil {
@@ -94,7 +95,7 @@ func run() error {
metricsHandler = m.Handler() metricsHandler = m.Handler()
} }
webHandler, err := web.NewServer(store, logger.With("component", "web"), metricsHandler) webHandler, err := web.NewServer(store, logger.With("component", "web"), metricsHandler, cfg.Web.MetricsToken)
if err != nil { if err != nil {
return fmt.Errorf("create web server: %w", err) return fmt.Errorf("create web server: %w", err)
} }

View File

@@ -24,6 +24,7 @@ type WebConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
ListenAddr string `toml:"listen_addr"` ListenAddr string `toml:"listen_addr"`
MetricsEnabled *bool `toml:"metrics_enabled"` MetricsEnabled *bool `toml:"metrics_enabled"`
MetricsToken string `toml:"metrics_token"`
} }
type ShellConfig struct { type ShellConfig struct {

View File

@@ -282,6 +282,22 @@ password = "toor"
} }
} }
func TestLoadMetricsToken(t *testing.T) {
content := `
[web]
enabled = true
metrics_token = "my-secret-token"
`
path := writeTemp(t, content)
cfg, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Web.MetricsToken != "my-secret-token" {
t.Errorf("metrics_token = %q, want %q", cfg.Web.MetricsToken, "my-secret-token")
}
}
func TestLoadMissingFile(t *testing.T) { func TestLoadMissingFile(t *testing.T) {
_, err := Load("/nonexistent/path/config.toml") _, err := Load("/nonexistent/path/config.toml")
if err == nil { if err == nil {

View File

@@ -1,8 +1,10 @@
package metrics package metrics
import ( import (
"context"
"net/http" "net/http"
"git.t-juice.club/torjus/oubliette/internal/storage"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
@@ -15,6 +17,9 @@ type Metrics struct {
SSHConnectionsTotal *prometheus.CounterVec SSHConnectionsTotal *prometheus.CounterVec
SSHConnectionsActive prometheus.Gauge SSHConnectionsActive prometheus.Gauge
AuthAttemptsTotal *prometheus.CounterVec AuthAttemptsTotal *prometheus.CounterVec
AuthAttemptsByCountry *prometheus.CounterVec
CommandsExecuted *prometheus.CounterVec
HumanScore prometheus.Histogram
SessionsTotal *prometheus.CounterVec SessionsTotal *prometheus.CounterVec
SessionsActive prometheus.Gauge SessionsActive prometheus.Gauge
SessionDuration prometheus.Histogram SessionDuration prometheus.Histogram
@@ -39,6 +44,19 @@ func New(version string) *Metrics {
Name: "oubliette_auth_attempts_total", Name: "oubliette_auth_attempts_total",
Help: "Total authentication attempts.", Help: "Total authentication attempts.",
}, []string{"result", "reason"}), }, []string{"result", "reason"}),
AuthAttemptsByCountry: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_auth_attempts_by_country_total",
Help: "Total authentication attempts by country.",
}, []string{"country"}),
CommandsExecuted: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_commands_executed_total",
Help: "Total commands executed in shells.",
}, []string{"shell"}),
HumanScore: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "oubliette_human_score",
Help: "Distribution of final human detection scores.",
Buckets: prometheus.LinearBuckets(0, 0.1, 11), // 0.0, 0.1, ..., 1.0
}),
SessionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ SessionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_sessions_total", Name: "oubliette_sessions_total",
Help: "Total sessions created.", Help: "Total sessions created.",
@@ -64,6 +82,9 @@ func New(version string) *Metrics {
m.SSHConnectionsTotal, m.SSHConnectionsTotal,
m.SSHConnectionsActive, m.SSHConnectionsActive,
m.AuthAttemptsTotal, m.AuthAttemptsTotal,
m.AuthAttemptsByCountry,
m.CommandsExecuted,
m.HumanScore,
m.SessionsTotal, m.SessionsTotal,
m.SessionsActive, m.SessionsActive,
m.SessionDuration, m.SessionDuration,
@@ -80,14 +101,59 @@ func New(version string) *Metrics {
m.AuthAttemptsTotal.WithLabelValues("accepted", reason) m.AuthAttemptsTotal.WithLabelValues("accepted", reason)
m.AuthAttemptsTotal.WithLabelValues("rejected", reason) m.AuthAttemptsTotal.WithLabelValues("rejected", reason)
} }
for _, shell := range []string{"bash", "fridge", "banking", "adventure"} { for _, sh := range []string{"bash", "fridge", "banking", "adventure", "cisco"} {
m.SessionsTotal.WithLabelValues(shell) m.SessionsTotal.WithLabelValues(sh)
m.CommandsExecuted.WithLabelValues(sh)
} }
return m return m
} }
// RegisterStoreCollector registers a collector that queries storage stats on each scrape.
func (m *Metrics) RegisterStoreCollector(store storage.Store) {
m.registry.MustRegister(&storeCollector{store: store})
}
// Handler returns an http.Handler that serves Prometheus metrics. // Handler returns an http.Handler that serves Prometheus metrics.
func (m *Metrics) Handler() http.Handler { func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{}) return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
} }
// storeCollector implements prometheus.Collector, querying storage on each scrape.
type storeCollector struct {
store storage.Store
}
var (
storageLoginAttemptsDesc = prometheus.NewDesc(
"oubliette_storage_login_attempts_total",
"Total login attempts in storage.",
nil, nil,
)
storageUniqueIPsDesc = prometheus.NewDesc(
"oubliette_storage_unique_ips",
"Unique IPs in storage.",
nil, nil,
)
storageSessionsDesc = prometheus.NewDesc(
"oubliette_storage_sessions_total",
"Total sessions in storage.",
nil, nil,
)
)
func (c *storeCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- storageLoginAttemptsDesc
ch <- storageUniqueIPsDesc
ch <- storageSessionsDesc
}
func (c *storeCollector) Collect(ch chan<- prometheus.Metric) {
stats, err := c.store.GetDashboardStats(context.Background())
if err != nil {
return
}
ch <- prometheus.MustNewConstMetric(storageLoginAttemptsDesc, prometheus.GaugeValue, float64(stats.TotalAttempts))
ch <- prometheus.MustNewConstMetric(storageUniqueIPsDesc, prometheus.GaugeValue, float64(stats.UniqueIPs))
ch <- prometheus.MustNewConstMetric(storageSessionsDesc, prometheus.GaugeValue, float64(stats.TotalSessions))
}

View File

@@ -1,11 +1,14 @@
package metrics package metrics
import ( import (
"context"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"git.t-juice.club/torjus/oubliette/internal/storage"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@@ -21,6 +24,8 @@ func TestNew(t *testing.T) {
"oubliette_ssh_connections_total": false, "oubliette_ssh_connections_total": false,
"oubliette_ssh_connections_active": false, "oubliette_ssh_connections_active": false,
"oubliette_auth_attempts_total": false, "oubliette_auth_attempts_total": false,
"oubliette_commands_executed_total": false,
"oubliette_human_score": false,
"oubliette_sessions_total": false, "oubliette_sessions_total": false,
"oubliette_sessions_active": false, "oubliette_sessions_active": false,
"oubliette_session_duration_seconds": false, "oubliette_session_duration_seconds": false,
@@ -40,6 +45,31 @@ func TestNew(t *testing.T) {
} }
} }
func TestAuthAttemptsByCountry(t *testing.T) {
m := New("1.0.0")
m.AuthAttemptsByCountry.WithLabelValues("US").Inc()
m.AuthAttemptsByCountry.WithLabelValues("DE").Inc()
m.AuthAttemptsByCountry.WithLabelValues("US").Inc()
families, err := m.registry.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
var found bool
for _, f := range families {
if f.GetName() == "oubliette_auth_attempts_by_country_total" {
found = true
if len(f.GetMetric()) != 2 {
t.Errorf("expected 2 label pairs (US, DE), got %d", len(f.GetMetric()))
}
}
}
if !found {
t.Error("oubliette_auth_attempts_by_country_total not found after incrementing")
}
}
func TestHandler(t *testing.T) { func TestHandler(t *testing.T) {
m := New("1.2.3") m := New("1.2.3")
@@ -60,3 +90,53 @@ func TestHandler(t *testing.T) {
t.Errorf("response should contain build_info metric, got:\n%s", body) t.Errorf("response should contain build_info metric, got:\n%s", body)
} }
} }
func TestStoreCollector(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
// Seed some data.
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1", ""); err != nil {
t.Fatalf("RecordLoginAttempt: %v", err)
}
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2", ""); err != nil {
t.Fatalf("RecordLoginAttempt: %v", err)
}
if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", ""); err != nil {
t.Fatalf("CreateSession: %v", err)
}
m := New("test")
m.RegisterStoreCollector(store)
families, err := m.registry.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
wantMetrics := map[string]float64{
"oubliette_storage_login_attempts_total": 2,
"oubliette_storage_unique_ips": 2,
"oubliette_storage_sessions_total": 1,
}
for _, f := range families {
expected, ok := wantMetrics[f.GetName()]
if !ok {
continue
}
if len(f.GetMetric()) == 0 {
t.Errorf("metric %q has no samples", f.GetName())
continue
}
got := f.GetMetric()[0].GetGauge().GetValue()
if got != expected {
t.Errorf("metric %q = %f, want %f", f.GetName(), got, expected)
}
delete(wantMetrics, f.GetName())
}
for name := range wantMetrics {
t.Errorf("metric %q not found in gather output", name)
}
}

View File

@@ -264,6 +264,9 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
Banner: s.cfg.Shell.Banner, Banner: s.cfg.Shell.Banner,
FakeUser: s.cfg.Shell.FakeUser, FakeUser: s.cfg.Shell.FakeUser,
}, },
OnCommand: func(sh string) {
s.metrics.CommandsExecuted.WithLabelValues(sh).Inc()
},
} }
// Wrap channel in RecordingChannel. // Wrap channel in RecordingChannel.
@@ -299,6 +302,7 @@ func (s *Server) handleSession(channel ssh.Channel, requests <-chan *ssh.Request
} }
if scorer != nil { if scorer != nil {
finalScore := scorer.Score() finalScore := scorer.Score()
s.metrics.HumanScore.Observe(finalScore)
if err := s.store.UpdateHumanScore(context.Background(), sessionID, finalScore); err != nil { if err := s.store.UpdateHumanScore(context.Background(), sessionID, finalScore); err != nil {
s.logger.Error("failed to write final human score", "err", err, "session_id", sessionID) s.logger.Error("failed to write final human score", "err", err, "session_id", sessionID)
} }
@@ -362,6 +366,9 @@ func (s *Server) passwordCallback(conn ssh.ConnMetadata, password []byte) (*ssh.
) )
country := s.geoip.Lookup(ip) country := s.geoip.Lookup(ip)
if country != "" {
s.metrics.AuthAttemptsByCountry.WithLabelValues(country).Inc()
}
if err := s.store.RecordLoginAttempt(context.Background(), conn.User(), string(password), ip, country); err != nil { if err := s.store.RecordLoginAttempt(context.Background(), conn.User(), string(password), ip, country); err != nil {
s.logger.Error("failed to record login attempt", "err", err) s.logger.Error("failed to record login attempt", "err", err)
} }

View File

@@ -75,6 +75,9 @@ func (a *AdventureShell) Handle(ctx context.Context, sess *shell.SessionContext,
return fmt.Errorf("append session log: %w", err) return fmt.Errorf("append session log: %w", err)
} }
} }
if sess.OnCommand != nil {
sess.OnCommand("adventure")
}
if result.exit { if result.exit {
return nil return nil

View File

@@ -345,6 +345,9 @@ func logAction(sess *shell.SessionContext, input, output string) tea.Cmd {
defer cancel() defer cancel()
_ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output) _ = sess.Store.AppendSessionLog(ctx, sess.SessionID, input, output)
} }
if sess.OnCommand != nil {
sess.OnCommand("banking")
}
return nil return nil
} }
} }

View File

@@ -86,6 +86,9 @@ func (b *BashShell) Handle(ctx context.Context, sess *shell.SessionContext, rw i
return fmt.Errorf("append session log: %w", err) return fmt.Errorf("append session log: %w", err)
} }
} }
if sess.OnCommand != nil {
sess.OnCommand("bash")
}
if result.exit { if result.exit {
return nil return nil

View File

@@ -74,6 +74,9 @@ func (c *CiscoShell) Handle(ctx context.Context, sess *shell.SessionContext, rw
return fmt.Errorf("append session log: %w", err) return fmt.Errorf("append session log: %w", err)
} }
} }
if sess.OnCommand != nil {
sess.OnCommand("cisco")
}
continue continue
} }
@@ -92,6 +95,9 @@ func (c *CiscoShell) Handle(ctx context.Context, sess *shell.SessionContext, rw
return fmt.Errorf("append session log: %w", err) return fmt.Errorf("append session log: %w", err)
} }
} }
if sess.OnCommand != nil {
sess.OnCommand("cisco")
}
if result.exit { if result.exit {
return nil return nil

View File

@@ -69,6 +69,9 @@ func (f *FridgeShell) Handle(ctx context.Context, sess *shell.SessionContext, rw
return fmt.Errorf("append session log: %w", err) return fmt.Errorf("append session log: %w", err)
} }
} }
if sess.OnCommand != nil {
sess.OnCommand("fridge")
}
if result.exit { if result.exit {
return nil return nil

View File

@@ -24,6 +24,7 @@ type SessionContext struct {
Store storage.Store Store storage.Store
ShellConfig map[string]any ShellConfig map[string]any
CommonConfig ShellCommonConfig CommonConfig ShellCommonConfig
OnCommand func(shell string) // called when a command is executed; may be nil
} }
// ShellCommonConfig holds settings shared across all shell types. // ShellCommonConfig holds settings shared across all shell types.

View File

@@ -1,9 +1,11 @@
package web package web
import ( import (
"crypto/subtle"
"embed" "embed"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"git.t-juice.club/torjus/oubliette/internal/storage" "git.t-juice.club/torjus/oubliette/internal/storage"
) )
@@ -21,7 +23,8 @@ type Server struct {
// NewServer creates a new web Server with routes registered. // NewServer creates a new web Server with routes registered.
// If metricsHandler is non-nil, a /metrics endpoint is 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() tmpl, err := loadTemplates()
if err != nil { if err != nil {
return nil, err 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) s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
if metricsHandler != nil { 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 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) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r) 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)
})
}

View File

@@ -18,7 +18,7 @@ func newTestServer(t *testing.T) *Server {
t.Helper() t.Helper()
store := storage.NewMemoryStore() store := storage.NewMemoryStore()
logger := slog.Default() logger := slog.Default()
srv, err := NewServer(store, logger, nil) srv, err := NewServer(store, logger, nil, "")
if err != nil { if err != nil {
t.Fatalf("creating server: %v", err) t.Fatalf("creating server: %v", err)
} }
@@ -47,7 +47,7 @@ func newSeededTestServer(t *testing.T) *Server {
} }
logger := slog.Default() logger := slog.Default()
srv, err := NewServer(store, logger, nil) srv, err := NewServer(store, logger, nil, "")
if err != nil { if err != nil {
t.Fatalf("creating server: %v", err) t.Fatalf("creating server: %v", err)
} }
@@ -155,7 +155,7 @@ func TestSessionDetailHandler(t *testing.T) {
t.Fatalf("CreateSession: %v", err) t.Fatalf("CreateSession: %v", err)
} }
srv, err := NewServer(store, slog.Default(), nil) srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }
@@ -195,7 +195,7 @@ func TestAPISessionEvents(t *testing.T) {
t.Fatalf("AppendSessionEvents: %v", err) t.Fatalf("AppendSessionEvents: %v", err)
} }
srv, err := NewServer(store, slog.Default(), nil) srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }
@@ -241,7 +241,7 @@ func TestMetricsEndpoint(t *testing.T) {
t.Run("enabled", func(t *testing.T) { t.Run("enabled", func(t *testing.T) {
m := metrics.New("test") m := metrics.New("test")
store := storage.NewMemoryStore() store := storage.NewMemoryStore()
srv, err := NewServer(store, slog.Default(), m.Handler()) srv, err := NewServer(store, slog.Default(), m.Handler(), "")
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) t.Fatalf("NewServer: %v", err)
} }
@@ -261,7 +261,7 @@ func TestMetricsEndpoint(t *testing.T) {
t.Run("disabled", func(t *testing.T) { t.Run("disabled", func(t *testing.T) {
store := storage.NewMemoryStore() store := storage.NewMemoryStore()
srv, err := NewServer(store, slog.Default(), nil) srv, err := NewServer(store, slog.Default(), nil, "")
if err != nil { if err != nil {
t.Fatalf("NewServer: %v", err) 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) { func TestStaticAssets(t *testing.T) {
srv := newTestServer(t) srv := newTestServer(t)

View File

@@ -43,6 +43,7 @@ retention_interval = "1h"
# enabled = true # enabled = true
# listen_addr = ":8080" # listen_addr = ":8080"
# metrics_enabled = true # metrics_enabled = true
# metrics_token = "" # bearer token for /metrics; empty = no auth
[shell] [shell]
hostname = "ubuntu-server" hostname = "ubuntu-server"