forgejo-runner-optimiser/internal/receiver/handler.go
Martin McCaffery d0dd209bc9
All checks were successful
ci / build (push) Successful in 28s
Add token-based authentication for receiver
2026-02-11 15:18:03 +01:00

196 lines
6.5 KiB
Go

// ABOUTME: HTTP handlers for the metrics receiver service.
// ABOUTME: Provides endpoints for receiving and querying metrics.
package receiver
import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"strings"
)
// Handler handles HTTP requests for the metrics receiver
type Handler struct {
store *Store
logger *slog.Logger
readToken string // Pre-shared token for read endpoint authentication
hmacKey string // Separate key for HMAC-based push token generation/validation
}
// NewHandler creates a new HTTP handler with the given store.
// readToken authenticates read endpoints and the token generation endpoint.
// hmacKey is the secret used to derive scoped push tokens.
func NewHandler(store *Store, logger *slog.Logger, readToken, hmacKey string) *Handler {
return &Handler{store: store, logger: logger, readToken: readToken, hmacKey: hmacKey}
}
// RegisterRoutes registers all HTTP routes on the given mux
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/metrics", h.handleReceiveMetrics)
mux.HandleFunc("POST /api/v1/token", h.handleGenerateToken)
mux.HandleFunc("GET /api/v1/metrics/repo/{org}/{repo}/{workflow}/{job}", h.handleGetByWorkflowJob)
mux.HandleFunc("GET /health", h.handleHealth)
}
// validateReadToken checks the Authorization header for a valid Bearer token.
func (h *Handler) validateReadToken(w http.ResponseWriter, r *http.Request) bool {
if h.readToken == "" {
h.logger.Warn("no read-token configured, rejecting request", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
h.logger.Warn("missing authorization header", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
h.logger.Warn("invalid authorization format", slog.String("path", r.URL.Path))
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return false
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if subtle.ConstantTimeCompare([]byte(token), []byte(h.readToken)) != 1 {
h.logger.Warn("invalid token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return false
}
return true
}
func (h *Handler) handleGenerateToken(w http.ResponseWriter, r *http.Request) {
if h.hmacKey == "" {
http.Error(w, "token generation requires a configured HMAC key", http.StatusBadRequest)
return
}
if !h.validateReadToken(w, r) {
return
}
var req TokenRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON body", http.StatusBadRequest)
return
}
if req.Organization == "" || req.Repository == "" || req.Workflow == "" || req.Job == "" {
http.Error(w, "organization, repository, workflow, and job are required", http.StatusBadRequest)
return
}
token := GenerateScopedToken(h.hmacKey, req.Organization, req.Repository, req.Workflow, req.Job)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TokenResponse{Token: token})
}
// validatePushToken checks push authentication via scoped HMAC token.
func (h *Handler) validatePushToken(w http.ResponseWriter, r *http.Request, exec ExecutionContext) bool {
if h.hmacKey == "" {
h.logger.Warn("no HMAC key configured, rejecting push", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
h.logger.Warn("missing push authorization", slog.String("path", r.URL.Path))
http.Error(w, "authorization required", http.StatusUnauthorized)
return false
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(authHeader, bearerPrefix) {
h.logger.Warn("invalid push authorization format", slog.String("path", r.URL.Path))
http.Error(w, "invalid authorization format", http.StatusUnauthorized)
return false
}
token := strings.TrimPrefix(authHeader, bearerPrefix)
if !ValidateScopedToken(h.hmacKey, token, exec.Organization, exec.Repository, exec.Workflow, exec.Job) {
h.logger.Warn("invalid push token", slog.String("path", r.URL.Path))
http.Error(w, "invalid token", http.StatusUnauthorized)
return false
}
return true
}
func (h *Handler) handleReceiveMetrics(w http.ResponseWriter, r *http.Request) {
var payload MetricsPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
h.logger.Error("failed to decode payload", slog.String("error", err.Error()))
http.Error(w, "invalid JSON payload", http.StatusBadRequest)
return
}
if payload.Execution.RunID == "" {
http.Error(w, "run_id is required", http.StatusBadRequest)
return
}
if !h.validatePushToken(w, r, payload.Execution) {
return
}
id, err := h.store.SaveMetric(&payload)
if err != nil {
h.logger.Error("failed to save metric", slog.String("error", err.Error()))
http.Error(w, "failed to save metric", http.StatusInternalServerError)
return
}
h.logger.Info("metric saved",
slog.Uint64("id", uint64(id)),
slog.String("run_id", payload.Execution.RunID),
slog.String("repository", payload.Execution.Repository),
)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"id": id, "status": "created"})
}
func (h *Handler) handleGetByWorkflowJob(w http.ResponseWriter, r *http.Request) {
if !h.validateReadToken(w, r) {
return
}
org := r.PathValue("org")
repo := r.PathValue("repo")
workflow := r.PathValue("workflow")
job := r.PathValue("job")
if org == "" || repo == "" || workflow == "" || job == "" {
http.Error(w, "org, repo, workflow and job are required", http.StatusBadRequest)
return
}
metrics, err := h.store.GetMetricsByWorkflowJob(org, repo, workflow, job)
if err != nil {
h.logger.Error("failed to get metrics", slog.String("error", err.Error()))
http.Error(w, "failed to get metrics", http.StatusInternalServerError)
return
}
// Convert to response type with Payload as JSON object
response := make([]MetricResponse, len(metrics))
for i, m := range metrics {
response[i] = m.ToResponse()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}