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.
This commit is contained in:
1 parent
9a85e9acc8
commit
b792ad256e
11 files changed
+2028
No files matched your search
+163
@@ -0,0 +1,163 @@
|
||||
// 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),
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user