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.
164 lines
5.3 KiB
Go
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),
|
|
)
|
|
})
|
|
}
|