Files
bryanpedini b792ad256e feat(api): add authenticated containers http api
GET /api/v1/containers behind bearer token auth, JSON errors for 401/404/405/500, one audit row per authenticated request, graceful shutdown; unit and e2e tests for auth, routing, auditing, request logging and server lifecycle.
2026-10-06 10:52:25 +02:00

164 lines
5.3 KiB
Go

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"net"
"net/http"
"strconv"
"time"
)
// AuditRecorder is the audit-store dependency of the HTTP layer
type AuditRecorder interface {
RecordOperation(ctx context.Context, token, origin, operation string) error
}
// Fixed JSON error bodies
const (
bodyUnauthorized = `{"error":"unauthorized"}`
bodyNotFound = `{"error":"not found"}`
bodyMethodNotAllowed = `{"error":"method not allowed"}`
bodyInternalError = `{"error":"internal server error"}`
)
// writeBody writes a complete JSON response with an explicit Content-Length
func writeBody(w http.ResponseWriter, status int, body []byte) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(status)
w.Write(body)
}
// writeError writes one of the fixed JSON error bodies
func writeError(w http.ResponseWriter, status int, body string) {
writeBody(w, status, []byte(body))
}
// clientIP returns the IP part of the connection's remote address
// Forwarding headers (X-Forwarded-For, Forwarded, X-Real-IP) are deliberately ignored
func clientIP(remoteAddr string) string {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
return remoteAddr
}
return host
}
// handleContainers serves GET /api/v1/containers: authenticate, audit, list containers, respond
func (s *WebServer) handleContainers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
defer traceFunc(ctx, s.log, "WebServer.handleContainers")()
// Authenticate
tok, class, err := authenticate(ctx, s.log, s.tokens, r.Header)
if err != nil {
s.log.DebugContext(ctx, "authentication result", "result", class)
s.log.ErrorContext(ctx, "token lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, bodyInternalError)
return
}
if class != authOK {
s.log.DebugContext(ctx, "authentication result", "result", class)
w.Header().Set("WWW-Authenticate", "Bearer")
writeError(w, http.StatusUnauthorized, bodyUnauthorized)
return
}
id := tokenID(tok.Token)
s.log.DebugContext(ctx, "authentication result", "result", class, "token_id", id)
// Audit, before touching Docker so failed Docker calls are still recorded
origin := clientIP(r.RemoteAddr)
if err := s.audit.RecordOperation(ctx, tok.Token, origin, operationGetContainers); err != nil {
s.log.ErrorContext(ctx, "audit insert failed", "token_id", id, "error", err)
writeError(w, http.StatusInternalServerError, bodyInternalError)
return
}
s.log.DebugContext(ctx, "audit row inserted", "token_id", id, "origin", origin, "operation", operationGetContainers)
// Query Docker
s.log.DebugContext(ctx, "docker request started")
start := time.Now()
containers, err := s.docker.ListRunningContainers(ctx)
if err != nil {
s.log.DebugContext(ctx, "docker request finished", "ok", false, "duration", time.Since(start))
s.log.ErrorContext(ctx, "docker request failed", "error", err)
writeError(w, http.StatusInternalServerError, bodyInternalError)
return
}
s.log.DebugContext(ctx, "docker request finished", "ok", true, "duration", time.Since(start), "containers", len(containers))
// Serialize fully before writing anything
body, err := s.encode(containers)
if err != nil {
s.log.ErrorContext(ctx, "response serialization failed", "error", err)
writeError(w, http.StatusInternalServerError, bodyInternalError)
return
}
writeBody(w, http.StatusOK, body)
}
// handleNotFound serves every unknown path
func (s *WebServer) handleNotFound(w http.ResponseWriter, r *http.Request) {
defer traceFunc(r.Context(), s.log, "WebServer.handleNotFound")()
writeError(w, http.StatusNotFound, bodyNotFound)
}
// handleMethodNotAllowed serves known paths requested with an unsupported method
func (s *WebServer) handleMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
defer traceFunc(r.Context(), s.log, "WebServer.handleMethodNotAllowed")()
w.Header().Set("Allow", http.MethodGet)
writeError(w, http.StatusMethodNotAllowed, bodyMethodNotAllowed)
}
// statusRecorder captures the status code written by the wrapped handler
type statusRecorder struct {
http.ResponseWriter
status int
wroteHeader bool
}
// WriteHeader records the first status code
func (r *statusRecorder) WriteHeader(code int) {
if !r.wroteHeader {
r.status = code
r.wroteHeader = true
}
r.ResponseWriter.WriteHeader(code)
}
// Write records an implicit 200 when no status was set
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.status = http.StatusOK
r.wroteHeader = true
}
return r.ResponseWriter.Write(b)
}
// Unwrap exposes the underlying writer to http.ResponseController
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
// logRequests logs every request reaching the application at INFO
func (s *WebServer) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
defer traceFunc(ctx, s.log, "WebServer.logRequests")()
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.InfoContext(ctx, "this request has been made",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
"origin", clientIP(r.RemoteAddr),
)
})
}