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:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent 9a85e9acc8
commit b792ad256e
11 files changed
+2028

No files matched your search

+521
View File
@@ -0,0 +1,521 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/moby/moby/api/types/container"
)
// fakeAudit is an in-memory AuditRecorder
type fakeAudit struct {
mu sync.Mutex
rows []opRow
err error
}
func (f *fakeAudit) RecordOperation(ctx context.Context, token, origin, operation string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.err != nil {
return f.err
}
f.rows = append(f.rows, opRow{token: token, origin: origin, operation: operation})
return nil
}
func (f *fakeAudit) Rows() []opRow {
f.mu.Lock()
defer f.mu.Unlock()
return append([]opRow(nil), f.rows...)
}
// doRequest runs one request through the complete handler chain
func doRequest(s *WebServer, method, target string, header http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, nil)
req.RemoteAddr = "198.51.100.7:40000"
for k, vs := range header {
for _, v := range vs {
req.Header.Add(k, v)
}
}
rec := httptest.NewRecorder()
s.HTTPServer.Handler.ServeHTTP(rec, req)
return rec
}
// realStack wires the server with real SQLite stores and a fake Docker client
type realStack struct {
server *WebServer
tokens *TokenStore
audit *AuditStore
docker *fakeDocker
log *syncBuffer
good string
old string
}
func newRealStack(t *testing.T, level slog.Level) *realStack {
t.Helper()
log, buf := newTestLogger(level)
db := openTestDB(t)
clock := newFakeClock(t0, time.Millisecond)
tokens := NewTokenStore(db, log, clock.Now, GenerateToken)
audit := NewAuditStore(db, log, clock.Now)
good, err := tokens.CreateToken(context.Background(), "good")
if err != nil {
t.Fatal(err)
}
old, err := tokens.CreateToken(context.Background(), "old")
if err != nil {
t.Fatal(err)
}
if err := tokens.DeactivateToken(context.Background(), old.Token); err != nil {
t.Fatal(err)
}
docker := &fakeDocker{containers: []container.Summary{}}
return &realStack{
server: NewWebServer(log, tokens, audit, docker),
tokens: tokens, audit: audit, docker: docker, log: buf,
good: good.Token, old: old.Token,
}
}
func bearer(token string) http.Header {
return http.Header{"Authorization": {"Bearer " + token}}
}
func assertJSONResponse(t *testing.T, rec *httptest.ResponseRecorder, status int, body string) {
t.Helper()
if rec.Code != status {
t.Errorf("status = %d, want %d", rec.Code, status)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q", ct)
}
if got := rec.Body.String(); got != body {
t.Errorf("body = %q, want %q", got, body)
}
if cl := rec.Header().Get("Content-Length"); cl != itoa(len(body)) {
t.Errorf("Content-Length = %q, want %d", cl, len(body))
}
}
// nearMiss returns a different token differing only in its first character
func nearMiss(token string) string {
if token[0] == 'A' {
return "B" + token[1:]
}
return "A" + token[1:]
}
func itoa(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
func TestContainersNoContainers(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
assertJSONResponse(t, rec, 200, "[]")
rows := readOperations(t, st.audit)
if len(rows) != 1 || rows[0].token != st.good || rows[0].origin != "198.51.100.7" || rows[0].operation != "GET containers" {
t.Fatalf("audit rows = %+v", rows)
}
if _, err := time.Parse(time.RFC3339Nano, rows[0].time); err != nil || !strings.HasSuffix(rows[0].time, "Z") {
t.Fatalf("audit time %q", rows[0].time)
}
}
func TestContainersNilSliceFromLister(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
st.docker.containers = nil
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
// A nil slice from a lister still yields valid JSON (null); the real client never returns nil
if rec.Code != 200 || rec.Body.String() != "null" {
t.Fatalf("got %d %q", rec.Code, rec.Body.String())
}
}
func TestContainersOneAndMany(t *testing.T) {
t.Parallel()
for _, n := range []int{1, 250} {
st := newRealStack(t, slog.LevelInfo)
st.docker.containers = makeContainers(n)
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
want, _ := json.Marshal(st.docker.containers)
assertJSONResponse(t, rec, 200, string(want))
var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got) != n || got[0]["Id"] != st.docker.containers[0].ID || got[n-1]["State"] != "running" {
t.Fatalf("n=%d: decoded %d items", n, len(got))
}
}
}
func TestContainersQueryStringIgnored(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
rec := doRequest(st.server, "GET", "/api/v1/containers?all=1&x=%00", bearer(st.good))
assertJSONResponse(t, rec, 200, "[]")
}
func TestContainersDockerError(t *testing.T) {
t.Parallel()
st := newRealStack(t, LevelTrace)
st.docker.err = errors.New("Cannot connect to the Docker daemon at unix:///var/run/docker.sock")
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
if strings.Contains(rec.Body.String(), "docker") {
t.Fatal("internal detail leaked")
}
// The request was audited even though Docker failed
if rows := readOperations(t, st.audit); len(rows) != 1 {
t.Fatalf("audit rows = %d", len(rows))
}
if !strings.Contains(st.log.String(), `level=ERROR msg="docker request failed" error="Cannot connect`) {
t.Fatalf("missing error log:\n%s", st.log.String())
}
}
func TestContainersAuditError(t *testing.T) {
t.Parallel()
log, buf := newTestLogger(slog.LevelInfo)
docker := &fakeDocker{}
s := NewWebServer(log, newFakeLookup(), &fakeAudit{err: errors.New("SQL logic error: no such table")}, docker)
rec := doRequest(s, "GET", "/api/v1/containers", bearer("good"))
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
if docker.Calls() != 0 {
t.Fatal("Docker queried although the audit failed")
}
if !strings.Contains(buf.String(), `level=ERROR msg="audit insert failed" token_id=`+tokenID("good")) {
t.Fatalf("missing error log:\n%s", buf.String())
}
}
func TestContainersAuditForeignKeyFailure(t *testing.T) {
t.Parallel()
// The lookup says the token exists but the real store has no such row: the FK rejects the audit row
st := newRealStack(t, slog.LevelInfo)
s := NewWebServer(traceDiscard(), &fakeLookup{tokens: map[string]Token{"ghost": {Token: "ghost", Active: true}}}, st.audit, st.docker)
rec := doRequest(s, "GET", "/api/v1/containers", bearer("ghost"))
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
if st.docker.Calls() != 0 {
t.Fatal("Docker queried although the audit failed")
}
}
func TestContainersLookupError(t *testing.T) {
t.Parallel()
audit := &fakeAudit{}
s := NewWebServer(traceDiscard(), &fakeLookup{err: errors.New("database is locked")}, audit, &fakeDocker{})
rec := doRequest(s, "GET", "/api/v1/containers", bearer("good"))
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
if len(audit.Rows()) != 0 {
t.Fatal("audited without authentication")
}
}
func TestContainersSerializationFailure(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
st.docker.containers = makeContainers(2)
st.server.encode = func(any) ([]byte, error) { return nil, errors.New("encoder exploded") }
rec := doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
assertJSONResponse(t, rec, 500, `{"error":"internal server error"}`)
if !strings.Contains(st.log.String(), `msg="response serialization failed" error="encoder exploded"`) {
t.Fatalf("missing log:\n%s", st.log.String())
}
}
func TestContainersUnauthorized(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelDebug)
cases := []struct {
name string
header http.Header
class string
}{
{"missing", nil, authMissingHeader},
{"empty", http.Header{"Authorization": {""}}, authEmptyHeader},
{"wrong scheme", http.Header{"Authorization": {"Basic " + st.good}}, authBadScheme},
{"empty bearer", http.Header{"Authorization": {"Bearer "}}, authEmptyToken},
{"malformed bearer", http.Header{"Authorization": {"Bearer " + st.good}}, authUnknownToken},
{"duplicated", http.Header{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}}, authMultipleHeaders},
{"unknown", bearer(nearMiss(st.good)), authUnknownToken},
{"case changed", bearer(strings.ToUpper(st.good)), authUnknownToken},
{"inactive", bearer(st.old), authInactiveToken},
{"sql injection", bearer("' OR '1'='1"), authUnknownToken},
}
for _, c := range cases {
rec := doRequest(st.server, "GET", "/api/v1/containers", c.header)
assertJSONResponse(t, rec, 401, `{"error":"unauthorized"}`)
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
t.Errorf("%s: WWW-Authenticate = %q", c.name, rec.Header().Get("WWW-Authenticate"))
}
if !strings.Contains(st.log.String(), `msg="authentication result" result=`+c.class) {
t.Errorf("%s: missing class %s in log", c.name, c.class)
}
}
// No audit row and no Docker call for any failed authentication
if rows := readOperations(t, st.audit); len(rows) != 0 {
t.Fatalf("audit rows after failed auth: %+v", rows)
}
if st.docker.Calls() != 0 {
t.Fatal("Docker queried without authentication")
}
// Then a valid one creates exactly one row
assertJSONResponse(t, doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good)), 200, "[]")
if rows := readOperations(t, st.audit); len(rows) != 1 {
t.Fatalf("audit rows = %d", len(rows))
}
}
func TestContainersAuditBeforeDocker(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
var during int
st.docker.onList = func(ctx context.Context) {
during = len(readOperations(t, st.audit))
}
st.docker.err = errors.New("boom")
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
if during != 1 {
t.Fatalf("audit rows visible during Docker call = %d, want 1", during)
}
}
func TestMethodNotAllowed(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT", "FOO", "get"} {
rec := doRequest(st.server, m, "/api/v1/containers", bearer(st.good))
assertJSONResponse(t, rec, 405, `{"error":"method not allowed"}`)
if rec.Header().Get("Allow") != "GET" {
t.Errorf("%s: Allow = %q", m, rec.Header().Get("Allow"))
}
}
if rows := readOperations(t, st.audit); len(rows) != 0 {
t.Fatalf("audit rows = %d", len(rows))
}
if st.docker.Calls() != 0 {
t.Fatal("Docker queried")
}
}
func TestNotFound(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
for _, path := range []string{
"/", "/api", "/api/v1", "/api/v1/", "/api/v1/containers/", "/api/v1/containers/x",
"/API/V1/CONTAINERS", "/api/v1/Containers", "//api/v1/containers", "/api//v1/containers",
"/api/v1/./containers", "/api/v1/../v1/containers", "/api/v2/containers", "/api/v1/containers.json",
"/api/v1/containers%2F", "/api/v1%2Fcontainers", "/api%2fv1%2fcontainers", "/version", "/favicon.ico",
} {
for _, m := range []string{"GET", "POST"} {
rec := doRequest(st.server, m, path, bearer(st.good))
assertJSONResponse(t, rec, 404, `{"error":"not found"}`)
if rec.Header().Get("Location") != "" {
t.Errorf("%s %s redirected to %q", m, path, rec.Header().Get("Location"))
}
}
}
if rows := readOperations(t, st.audit); len(rows) != 0 {
t.Fatalf("audit rows = %d", len(rows))
}
}
func TestEncodedPathMatches(t *testing.T) {
t.Parallel()
// Percent-encoded unreserved characters decode to the same path
st := newRealStack(t, slog.LevelInfo)
assertJSONResponse(t, doRequest(st.server, "GET", "/api/v1/%63ontainers", bearer(st.good)), 200, "[]")
}
func TestClientIP(t *testing.T) {
t.Parallel()
for in, want := range map[string]string{
"203.0.113.5:1234": "203.0.113.5",
"[2001:db8::1]:443": "2001:db8::1",
"[fe80::1%eth0]:8080": "fe80::1%eth0",
"127.0.0.1:0": "127.0.0.1",
"garbage": "garbage",
"": "",
"@": "@",
} {
if got := clientIP(in); got != want {
t.Errorf("clientIP(%q) = %q, want %q", in, got, want)
}
}
}
func TestOriginIgnoresForwardingHeaders(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
h := bearer(st.good)
h.Set("X-Forwarded-For", "1.2.3.4")
h.Set("Forwarded", "for=5.6.7.8")
h.Set("X-Real-IP", "9.9.9.9")
h.Set("X-Client-IP", "8.8.8.8")
h.Set("True-Client-IP", "7.7.7.7")
h.Set("CF-Connecting-IP", "6.6.6.6")
doRequest(st.server, "GET", "/api/v1/containers", h)
rows := readOperations(t, st.audit)
if len(rows) != 1 || rows[0].origin != "198.51.100.7" {
t.Fatalf("origin = %+v", rows)
}
if strings.Contains(st.log.String(), "1.2.3.4") {
t.Fatal("forwarded address logged as origin")
}
}
func TestRequestLogLine(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
doRequest(st.server, "GET", "/api/v1/containers", nil)
doRequest(st.server, "POST", "/api/v1/containers", nil)
doRequest(st.server, "GET", "/missing%0Aline", nil)
st.docker.err = errors.New("x")
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
out := st.log.String()
for _, want := range []string{
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=200 duration=`,
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=401 duration=`,
`level=INFO msg="this request has been made" method=POST path=/api/v1/containers status=405 duration=`,
`level=INFO msg="this request has been made" method=GET path="/missing\nline" status=404 duration=`,
`level=INFO msg="this request has been made" method=GET path=/api/v1/containers status=500 duration=`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
if c := strings.Count(out, "this request has been made"); c != 5 {
t.Fatalf("request lines = %d", c)
}
if !strings.Contains(out, "origin=198.51.100.7\n") {
t.Fatalf("origin missing:\n%s", out)
}
// INFO shows no ERROR-level noise other than the Docker failure
if c := strings.Count(out, "level=ERROR"); c != 1 {
t.Fatalf("error lines = %d", c)
}
}
func TestNoSecretInLogsAtTrace(t *testing.T) {
t.Parallel()
st := newRealStack(t, LevelTrace)
st.docker.containers = makeContainers(2)
unknown := nearMiss(st.good)
for _, h := range []http.Header{
bearer(st.good), bearer(st.old), bearer(unknown), bearer(""),
{"Authorization": {"Basic " + st.good}},
{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}},
} {
doRequest(st.server, "GET", "/api/v1/containers", h)
doRequest(st.server, "POST", "/api/v1/containers", h)
}
st.docker.err = errors.New("fail")
doRequest(st.server, "GET", "/api/v1/containers", bearer(st.good))
out := st.log.String()
for _, secret := range []string{st.good, st.old, unknown, st.good[:16], "Authorization", "Bearer"} {
if strings.Contains(out, secret) {
t.Fatalf("%q leaked into logs", secret)
}
}
// TRACE covers the execution path
for _, want := range []string{
`func=WebServer.logRequests`, `func=WebServer.handleContainers`, `func=authenticate`,
`func=TokenStore.GetToken`, `func=AuditStore.RecordOperation`, `func=WebServer.handleMethodNotAllowed`,
`msg="row read" table=tokens token_id=` + tokenID(st.good),
`msg="row inserted" table=operations token_id=` + tokenID(st.good) + ` origin=198.51.100.7 operation="GET containers"`,
`level=DEBUG msg="authentication result" result=ok token_id=` + tokenID(st.good),
`level=DEBUG msg="audit row inserted" token_id=` + tokenID(st.good),
`level=DEBUG msg="docker request started"`,
`level=DEBUG msg="docker request finished" ok=true`,
`level=DEBUG msg="docker request finished" ok=false`,
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q", want)
}
}
}
func TestHandlerOverRealHTTP(t *testing.T) {
t.Parallel()
st := newRealStack(t, slog.LevelInfo)
srv := httptest.NewServer(st.server.HTTPServer.Handler)
defer srv.Close()
get := func(header http.Header) *http.Response {
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/containers", nil)
req.Header = header
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
resp := get(bearer(st.good))
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || string(body) != "[]" || resp.Header.Get("Content-Type") != "application/json" {
t.Fatalf("got %d %q", resp.StatusCode, body)
}
// Two Authorization header lines on the wire
resp = get(http.Header{"Authorization": {"Bearer " + st.good, "Bearer " + st.good}})
resp.Body.Close()
if resp.StatusCode != 401 {
t.Fatalf("duplicate header status = %d", resp.StatusCode)
}
rows := readOperations(t, st.audit)
if len(rows) != 1 || rows[0].origin != "127.0.0.1" {
t.Fatalf("rows = %+v", rows)
}
}
func TestStatusRecorder(t *testing.T) {
t.Parallel()
// Implicit 200 on Write, first WriteHeader wins, Unwrap exposes the writer
inner := httptest.NewRecorder()
rec := &statusRecorder{ResponseWriter: inner, status: http.StatusOK}
rec.Write([]byte("x"))
rec.WriteHeader(http.StatusTeapot)
if rec.status != http.StatusOK {
t.Fatalf("status = %d", rec.status)
}
if rec.Unwrap() != inner {
t.Fatal("Unwrap")
}
rec2 := &statusRecorder{ResponseWriter: httptest.NewRecorder(), status: http.StatusOK}
rec2.WriteHeader(http.StatusAccepted)
rec2.WriteHeader(http.StatusTeapot)
if rec2.status != http.StatusAccepted {
t.Fatalf("status = %d", rec2.status)
}
// A handler writing nothing is logged as 200
log, buf := newTestLogger(slog.LevelInfo)
s := &WebServer{log: log}
h := s.logRequests(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
if !strings.Contains(buf.String(), "status=200") {
t.Fatalf("log = %s", buf.String())
}
}