feat: add Prometheus metrics endpoint and Docker image (PLAN.md 4.2)

Add internal/metrics package with dedicated Prometheus registry exposing
SSH connection, auth attempt, session, and build info metrics. Wire into
SSH server (4 instrumentation points) and web server (/metrics endpoint).
Add dockerImage output to flake.nix via dockerTools.buildLayeredImage.
Bump version to 0.7.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 05:47:16 +01:00
parent b8fcbc7e10
commit ab07e6a8dc
14 changed files with 342 additions and 19 deletions

View File

@@ -0,0 +1,93 @@
package metrics
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// Metrics holds all Prometheus collectors for the honeypot.
type Metrics struct {
registry *prometheus.Registry
SSHConnectionsTotal *prometheus.CounterVec
SSHConnectionsActive prometheus.Gauge
AuthAttemptsTotal *prometheus.CounterVec
SessionsTotal *prometheus.CounterVec
SessionsActive prometheus.Gauge
SessionDuration prometheus.Histogram
BuildInfo *prometheus.GaugeVec
}
// New creates a new Metrics instance with all collectors registered.
func New(version string) *Metrics {
reg := prometheus.NewRegistry()
m := &Metrics{
registry: reg,
SSHConnectionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_ssh_connections_total",
Help: "Total SSH connections received.",
}, []string{"outcome"}),
SSHConnectionsActive: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "oubliette_ssh_connections_active",
Help: "Current active SSH connections.",
}),
AuthAttemptsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_auth_attempts_total",
Help: "Total authentication attempts.",
}, []string{"result", "reason"}),
SessionsTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "oubliette_sessions_total",
Help: "Total sessions created.",
}, []string{"shell"}),
SessionsActive: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "oubliette_sessions_active",
Help: "Current active sessions.",
}),
SessionDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "oubliette_session_duration_seconds",
Help: "Session duration in seconds.",
Buckets: []float64{1, 5, 10, 30, 60, 120, 300, 600, 1800, 3600},
}),
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Name: "oubliette_build_info",
Help: "Build information. Always 1.",
}, []string{"version"}),
}
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
m.SSHConnectionsTotal,
m.SSHConnectionsActive,
m.AuthAttemptsTotal,
m.SessionsTotal,
m.SessionsActive,
m.SessionDuration,
m.BuildInfo,
)
m.BuildInfo.WithLabelValues(version).Set(1)
// Initialize label combinations so they appear in Gather/output.
for _, outcome := range []string{"accepted", "rejected_handshake", "rejected_max_connections"} {
m.SSHConnectionsTotal.WithLabelValues(outcome)
}
for _, reason := range []string{"static_credential", "remembered_credential", "threshold_reached", "rejected"} {
m.AuthAttemptsTotal.WithLabelValues("accepted", reason)
m.AuthAttemptsTotal.WithLabelValues("rejected", reason)
}
for _, shell := range []string{"bash", "fridge", "banking", "adventure"} {
m.SessionsTotal.WithLabelValues(shell)
}
return m
}
// Handler returns an http.Handler that serves Prometheus metrics.
func (m *Metrics) Handler() http.Handler {
return promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{})
}

View File

@@ -0,0 +1,62 @@
package metrics
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNew(t *testing.T) {
m := New("1.2.3")
// Gather all metrics and check expected names exist.
families, err := m.registry.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
want := map[string]bool{
"oubliette_ssh_connections_total": false,
"oubliette_ssh_connections_active": false,
"oubliette_auth_attempts_total": false,
"oubliette_sessions_total": false,
"oubliette_sessions_active": false,
"oubliette_session_duration_seconds": false,
"oubliette_build_info": false,
}
for _, f := range families {
if _, ok := want[f.GetName()]; ok {
want[f.GetName()] = true
}
}
for name, found := range want {
if !found {
t.Errorf("metric %q not registered", name)
}
}
}
func TestHandler(t *testing.T) {
m := New("1.2.3")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
w := httptest.NewRecorder()
m.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body, err := io.ReadAll(w.Body)
if err != nil {
t.Fatalf("reading body: %v", err)
}
if !strings.Contains(string(body), `oubliette_build_info{version="1.2.3"} 1`) {
t.Errorf("response should contain build_info metric, got:\n%s", body)
}
}