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
@@ -0,0 +1,81 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authentication result classes; these fixed strings are the only authentication detail ever logged
|
||||||
|
const (
|
||||||
|
authMissingHeader = "missing_header"
|
||||||
|
authEmptyHeader = "empty_header"
|
||||||
|
authMultipleHeaders = "multiple_headers"
|
||||||
|
authBadScheme = "bad_scheme"
|
||||||
|
authEmptyToken = "empty_token"
|
||||||
|
authUnknownToken = "unknown_token"
|
||||||
|
authInactiveToken = "inactive_token"
|
||||||
|
authLookupError = "lookup_error"
|
||||||
|
authOK = "ok"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenLookup is the token-store dependency of the HTTP layer
|
||||||
|
type TokenLookup interface {
|
||||||
|
GetToken(ctx context.Context, value string) (Token, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAuthorization extracts the bearer token from the request headers
|
||||||
|
// It returns the token and an empty class on success, or an empty token and the failure class
|
||||||
|
// Accepted form: exactly one Authorization header, scheme "Bearer" (ASCII case-insensitive), one space, token
|
||||||
|
// The token is taken byte-exact: no trimming or normalization is applied
|
||||||
|
func parseAuthorization(h http.Header) (string, string) {
|
||||||
|
values := h.Values("Authorization")
|
||||||
|
switch {
|
||||||
|
case len(values) == 0:
|
||||||
|
return "", authMissingHeader
|
||||||
|
case len(values) > 1:
|
||||||
|
return "", authMultipleHeaders
|
||||||
|
case values[0] == "":
|
||||||
|
return "", authEmptyHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split "<scheme> <token>" at the first space
|
||||||
|
scheme, token, found := strings.Cut(values[0], " ")
|
||||||
|
if !asciiEqualFold(scheme, "Bearer") {
|
||||||
|
return "", authBadScheme
|
||||||
|
}
|
||||||
|
if !found || token == "" {
|
||||||
|
return "", authEmptyToken
|
||||||
|
}
|
||||||
|
return token, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate validates the request credentials against the token store
|
||||||
|
// It returns the authenticated token, the result class, and a non-nil error only when the lookup itself failed
|
||||||
|
func authenticate(ctx context.Context, log *slog.Logger, tokens TokenLookup, h http.Header) (Token, string, error) {
|
||||||
|
defer traceFunc(ctx, log, "authenticate")()
|
||||||
|
|
||||||
|
// Parse the header
|
||||||
|
value, class := parseAuthorization(h)
|
||||||
|
if class != "" {
|
||||||
|
return Token{}, class, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look the token up
|
||||||
|
tok, err := tokens.GetToken(ctx, value)
|
||||||
|
if errors.Is(err, ErrTokenNotFound) {
|
||||||
|
return Token{}, authUnknownToken, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Token{}, authLookupError, err
|
||||||
|
}
|
||||||
|
if !tok.Active {
|
||||||
|
return Token{}, authInactiveToken, nil
|
||||||
|
}
|
||||||
|
return tok, authOK, nil
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeLookup is an in-memory TokenLookup
|
||||||
|
type fakeLookup struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tokens map[string]Token
|
||||||
|
err error
|
||||||
|
calls []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLookup) GetToken(ctx context.Context, value string) (Token, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.calls = append(f.calls, value)
|
||||||
|
if f.err != nil {
|
||||||
|
return Token{}, f.err
|
||||||
|
}
|
||||||
|
tok, ok := f.tokens[value]
|
||||||
|
if !ok {
|
||||||
|
return Token{}, ErrTokenNotFound
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeLookup() *fakeLookup {
|
||||||
|
return &fakeLookup{tokens: map[string]Token{
|
||||||
|
"good": {Token: "good", Active: true},
|
||||||
|
"Good": {Token: "Good", Active: true},
|
||||||
|
"inactive": {Token: "inactive", Active: false},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func headerWith(values ...string) http.Header {
|
||||||
|
h := http.Header{}
|
||||||
|
for _, v := range values {
|
||||||
|
h.Add("Authorization", v)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthorization(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header http.Header
|
||||||
|
token string
|
||||||
|
class string
|
||||||
|
}{
|
||||||
|
{"missing", http.Header{}, "", authMissingHeader},
|
||||||
|
{"other headers only", http.Header{"X-Authorization": {"Bearer good"}}, "", authMissingHeader},
|
||||||
|
{"empty", headerWith(""), "", authEmptyHeader},
|
||||||
|
{"multiple", headerWith("Bearer good", "Bearer good"), "", authMultipleHeaders},
|
||||||
|
{"multiple with empty", headerWith("", "Bearer good"), "", authMultipleHeaders},
|
||||||
|
{"basic", headerWith("Basic Z29vZDo="), "", authBadScheme},
|
||||||
|
{"token scheme", headerWith("Token good"), "", authBadScheme},
|
||||||
|
{"no space", headerWith("Bearergood"), "", authBadScheme},
|
||||||
|
{"tab separator", headerWith("Bearer\tgood"), "", authBadScheme},
|
||||||
|
{"leading space", headerWith(" Bearer good"), "", authBadScheme},
|
||||||
|
{"bare token", headerWith("good"), "", authBadScheme},
|
||||||
|
{"unicode scheme", headerWith("Beаrer good"), "", authBadScheme}, // Cyrillic a
|
||||||
|
{"scheme only", headerWith("Bearer"), "", authEmptyToken},
|
||||||
|
{"scheme and space", headerWith("Bearer "), "", authEmptyToken},
|
||||||
|
{"ok", headerWith("Bearer good"), "good", ""},
|
||||||
|
{"lowercase scheme", headerWith("bearer good"), "good", ""},
|
||||||
|
{"uppercase scheme", headerWith("BEARER good"), "good", ""},
|
||||||
|
{"double space kept", headerWith("Bearer good"), " good", ""},
|
||||||
|
{"trailing kept", headerWith("Bearer good "), "good ", ""},
|
||||||
|
{"extra words kept", headerWith("Bearer good extra"), "good extra", ""},
|
||||||
|
{"comma kept", headerWith("Bearer good, Bearer other"), "good, Bearer other", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
token, class := parseAuthorization(c.header)
|
||||||
|
if token != c.token || class != c.class {
|
||||||
|
t.Errorf("%s: got (%q, %q), want (%q, %q)", c.name, token, class, c.token, c.class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
header http.Header
|
||||||
|
class string
|
||||||
|
token string
|
||||||
|
}{
|
||||||
|
{"missing", http.Header{}, authMissingHeader, ""},
|
||||||
|
{"empty", headerWith(""), authEmptyHeader, ""},
|
||||||
|
{"wrong scheme", headerWith("Basic good"), authBadScheme, ""},
|
||||||
|
{"empty bearer", headerWith("Bearer "), authEmptyToken, ""},
|
||||||
|
{"malformed bearer", headerWith("Bearer good"), authUnknownToken, ""},
|
||||||
|
{"multiple", headerWith("Bearer good", "Bearer good"), authMultipleHeaders, ""},
|
||||||
|
{"unknown", headerWith("Bearer nope"), authUnknownToken, ""},
|
||||||
|
{"case changed", headerWith("Bearer GOOD"), authUnknownToken, ""},
|
||||||
|
{"near match", headerWith("Bearer goo"), authUnknownToken, ""},
|
||||||
|
{"inactive", headerWith("Bearer inactive"), authInactiveToken, ""},
|
||||||
|
{"valid", headerWith("Bearer good"), authOK, "good"},
|
||||||
|
{"valid case-distinct token", headerWith("bearer Good"), authOK, "Good"},
|
||||||
|
}
|
||||||
|
lookup := newFakeLookup()
|
||||||
|
for _, c := range cases {
|
||||||
|
tok, class, err := authenticate(context.Background(), traceDiscard(), lookup, c.header)
|
||||||
|
if err != nil || class != c.class || tok.Token != c.token {
|
||||||
|
t.Errorf("%s: got (%q, %q, %v), want (%q, %q)", c.name, tok.Token, class, err, c.token, c.class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateLookupError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
lookup := &fakeLookup{err: errors.New("database is locked")}
|
||||||
|
_, class, err := authenticate(context.Background(), traceDiscard(), lookup, headerWith("Bearer good"))
|
||||||
|
if err == nil || class != authLookupError {
|
||||||
|
t.Fatalf("got (%q, %v)", class, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateDoesNotLookUpMalformedHeaders(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
lookup := newFakeLookup()
|
||||||
|
for _, h := range []http.Header{{}, headerWith(""), headerWith("Basic x"), headerWith("Bearer "), headerWith("Bearer a", "Bearer b")} {
|
||||||
|
authenticate(context.Background(), traceDiscard(), lookup, h)
|
||||||
|
}
|
||||||
|
if len(lookup.calls) != 0 {
|
||||||
|
t.Fatalf("lookups performed: %q", lookup.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticationClassesAreFixedStrings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, c := range []string{authMissingHeader, authEmptyHeader, authMultipleHeaders, authBadScheme,
|
||||||
|
authEmptyToken, authUnknownToken, authInactiveToken, authLookupError, authOK} {
|
||||||
|
if c == "" || strings.ContainsAny(c, " =\"") {
|
||||||
|
t.Errorf("class %q is not a plain identifier", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 10: audit rows verified directly in SQLite — exact token/origin/operation/
|
||||||
|
* time values, one row per authenticated GET only, written before the Docker call (so Docker
|
||||||
|
* failures are audited), origin = TCP peer (forwarding headers ignored), IPv4/IPv6 origins.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
msToNanos,
|
||||||
|
readOperations,
|
||||||
|
rfc3339ToNanos,
|
||||||
|
RFC3339NANO_UTC_RE,
|
||||||
|
type DockerBackend,
|
||||||
|
type FakeDocker,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
test('each authenticated GET creates exactly one row with exact values, in request order', async ({ cli, startServer, http }) => {
|
||||||
|
const a = await cli.tokenNew('a');
|
||||||
|
const b = await cli.tokenNew('b');
|
||||||
|
const server = await startServer();
|
||||||
|
const plan = [a, b, a, a, b, a];
|
||||||
|
const windows: Array<[bigint, bigint]> = [];
|
||||||
|
for (const [i, tok] of plan.entries()) {
|
||||||
|
const t0 = msToNanos(Date.now() - 1);
|
||||||
|
const r = i % 2 ? await http.curl({ url: server.url(), token: tok }) : await http.get(server.url(), { token: tok });
|
||||||
|
windows.push([t0, msToNanos(Date.now() + 1)]);
|
||||||
|
expectContainersResponse(r);
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const ops = readOperations(server.dbPath);
|
||||||
|
expect(ops.map((o) => [o.token, o.origin, o.operation])).toEqual(plan.map((t) => [t, '127.0.0.1', 'GET containers']));
|
||||||
|
for (const [i, o] of ops.entries()) {
|
||||||
|
expect(o.time).toMatch(RFC3339NANO_UTC_RE);
|
||||||
|
const t = rfc3339ToNanos(o.time);
|
||||||
|
expect(t >= windows[i][0] && t <= windows[i][1], `row ${i} time ${o.time} inside its request window`).toBe(true);
|
||||||
|
if (i > 0) expect(o.rowid).toBeGreaterThan(ops[i - 1].rowid);
|
||||||
|
}
|
||||||
|
assertDbInvariants(server.dbPath, { tokenCount: 2, operationCount: plan.length, auditBeforeDeletion: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsuccessful requests never create rows (401 variants, 404, 405, Go-level rejects)', async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const dead = await cli.tokenNew();
|
||||||
|
await cli.tokenDelete(dead);
|
||||||
|
const server = await startServer();
|
||||||
|
expectErrorResponse(await http.get(server.url()), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: dead }), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url(), { token: tok + 'x' }), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url(), { headers: { Authorization: `Basic ${tok}` } }), 401);
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(), headers: [`Authorization: Bearer ${tok}`, `Authorization: Bearer ${tok}`] }), 401);
|
||||||
|
expectErrorResponse(await http.get(server.url('/api/v1/containers/'), { token: tok }), 404);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token: tok }), 405);
|
||||||
|
const bad = await http.raw({ host: server.connectHost, port: server.port, data: `GET /api/v1/containers HTTP/1.1\r\nAuthorization: Bearer ${tok}\r\n\r\n` }); // no Host → 400
|
||||||
|
expect(bad.response!.status).toBe(400);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticated requests are audited even when Docker fails (row written before the Docker call)', async ({ cli, startServer, http, ws }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const backends: Array<[string, DockerBackend | ((f: FakeDocker) => void)]> = [
|
||||||
|
['engine 500', (f) => f.error500()],
|
||||||
|
['connection dropped', (f) => f.destroy()],
|
||||||
|
['malformed JSON', (f) => f.badJson()],
|
||||||
|
['engine 404', (f) => f.error(404, 'page not found')],
|
||||||
|
['socket inaccessible (EACCES)', 'inaccessible'],
|
||||||
|
['socket missing (ENOENT)', 'missing'],
|
||||||
|
];
|
||||||
|
let expected = 0;
|
||||||
|
for (const [name, b] of backends) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const server = typeof b === 'function' ? await startServer({ logLevel: 'DEBUG' }) : await startServer({ docker: b, logLevel: 'DEBUG' });
|
||||||
|
if (typeof b === 'function') b(server.fakeDocker!);
|
||||||
|
const r = await http.get(server.url(), { token: tok });
|
||||||
|
expectErrorResponse(r, 500);
|
||||||
|
expected++;
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const logs = server.logs();
|
||||||
|
const audit = logs.find({ msg: 'audit row inserted' });
|
||||||
|
const dockerStart = logs.find({ msg: 'docker request started' });
|
||||||
|
expect(audit && dockerStart && audit.line < dockerStart.line, 'audit insert logged before the Docker request').toBe(true);
|
||||||
|
expect(logs.has({ level: 'ERROR', msg: 'docker request failed' })).toBe(true);
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assertDbInvariants(ws.dbPath, { operationCount: expected });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forwarding headers cannot spoof the audited origin', async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const server = await startServer();
|
||||||
|
const spoof: Array<Record<string, string>> = [
|
||||||
|
{ 'X-Forwarded-For': '203.0.113.7' },
|
||||||
|
{ 'X-Forwarded-For': '203.0.113.7, 198.51.100.1' },
|
||||||
|
{ Forwarded: 'for=203.0.113.7;proto=https' },
|
||||||
|
{ 'X-Real-IP': '203.0.113.7' },
|
||||||
|
{ 'True-Client-IP': '203.0.113.7' },
|
||||||
|
{ 'CF-Connecting-IP': '203.0.113.7' },
|
||||||
|
{ 'X-Client-IP': '203.0.113.7', 'X-Originating-IP': '203.0.113.7', 'X-Remote-Addr': '203.0.113.7' },
|
||||||
|
];
|
||||||
|
for (const h of spoof) expectContainersResponse(await http.get(server.url(), { token: tok, headers: h }));
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(readOperations(server.dbPath).map((o) => o.origin)).toEqual(spoof.map(() => '127.0.0.1'));
|
||||||
|
expect(server.logs().requests().map((r) => r.attrs.origin)).toEqual(spoof.map(() => '127.0.0.1'));
|
||||||
|
expect(server.stderr).not.toContain('203.0.113.7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPv6 and dual-stack listeners record the peer address family correctly', async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const v6 = await startServer({ addr: '::1' });
|
||||||
|
expect(v6.baseURL).toBe(`http://[::1]:${v6.port}`);
|
||||||
|
expect(v6.logs().find({ msg: 'server listening' })!.attrs.addr).toBe(`[::1]:${v6.port}`);
|
||||||
|
expectContainersResponse(await http.get(v6.url(), { token: tok }));
|
||||||
|
expectContainersResponse(await http.curl({ url: v6.url(), token: tok }));
|
||||||
|
expect((await v6.stop()).exitCode).toBe(0);
|
||||||
|
|
||||||
|
const dual = await startServer({ addr: '::' });
|
||||||
|
expectContainersResponse(await http.get(`http://127.0.0.1:${dual.port}/api/v1/containers`, { token: tok }));
|
||||||
|
expectContainersResponse(await http.get(`http://[::1]:${dual.port}/api/v1/containers`, { token: tok }));
|
||||||
|
expect((await dual.stop()).exitCode).toBe(0);
|
||||||
|
|
||||||
|
expect(readOperations(v6.dbPath).map((o) => o.origin)).toEqual(['::1', '::1', '127.0.0.1', '::1']);
|
||||||
|
assertDbInvariants(v6.dbPath, { operationCount: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('audit history survives restarts and is append-only', async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const server = await startServer();
|
||||||
|
for (let i = 0; i < 3; i++) expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||||
|
const before = readOperations(server.dbPath);
|
||||||
|
await server.restart();
|
||||||
|
for (let i = 0; i < 2; i++) expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const after = readOperations(server.dbPath);
|
||||||
|
expect(after.slice(0, 3)).toEqual(before);
|
||||||
|
expect(after).toHaveLength(5);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: 5 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core items 8 and 10: the Authorization matrix (missing, empty, wrong scheme, malformed,
|
||||||
|
* unknown, near-match, inactive, duplicated, unusual) with exact status, JSON body and headers,
|
||||||
|
* the authentication result class logged at DEBUG, and audit rows only for successes.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
buildRawRequest,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
readOperations,
|
||||||
|
type CliRunner,
|
||||||
|
type HttpClient,
|
||||||
|
type RawResult,
|
||||||
|
type ServerHandle,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
interface Ctx {
|
||||||
|
server: ServerHandle;
|
||||||
|
active: string;
|
||||||
|
other: string;
|
||||||
|
inactive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>): Promise<Ctx> {
|
||||||
|
const active = await cli.tokenNew('active');
|
||||||
|
const other = await cli.tokenNew('other');
|
||||||
|
const inactive = await cli.tokenNew('inactive');
|
||||||
|
await cli.tokenDelete(inactive);
|
||||||
|
const server = await startServer({ logLevel: 'DEBUG' });
|
||||||
|
server.fakeDocker!.many(1);
|
||||||
|
return { server, active, other, inactive };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw request with verbatim header lines. */
|
||||||
|
function raw(http: HttpClient, s: ServerHandle, headerLines: Array<string | Buffer>, target = '/api/v1/containers'): Promise<RawResult> {
|
||||||
|
const head = Buffer.concat([
|
||||||
|
Buffer.from(`GET ${target} HTTP/1.1\r\nHost: localhost\r\n`, 'latin1'),
|
||||||
|
...headerLines.map((h) => Buffer.concat([typeof h === 'string' ? Buffer.from(h, 'latin1') : h, Buffer.from('\r\n')])),
|
||||||
|
Buffer.from('Connection: close\r\n\r\n'),
|
||||||
|
]);
|
||||||
|
return http.raw({ host: s.connectHost, port: s.port, data: head });
|
||||||
|
}
|
||||||
|
|
||||||
|
function authClasses(s: ServerHandle): string[] {
|
||||||
|
return s.logs().filter({ msg: 'authentication result' }).map((r) => r.attrs.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flipCase = (t: string) => [...t].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join('');
|
||||||
|
|
||||||
|
test('missing, empty and malformed Authorization headers → 401 (Playwright request)', async ({ cli, startServer, http }) => {
|
||||||
|
const { server, active } = await setup(cli, startServer);
|
||||||
|
const cases: Array<[string, Record<string, string>, string]> = [
|
||||||
|
['no header', {}, 'missing_header'],
|
||||||
|
['scheme only', { Authorization: 'Bearer' }, 'empty_token'],
|
||||||
|
['scheme + trailing space (trimmed by HTTP)', { Authorization: 'Bearer ' }, 'empty_token'],
|
||||||
|
['Basic credentials', { Authorization: 'Basic dXNlcjpwYXNz' }, 'bad_scheme'],
|
||||||
|
['Token scheme', { Authorization: `Token ${active}` }, 'bad_scheme'],
|
||||||
|
['bare token, no scheme', { Authorization: active }, 'bad_scheme'],
|
||||||
|
['tab instead of space', { Authorization: `Bearer\t${active}` }, 'bad_scheme'],
|
||||||
|
['scheme with colon', { Authorization: `Bearer: ${active}` }, 'bad_scheme'],
|
||||||
|
['misspelled scheme', { Authorization: `Baerer ${active}` }, 'bad_scheme'],
|
||||||
|
['scheme prefix', { Authorization: `BearerX ${active}` }, 'bad_scheme'],
|
||||||
|
['double space before token', { Authorization: `Bearer ${active}` }, 'unknown_token'],
|
||||||
|
['token followed by extra word', { Authorization: `Bearer ${active} extra` }, 'unknown_token'],
|
||||||
|
['quoted token', { Authorization: `Bearer "${active}"` }, 'unknown_token'],
|
||||||
|
['two credentials in one value', { Authorization: `Bearer ${active}, Bearer ${active}` }, 'unknown_token'],
|
||||||
|
['Bearer Bearer', { Authorization: `Bearer Bearer ${active}` }, 'unknown_token'],
|
||||||
|
['different header (Proxy-Authorization)', { 'Proxy-Authorization': `Bearer ${active}` }, 'missing_header'],
|
||||||
|
['token in X-Auth-Token', { 'X-Auth-Token': active }, 'missing_header'],
|
||||||
|
];
|
||||||
|
for (const [name, headers] of cases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
expectErrorResponse(await http.get(server.url(), { headers }), 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// query-string tokens are not credentials
|
||||||
|
expectErrorResponse(await http.get(server.url(`/api/v1/containers?access_token=${active}`)), 401);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual([...cases.map((c) => c[2]), 'missing_header']);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty and whitespace-only header values → 401 (curl and raw socket)', async ({ cli, startServer, http }) => {
|
||||||
|
const { server } = await setup(cli, startServer);
|
||||||
|
// curl: "Name;" sends an empty header
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(), headers: ['Authorization;'] }), 401);
|
||||||
|
const cases: Array<[string, string, string]> = [
|
||||||
|
['empty value', 'Authorization:', 'empty_header'],
|
||||||
|
['spaces only', 'Authorization: ', 'empty_header'],
|
||||||
|
['tab only', 'Authorization:\t', 'empty_header'],
|
||||||
|
['no space after colon, Bearer only', 'Authorization:Bearer', 'empty_token'],
|
||||||
|
['Bearer + spaces', 'Authorization: Bearer ', 'empty_token'],
|
||||||
|
];
|
||||||
|
for (const [name, line] of cases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await raw(http, server, [line]);
|
||||||
|
expect(r.outcome).toBe('response');
|
||||||
|
expectErrorResponse(r.response!, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual(['empty_header', ...cases.map((c) => c[2])]);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown, near-match and inactive tokens → 401', async ({ cli, startServer, http, rng }) => {
|
||||||
|
const { server, active, inactive } = await setup(cli, startServer);
|
||||||
|
const lastFlip = active.slice(0, -1) + (active.endsWith('A') ? 'B' : 'A');
|
||||||
|
const cases: Array<[string, string, string]> = [
|
||||||
|
['random unknown token', rng.string(43, 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'), 'unknown_token'],
|
||||||
|
['last character changed', lastFlip, 'unknown_token'],
|
||||||
|
['case swapped', flipCase(active), 'unknown_token'],
|
||||||
|
['one character short', active.slice(0, 42), 'unknown_token'],
|
||||||
|
['one character extra', active + 'A', 'unknown_token'],
|
||||||
|
['base64 std alphabet variant', active.replaceAll('-', '+').replaceAll('_', '/'), active.includes('-') || active.includes('_') ? 'unknown_token' : 'ok'],
|
||||||
|
['padded with =', active + '=', 'unknown_token'],
|
||||||
|
['percent-encoded first char', '%' + active.charCodeAt(0).toString(16) + active.slice(1), 'unknown_token'],
|
||||||
|
['SQL injection', `' OR '1'='1`, 'unknown_token'],
|
||||||
|
['SQL wildcard', '%', 'unknown_token'],
|
||||||
|
['inactive (deleted) token', inactive, 'inactive_token'],
|
||||||
|
['inactive token, case swapped', flipCase(inactive), 'unknown_token'],
|
||||||
|
];
|
||||||
|
let oks = 0;
|
||||||
|
for (const [name, token, cls] of cases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await http.get(server.url(), { token });
|
||||||
|
if (cls === 'ok') {
|
||||||
|
expectContainersResponse(r);
|
||||||
|
oks++;
|
||||||
|
} else expectErrorResponse(r, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(oks);
|
||||||
|
assertDbInvariants(server.dbPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepted forms: case-insensitive scheme, header-name case, surrounding whitespace → 200 and audited', async ({ cli, startServer, http }) => {
|
||||||
|
const { server, active, other } = await setup(cli, startServer);
|
||||||
|
const pw: Array<[string, Record<string, string>, string]> = [
|
||||||
|
['canonical', { Authorization: `Bearer ${active}` }, active],
|
||||||
|
['lower-case scheme', { Authorization: `bearer ${active}` }, active],
|
||||||
|
['upper-case scheme', { Authorization: `BEARER ${other}` }, other],
|
||||||
|
['mixed-case scheme', { Authorization: `bEaReR ${other}` }, other],
|
||||||
|
['lower-case header name', { authorization: `Bearer ${active}` }, active],
|
||||||
|
];
|
||||||
|
const audited: string[] = [];
|
||||||
|
for (const [name, headers, tok] of pw) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
expectContainersResponse(await http.get(server.url(), { headers }));
|
||||||
|
audited.push(tok);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const rawCases: Array<[string, string, string]> = [
|
||||||
|
['trailing spaces trimmed by HTTP parsing', `Authorization: Bearer ${active} `, active],
|
||||||
|
['trailing tab trimmed', `Authorization: Bearer ${active}\t`, active],
|
||||||
|
['leading whitespace before scheme trimmed', `Authorization: Bearer ${other}`, other],
|
||||||
|
['UPPER-CASE header name', `AUTHORIZATION: Bearer ${other}`, other],
|
||||||
|
['no space after colon', `Authorization:Bearer ${active}`, active],
|
||||||
|
];
|
||||||
|
for (const [name, line, tok] of rawCases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await raw(http, server, [line]);
|
||||||
|
expectContainersResponse(r.response!);
|
||||||
|
audited.push(tok);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// curl
|
||||||
|
expectContainersResponse(await http.curl({ url: server.url(), token: active }));
|
||||||
|
audited.push(active);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual(audited.map(() => 'ok'));
|
||||||
|
expect(readOperations(server.dbPath).map((o) => o.token)).toEqual(audited);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: audited.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicated and conflicting Authorization headers → 401 (multiple_headers), never audited', async ({ cli, startServer, http }) => {
|
||||||
|
const { server, active, other, inactive } = await setup(cli, startServer);
|
||||||
|
const curlCases: Array<[string, string[]]> = [
|
||||||
|
['same valid header twice', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${active}`]],
|
||||||
|
['two different valid tokens', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${other}`]],
|
||||||
|
['valid + invalid', [`Authorization: Bearer ${active}`, 'Authorization: Basic Zm9vOmJhcg==']],
|
||||||
|
['invalid + valid', ['Authorization: Bearer nope', `Authorization: Bearer ${active}`]],
|
||||||
|
['valid + empty', [`Authorization: Bearer ${active}`, 'Authorization;']],
|
||||||
|
['valid + inactive', [`Authorization: Bearer ${active}`, `Authorization: Bearer ${inactive}`]],
|
||||||
|
];
|
||||||
|
for (const [name, headers] of curlCases) {
|
||||||
|
await test.step(`curl: ${name}`, async () => {
|
||||||
|
const r = await http.curl({ url: server.url(), headers });
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expectErrorResponse(r, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await test.step('raw: three headers with different name case', async () => {
|
||||||
|
const r = await raw(http, server, [`authorization: Bearer ${active}`, `Authorization: Bearer ${active}`, `AUTHORIZATION: Bearer ${active}`]);
|
||||||
|
expectErrorResponse(r.response!, 401);
|
||||||
|
});
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual([...curlCases.map(() => 'multiple_headers'), 'multiple_headers']);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unusual but syntactically valid Authorization values → 401 without leaking anything', async ({ cli, startServer, http }) => {
|
||||||
|
const { server, active } = await setup(cli, startServer);
|
||||||
|
const cases: Array<[string, Buffer | string, string]> = [
|
||||||
|
['very long token (16 KiB)', `Authorization: Bearer ${'A'.repeat(16 * 1024)}`, 'unknown_token'],
|
||||||
|
['UTF-8 bytes in token (obs-text)', Buffer.concat([Buffer.from('Authorization: Bearer '), Buffer.from('tökén-ü', 'utf8')]), 'unknown_token'],
|
||||||
|
['Cyrillic look-alike scheme', Buffer.concat([Buffer.from('Authorization: B'), Buffer.from('е', 'utf8'), Buffer.from(`arer ${active}`)]), 'bad_scheme'],
|
||||||
|
['Latin-1 NBSP instead of space', Buffer.concat([Buffer.from('Authorization: Bearer\xa0', 'latin1'), Buffer.from(active)]), 'bad_scheme'],
|
||||||
|
['token followed by more words', `Authorization: Bearer ${active} x`, 'unknown_token'],
|
||||||
|
];
|
||||||
|
for (const [name, line] of cases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await raw(http, server, [line]);
|
||||||
|
expect(r.outcome).toBe('response');
|
||||||
|
expectErrorResponse(r.response!, 401);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Control characters (other than HTAB) are invalid in field values: Go rejects the request
|
||||||
|
// with a plain-text 400 before the application sees it (not logged, not audited).
|
||||||
|
for (const ctl of ['\x7f', '\x01', '\x1b']) {
|
||||||
|
await test.step(`control character ${JSON.stringify(ctl)} in the token → Go 400`, async () => {
|
||||||
|
const r = await raw(http, server, [`Authorization: Bearer ${active.slice(0, 20)}${ctl}${active.slice(21)}`]);
|
||||||
|
expect(r.response!.status).toBe(400);
|
||||||
|
expect(r.response!.header('content-type')).toBe('text/plain; charset=utf-8');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server)).toEqual(cases.map((c) => c[2]));
|
||||||
|
expect(server.logs().requests(), 'Go-rejected requests are not logged').toHaveLength(cases.length);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
expect(server.stderr).not.toContain('AAAAAAAAAAAAAAAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests on non-endpoint routes and methods are not authenticated or audited even with a valid token', async ({ cli, startServer, http }) => {
|
||||||
|
const { server, active } = await setup(cli, startServer);
|
||||||
|
expectErrorResponse(await http.get(server.url('/api/v1/containers/'), { token: active }), 404);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token: active }), 405);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'DELETE', token: 'garbage' }), 405);
|
||||||
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ method: 'HEAD', target: '/api/v1/containers', headers: [`Authorization: Bearer ${active}`] }) });
|
||||||
|
expectErrorResponse(r.response!, 405, { head: true });
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(authClasses(server), 'authentication never ran').toEqual([]);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core item 9: wrong methods, unknown routes, trailing slashes, path tricks, query
|
||||||
|
* strings, fragments, repeated/unusual headers, empty and irrelevant bodies, and requests Go
|
||||||
|
* rejects before routing. Every request reaching the application must be logged exactly once
|
||||||
|
* at INFO; only authenticated GETs on the exact path are audited.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
buildRawRequest,
|
||||||
|
expectContainersResponse,
|
||||||
|
expectErrorResponse,
|
||||||
|
readOperations,
|
||||||
|
type CliRunner,
|
||||||
|
type ServerHandle,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
async function setup(cli: CliRunner, startServer: (o?: object) => Promise<ServerHandle>) {
|
||||||
|
const token = await cli.tokenNew('surface');
|
||||||
|
const server = await startServer({ logLevel: 'INFO' });
|
||||||
|
server.fakeDocker!.many(1);
|
||||||
|
return { token, server };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every non-GET method on the endpoint → 405 with Allow: GET, before authentication', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
const seen: Array<[string, string]> = [];
|
||||||
|
for (const method of ['POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']) {
|
||||||
|
for (const auth of [undefined, token, 'wrong']) {
|
||||||
|
await test.step(`${method} auth=${auth === token ? 'valid' : auth ?? 'none'}`, async () => {
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method, token: auth }), 405);
|
||||||
|
seen.push([method, '405']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HEAD: same status/headers, no body
|
||||||
|
const head = await http.curl({ url: server.url(), head: true, token });
|
||||||
|
expect(head.exitCode).toBe(0);
|
||||||
|
expectErrorResponse({ status: head.status, headers: head.headers, body: head.body }, 405, { head: true });
|
||||||
|
seen.push(['HEAD', '405']);
|
||||||
|
// Methods curl/raw can send but browsers cannot
|
||||||
|
for (const method of ['PROPFIND', 'TRACE', 'FOO', 'get', 'Get', 'G-E_T']) {
|
||||||
|
await test.step(`curl -X ${method}`, async () => {
|
||||||
|
const r = await http.curl({ url: server.url(), method, token });
|
||||||
|
expectErrorResponse(r, 405);
|
||||||
|
seen.push([method, '405']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(server.logs().requests().map((r) => [r.attrs.method, r.attrs.status])).toEqual(seen);
|
||||||
|
expect(server.logs().count({ msg: 'authentication result' })).toBe(0);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown routes, trailing slashes, case and path tricks → 404 (no redirects, no cleaning)', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
const pw = [
|
||||||
|
'/',
|
||||||
|
'/api',
|
||||||
|
'/api/',
|
||||||
|
'/api/v1',
|
||||||
|
'/api/v1/',
|
||||||
|
'/api/v1/containers/',
|
||||||
|
'/api/v1/containers//',
|
||||||
|
'/api/v1/containers/x',
|
||||||
|
'/api/v1/containers.json',
|
||||||
|
'/api/v1/container',
|
||||||
|
'/API/v1/containers',
|
||||||
|
'/api/V1/containers',
|
||||||
|
'/api/v1/Containers',
|
||||||
|
'/api/v2/containers',
|
||||||
|
'/api/v1/containers%2F',
|
||||||
|
'/api/v1/containers%3Fx=1',
|
||||||
|
'/api/v1/containers%00',
|
||||||
|
'/api/v1/containers;jsessionid=1',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/.env',
|
||||||
|
];
|
||||||
|
const logged: string[] = [];
|
||||||
|
for (const p of pw) {
|
||||||
|
await test.step(`GET ${p}`, async () => {
|
||||||
|
expectErrorResponse(await http.get(server.url(p), { token }), 404);
|
||||||
|
});
|
||||||
|
logged.push(p);
|
||||||
|
}
|
||||||
|
// Normalizing clients would rewrite these; send them verbatim
|
||||||
|
for (const p of ['//api/v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/./api/v1/containers', '/api//v1/containers', '/api/v1/containers/.', '/api/v1/containers/..']) {
|
||||||
|
await test.step(`curl --path-as-is ${p}`, async () => {
|
||||||
|
const r = await http.curl({ url: server.url(p), token, pathAsIs: true });
|
||||||
|
expectErrorResponse(r, 404);
|
||||||
|
});
|
||||||
|
logged.push(p);
|
||||||
|
}
|
||||||
|
// other methods on unknown routes are 404, not 405
|
||||||
|
expectErrorResponse(await http.fetch(server.url('/api/v1/containers/'), { method: 'POST', token }), 404);
|
||||||
|
expectErrorResponse(await http.fetch(server.url('/nope'), { method: 'DELETE' }), 404);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const reqs = server.logs().requests();
|
||||||
|
expect(reqs.slice(0, logged.length).map((r) => [r.attrs.path, r.attrs.status])).toEqual(
|
||||||
|
logged.map((p) => [decodeURIComponent(p), '404']), // the app logs the decoded URL path
|
||||||
|
);
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('percent-encoded slashes are not path separators', async ({ cli, startServer, http }) => {
|
||||||
|
// %2F is not equivalent to "/", so these are unknown routes even with a valid token.
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
for (const p of ['/api/v1%2Fcontainers', '/api%2Fv1%2Fcontainers', '/api%2fv1%2fcontainers']) {
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(p), token, pathAsIs: true }), 404);
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(p), pathAsIs: true }), 404);
|
||||||
|
}
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(6).fill('404'));
|
||||||
|
expect(readOperations(server.dbPath)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('query strings, percent-encoded unreserved characters and fragments', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
let audited = 0;
|
||||||
|
for (const p of ['/api/v1/containers?x=1', '/api/v1/containers?', '/api/v1/containers?a=b&a=c', '/api/v1/containers?all=1&filters=%7B%7D', '/api/v1/containers?%00=%ff', '/api/v1/%63ontainers', '/%61pi/v1/containers']) {
|
||||||
|
await test.step(`GET ${p} → endpoint`, async () => {
|
||||||
|
expectContainersResponse(await http.get(server.url(p), { token }));
|
||||||
|
audited++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// the app never forwards client query parameters to Docker (always running containers only)
|
||||||
|
for (const q of server.fakeDocker!.requests.filter((r) => r.path === '/containers/json')) expect(q.query).not.toMatch(/all|filters/);
|
||||||
|
|
||||||
|
// curl strips the fragment client-side: endpoint reached
|
||||||
|
expectContainersResponse(await http.curl({ url: server.url('/api/v1/containers#frag'), token }));
|
||||||
|
audited++;
|
||||||
|
// a fragment sent on the wire is part of the request target → not the endpoint
|
||||||
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers#frag', headers: [`Authorization: Bearer ${token}`] }) });
|
||||||
|
expectErrorResponse(r.response!, 404);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(server.logs().requests().at(-1)!.attrs.path).toBe('/api/v1/containers#frag');
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(audited);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: audited });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated, unusual and irrelevant headers do not change a valid request', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
const auth = `Authorization: Bearer ${token}`;
|
||||||
|
const curlCases: Array<[string, string[]]> = [
|
||||||
|
['repeated Accept', ['Accept: text/html', 'Accept: application/xml']],
|
||||||
|
['Accept that excludes JSON', ['Accept: image/png']],
|
||||||
|
['repeated custom header', ['X-Thing: 1', 'X-Thing: 2', 'X-Thing: 3']],
|
||||||
|
['odd but valid Host', ['Host: evil.example:9999']],
|
||||||
|
['User-Agent removed', ['User-Agent:']],
|
||||||
|
['Accept-Encoding gzip', ['Accept-Encoding: gzip, br']],
|
||||||
|
['Cookie and Referer', ['Cookie: session=abc; other=1', 'Referer: http://elsewhere/']],
|
||||||
|
['Content-Type on a GET', ['Content-Type: application/x-www-form-urlencoded']],
|
||||||
|
['many headers (100)', Array.from({ length: 100 }, (_, i) => `X-Filler-${i}: ${'v'.repeat(20)}`)],
|
||||||
|
['one large header (32 KiB)', [`X-Large: ${'z'.repeat(32 * 1024)}`]],
|
||||||
|
['Connection: keep-alive', ['Connection: keep-alive']],
|
||||||
|
['Cache-Control/Pragma', ['Cache-Control: no-cache', 'Pragma: no-cache']],
|
||||||
|
['If-None-Match', ['If-None-Match: "abc"', 'If-Modified-Since: Thu, 01 Jan 1970 00:00:00 GMT']],
|
||||||
|
['Range', ['Range: bytes=0-1']],
|
||||||
|
];
|
||||||
|
let n = 0;
|
||||||
|
for (const [name, headers] of curlCases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await http.curl({ url: server.url(), headers: [auth, ...headers] });
|
||||||
|
expect(r.exitCode).toBe(0);
|
||||||
|
expectContainersResponse(r);
|
||||||
|
expect(r.headers['content-encoding']).toBeUndefined();
|
||||||
|
n++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await test.step('HTTP/1.0', async () => {
|
||||||
|
const r = await http.curl({ url: server.url(), token, httpVersion: '1.0' });
|
||||||
|
expectContainersResponse(r);
|
||||||
|
n++;
|
||||||
|
});
|
||||||
|
await test.step('Playwright: header names in odd case and many custom headers', async () => {
|
||||||
|
const headers: Record<string, string> = { aUtHoRiZaTiOn: `Bearer ${token}`, 'x-ünused': 'x', 'X-Empty': '' };
|
||||||
|
delete headers['x-ünused'];
|
||||||
|
expectContainersResponse(await http.get(server.url(), { headers }));
|
||||||
|
n++;
|
||||||
|
});
|
||||||
|
await test.step('pipelined requests on one connection', async () => {
|
||||||
|
const one = buildRawRequest({ target: '/api/v1/containers', headers: [auth], close: false });
|
||||||
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: one + one + buildRawRequest({ target: '/api/v1/containers', headers: [auth] }), expectResponses: 3 });
|
||||||
|
expect(r.responses).toHaveLength(3);
|
||||||
|
for (const resp of r.responses) expectContainersResponse(resp);
|
||||||
|
n += 3;
|
||||||
|
});
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(server.logs().requests().map((r) => r.attrs.status)).toEqual(Array(n).fill('200'));
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(n);
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: n });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty and irrelevant request bodies', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
// GET with bodies: ignored
|
||||||
|
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: '{"filters":{"status":["exited"]},"all":true}', headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
expectContainersResponse(await http.fetch(server.url(), { method: 'GET', token, data: Buffer.alloc(64 * 1024, 0x41) }));
|
||||||
|
const zero = await http.raw({ host: server.connectHost, port: server.port, data: buildRawRequest({ target: '/api/v1/containers', headers: [`Authorization: Bearer ${token}`, 'Content-Length: 0'] }) });
|
||||||
|
expectContainersResponse(zero.response!);
|
||||||
|
const chunked = await http.raw({
|
||||||
|
host: server.connectHost,
|
||||||
|
port: server.port,
|
||||||
|
data: `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${token}\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n`,
|
||||||
|
});
|
||||||
|
expectContainersResponse(chunked.response!);
|
||||||
|
// non-GET with or without bodies: 405
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token }), 405);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'POST', token, data: '' }), 405);
|
||||||
|
expectErrorResponse(await http.fetch(server.url(), { method: 'PUT', token, data: 'a=1&b=2', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }), 405);
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(), method: 'DELETE', token, data: '{"id":"x"}' }), 405);
|
||||||
|
expectErrorResponse(await http.curl({ url: server.url(), token, data: 'payload' }), 405); // curl --data-binary defaults to POST
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requests rejected by Go before routing get plain-text errors, are not logged or audited, and the server survives', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
const raw = (data: string | Buffer, extra: object = {}) => http.raw({ host: server.connectHost, port: server.port, data, ...extra });
|
||||||
|
const cases: Array<[string, string | Buffer, number]> = [
|
||||||
|
['garbage request line', 'GARBAGE\r\n\r\n', 400],
|
||||||
|
['missing HTTP version', 'GET /api/v1/containers\r\n\r\n', 400],
|
||||||
|
['unsupported HTTP version', 'GET /api/v1/containers HTTP/9.9\r\nHost: x\r\n\r\n', 505],
|
||||||
|
['HTTP/1.1 without Host', 'GET /api/v1/containers HTTP/1.1\r\nConnection: close\r\n\r\n', 400],
|
||||||
|
['duplicate Host', 'GET /api/v1/containers HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n', 400],
|
||||||
|
['space before colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization : Bearer x\r\n\r\n', 400],
|
||||||
|
['header line without colon', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nNoColonHere\r\n\r\n', 400],
|
||||||
|
['invalid header name', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nBad(Name): 1\r\n\r\n', 400],
|
||||||
|
['conflicting Content-Length', 'GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n', 400],
|
||||||
|
['request target not a path', 'GET api/v1/containers HTTP/1.1\r\nHost: x\r\n\r\n', 400],
|
||||||
|
// Go's limit is DefaultMaxHeaderBytes (1 MiB) plus 4 KiB of slack
|
||||||
|
['headers of 2 MiB', `GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nX-Big: ${'a'.repeat(2 * 1024 * 1024)}\r\n\r\n`, 431],
|
||||||
|
];
|
||||||
|
for (const [name, data, status] of cases) {
|
||||||
|
await test.step(name, async () => {
|
||||||
|
const r = await raw(data);
|
||||||
|
expect(r.outcome, name).toBe('response');
|
||||||
|
expect(r.response!.status, name).toBe(status);
|
||||||
|
expect(r.response!.header('content-type') ?? 'text/plain; charset=utf-8').toMatch(/^text\/plain/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// obs-fold continuation lines are accepted and unfolded by Go (RFC 9112 §5.2 allows replacing with SP)
|
||||||
|
const folded = await raw(`GET /api/v1/containers HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer\r\n ${token}\r\nConnection: close\r\n\r\n`);
|
||||||
|
expectContainersResponse(folded.response!);
|
||||||
|
const opts = await raw('OPTIONS * HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n');
|
||||||
|
expect(opts.response!.status).toBe(200);
|
||||||
|
// the server keeps serving
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token }));
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const logs = server.logs();
|
||||||
|
expect(logs.requests().map((r) => r.attrs.status), 'only the two valid requests were logged').toEqual(['200', '200']);
|
||||||
|
expect(logs.count({ level: ['WARN', 'ERROR'] })).toBe(0);
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('half-open and abruptly closed client connections do not break the server', async ({ cli, startServer, http }) => {
|
||||||
|
const { token, server } = await setup(cli, startServer);
|
||||||
|
// client sends nothing and closes
|
||||||
|
const a = await http.raw({ host: server.connectHost, port: server.port, data: '', endAfterWrite: true, timeoutMs: 3000 });
|
||||||
|
expect(['closed', 'reset']).toContain(a.outcome);
|
||||||
|
// client sends half a request and closes
|
||||||
|
const b = await http.raw({ host: server.connectHost, port: server.port, data: 'GET /api/v1/contai', endAfterWrite: true, timeoutMs: 3000 });
|
||||||
|
expect(['closed', 'reset', 'response']).toContain(b.outcome);
|
||||||
|
if (b.outcome === 'response') expect(b.response!.status).toBe(400);
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token }));
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
expect(server.logs().requests()).toHaveLength(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLAN §12 core items 3, 15 and the persistence matrix: HTTP readiness, SIGTERM/SIGINT graceful
|
||||||
|
* shutdown with exit code 0, in-flight requests drained, port released, database persistence
|
||||||
|
* across restarts, repeated schema initialization, and server/CLI sharing one database.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
test,
|
||||||
|
expect,
|
||||||
|
assertDbInvariants,
|
||||||
|
expectContainersResponse,
|
||||||
|
isPortFree,
|
||||||
|
pollUntil,
|
||||||
|
readOperations,
|
||||||
|
readSchema,
|
||||||
|
readTokens,
|
||||||
|
} from '../../harness';
|
||||||
|
|
||||||
|
for (const sig of ['SIGTERM', 'SIGINT'] as const) {
|
||||||
|
test(`${sig}: graceful shutdown, exit code 0, port released, connections refused afterwards`, async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const server = await startServer({ logLevel: 'DEBUG' });
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: tok }));
|
||||||
|
const pid = server.pid;
|
||||||
|
const exit = await server.stop(sig);
|
||||||
|
expect(exit.exitCode).toBe(0);
|
||||||
|
expect(exit.signal).toBeNull();
|
||||||
|
expect(exit.stdout).toBe('');
|
||||||
|
expect(exit.durationMs).toBeLessThan(5_000);
|
||||||
|
const logs = server.logs();
|
||||||
|
expect(logs.unparsed, 'no error line on graceful shutdown').toEqual([]);
|
||||||
|
const order = ['shutdown requested, draining http server', 'http server shut down', 'server stopped', 'database closed'].map((m) => logs.find({ msg: m })!.line);
|
||||||
|
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||||
|
expect(() => process.kill(pid, 0)).toThrow();
|
||||||
|
expect(await isPortFree(server.port)).toBe(true);
|
||||||
|
const r = await http.raw({ host: server.connectHost, port: server.port, data: 'GET / HTTP/1.1\r\nHost: x\r\n\r\n' });
|
||||||
|
expect(r.outcome).toBe('refused');
|
||||||
|
assertDbInvariants(server.dbPath, { operationCount: 1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('an in-flight request completes during graceful shutdown; new connections are refused', async ({ cli, startServer, http }) => {
|
||||||
|
const tok = await cli.tokenNew();
|
||||||
|
const server = await startServer();
|
||||||
|
const f = server.fakeDocker!.many(2).delay(1500);
|
||||||
|
const inflight = http.get(server.url(), { token: tok });
|
||||||
|
await pollUntil('request to reach Docker', () => (f.listCalls() === 1 ? true : undefined));
|
||||||
|
const stopping = server.stop('SIGTERM');
|
||||||
|
const r = await inflight;
|
||||||
|
expect(expectContainersResponse(r)).toHaveLength(2);
|
||||||
|
expect((await stopping).exitCode).toBe(0);
|
||||||
|
expect(r.durationMs).toBeGreaterThan(1400);
|
||||||
|
expect(server.logs().requests().map((x) => x.attrs.status)).toEqual(['200']);
|
||||||
|
expect(readOperations(server.dbPath)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state persists across restarts; schema initialization is idempotent', async ({ cli, startServer, http, ws }) => {
|
||||||
|
test.slow();
|
||||||
|
const t1 = await cli.tokenNew('before first start');
|
||||||
|
const server = await startServer();
|
||||||
|
const schema = readSchema(ws.dbPath);
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: t1 }));
|
||||||
|
let ops = 1;
|
||||||
|
const created = [t1];
|
||||||
|
for (let gen = 2; gen <= 4; gen++) {
|
||||||
|
await server.restart();
|
||||||
|
expect(server.generation).toBe(gen);
|
||||||
|
for (const t of created) {
|
||||||
|
expectContainersResponse(await http.get(server.url(), { token: t }));
|
||||||
|
ops++;
|
||||||
|
}
|
||||||
|
created.push(await cli.tokenNew(`generation ${gen}`));
|
||||||
|
expect(readSchema(ws.dbPath), 'schema unchanged by repeated initialization').toEqual(schema);
|
||||||
|
}
|
||||||
|
await cli.tokenDelete(t1);
|
||||||
|
await server.restart();
|
||||||
|
const r = await http.get(server.url(), { token: t1 });
|
||||||
|
expect(r.status, 'deletion persisted across restart').toBe(401);
|
||||||
|
expect((await server.stop()).exitCode).toBe(0);
|
||||||
|
const rows = readTokens(ws.dbPath);
|
||||||
|
expect(rows.map((x) => [x.token, x.active])).toEqual(created.map((t) => [t, t === t1 ? 0 : 1]));
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: created.length, operationCount: ops });
|
||||||
|
for (const g of server.allOutput()) expect(g.exit?.exitCode, `generation ${g.generation} exit code`).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two servers on different ports share one database with concurrent CLI use', async ({ cli, startServer, http, ws }) => {
|
||||||
|
const a = await startServer({ label: 'a' });
|
||||||
|
const b = await startServer({ label: 'b' });
|
||||||
|
const toks = await Promise.all(Array.from({ length: 6 }, (_, i) => cli.tokenNew(`shared ${i}`)));
|
||||||
|
await Promise.all(toks.flatMap((t) => [http.get(a.url(), { token: t }), http.get(b.url(), { token: t })]).map(async (p) => expectContainersResponse(await p)));
|
||||||
|
await cli.tokenDelete(toks[0]);
|
||||||
|
expect((await http.get(a.url(), { token: toks[0] })).status).toBe(401);
|
||||||
|
expect((await http.get(b.url(), { token: toks[0] })).status).toBe(401);
|
||||||
|
expect((await a.stop()).exitCode).toBe(0);
|
||||||
|
expect((await b.stop()).exitCode).toBe(0);
|
||||||
|
assertDbInvariants(ws.dbPath, { tokenCount: 6, operationCount: 12, auditBeforeDeletion: true });
|
||||||
|
});
|
||||||
+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),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLevelOrdering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ordered := []slog.Level{LevelTrace, slog.LevelDebug, slog.LevelInfo, slog.LevelWarn, slog.LevelError}
|
||||||
|
for i := 1; i < len(ordered); i++ {
|
||||||
|
if !(ordered[i-1] < ordered[i]) {
|
||||||
|
t.Fatalf("level %v must be below %v", ordered[i-1], ordered[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if LevelTrace != slog.Level(-8) {
|
||||||
|
t.Fatalf("LevelTrace = %d", LevelTrace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLogLevel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for name, want := range map[string]slog.Level{
|
||||||
|
"ERROR": slog.LevelError, "WARN": slog.LevelWarn, "INFO": slog.LevelInfo,
|
||||||
|
"DEBUG": slog.LevelDebug, "TRACE": LevelTrace, "trace": LevelTrace, "Debug": slog.LevelDebug,
|
||||||
|
} {
|
||||||
|
got, err := ParseLogLevel(name)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("ParseLogLevel(%q) = %v, %v", name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"", "WARNING", "TRACEE", "TRAC", "ınfo"} {
|
||||||
|
if _, err := ParseLogLevel(name); err == nil {
|
||||||
|
t.Errorf("ParseLogLevel(%q) should fail", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for level, want := range map[slog.Level]string{
|
||||||
|
slog.LevelError: "ERROR", slog.LevelWarn: "WARN", slog.LevelInfo: "INFO",
|
||||||
|
slog.LevelDebug: "DEBUG", LevelTrace: "TRACE", slog.Level(2): "INFO+2", slog.Level(-12): "DEBUG-8",
|
||||||
|
} {
|
||||||
|
if got := LevelName(level); got != want {
|
||||||
|
t.Errorf("LevelName(%d) = %q, want %q", level, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitAll writes one record per supported level
|
||||||
|
func emitAll(log *slog.Logger) {
|
||||||
|
ctx := context.Background()
|
||||||
|
trace(ctx, log, "trace-record")
|
||||||
|
log.Debug("debug-record")
|
||||||
|
log.Info("info-record")
|
||||||
|
log.Warn("warn-record")
|
||||||
|
log.Error("error-record")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
all := []string{"trace", "debug", "info", "warn", "error"}
|
||||||
|
levels := map[slog.Level]int{ // index of the lowest emitted record
|
||||||
|
LevelTrace: 0, slog.LevelDebug: 1, slog.LevelInfo: 2, slog.LevelWarn: 3, slog.LevelError: 4,
|
||||||
|
}
|
||||||
|
for level, lowest := range levels {
|
||||||
|
log, buf := newTestLogger(level)
|
||||||
|
emitAll(log)
|
||||||
|
out := buf.String()
|
||||||
|
for i, name := range all {
|
||||||
|
present := strings.Contains(out, "msg="+name+"-record")
|
||||||
|
if present != (i >= lowest) {
|
||||||
|
t.Errorf("level %s: record %s present=%v", LevelName(level), name, present)
|
||||||
|
}
|
||||||
|
if present && !strings.Contains(out, "level="+strings.ToUpper(name)+" msg="+name+"-record") {
|
||||||
|
t.Errorf("level %s: record %s has wrong level label:\n%s", LevelName(level), name, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newTestLogger(LevelTrace)
|
||||||
|
trace(context.Background(), log, "hello", "k", "v")
|
||||||
|
line := strings.TrimSuffix(buf.String(), "\n")
|
||||||
|
re := regexp.MustCompile(`^time=\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z level=TRACE msg=hello k=v$`)
|
||||||
|
if !re.MatchString(line) {
|
||||||
|
t.Fatalf("unexpected line %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerEmitsRequestMessageAtInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newTestLogger(slog.LevelInfo)
|
||||||
|
s := NewWebServer(log, &fakeLookup{}, &fakeAudit{}, &fakeDocker{})
|
||||||
|
rec := doRequest(s, "GET", "/nope", nil)
|
||||||
|
if rec.Code != 404 {
|
||||||
|
t.Fatalf("status %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(buf.String(), `level=INFO msg="this request has been made"`) {
|
||||||
|
t.Fatalf("missing request message:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(buf.String(), "level=DEBUG") || strings.Contains(buf.String(), "level=TRACE") {
|
||||||
|
t.Fatalf("lower-priority records leaked:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTraceFuncEmitsEntryAndExit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newTestLogger(LevelTrace)
|
||||||
|
func() {
|
||||||
|
defer traceFunc(context.Background(), log, "Example")()
|
||||||
|
log.Debug("inside")
|
||||||
|
}()
|
||||||
|
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
|
||||||
|
if len(lines) != 3 ||
|
||||||
|
!strings.Contains(lines[0], `level=TRACE msg="function entry" func=Example`) ||
|
||||||
|
!strings.Contains(lines[1], "msg=inside") ||
|
||||||
|
!strings.Contains(lines[2], `level=TRACE msg="function exit" func=Example`) {
|
||||||
|
t.Fatalf("unexpected output:\n%s", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing is emitted above TRACE
|
||||||
|
quiet, qbuf := newTestLogger(slog.LevelDebug)
|
||||||
|
traceFunc(context.Background(), quiet, "Example")()
|
||||||
|
if qbuf.String() != "" {
|
||||||
|
t.Fatalf("trace emitted at DEBUG: %q", qbuf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerUnusualFieldsDoNotPanic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, buf := newTestLogger(LevelTrace)
|
||||||
|
var nilPtr *int
|
||||||
|
log.Info("")
|
||||||
|
// Malformed argument lists are passed as slices on purpose (vet rejects them as literals)
|
||||||
|
log.Info("x", []any{""}...)
|
||||||
|
log.Info("x", "", "")
|
||||||
|
log.Info("x", []any{"odd"}...)
|
||||||
|
log.Info("x", []any{42, "non-string key"}...)
|
||||||
|
log.Info("x", "nil", nil, "nilptr", nilPtr)
|
||||||
|
log.Info("x", "level", "spoofed", "time", "not-a-time", "msg", "dup")
|
||||||
|
log.Info("x", "level", slog.LevelWarn)
|
||||||
|
log.Info("x", "nan", math.NaN(), "inf", math.Inf(1))
|
||||||
|
log.Info("x", "ctl", "a\nb\x00c\x1b[31m", "bad", string([]byte{0xff, 0xfe}))
|
||||||
|
log.Info("x", "unicode", "日本語 🚀", "long", strings.Repeat("y", 100000))
|
||||||
|
log.Info("x", slog.Group("g", "level", "inner"), slog.Any("map", map[string]any{"a": []int{1}}))
|
||||||
|
log.WithGroup("grp").Info("x", "level", "grouped", "time", time.Unix(0, 0))
|
||||||
|
log.Log(context.Background(), slog.Level(-100), "very low")
|
||||||
|
log.Log(context.Background(), slog.Level(100), "very high")
|
||||||
|
out := buf.String()
|
||||||
|
// 15 calls, of which the level -100 record is below TRACE and filtered
|
||||||
|
if c := strings.Count(out, "\n"); c != 14 {
|
||||||
|
t.Fatalf("expected 14 records, got %d:\n%s", c, out)
|
||||||
|
}
|
||||||
|
// Control characters are escaped, so one record is one line
|
||||||
|
if strings.Contains(out, "a\nb") {
|
||||||
|
t.Fatalf("raw newline leaked into log output")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "very low") || !strings.Contains(out, "level=ERROR+92") {
|
||||||
|
t.Fatalf("out-of-range levels not rendered:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "level=WARN") {
|
||||||
|
t.Fatalf("user level attr of type slog.Level not rendered by name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// sha256("abc") = ba7816bf...
|
||||||
|
if got := tokenID("abc"); got != "ba7816bf" {
|
||||||
|
t.Fatalf("tokenID(abc) = %q", got)
|
||||||
|
}
|
||||||
|
if tokenID("abc") != tokenID("abc") || tokenID("abc") == tokenID("abd") {
|
||||||
|
t.Fatal("tokenID must be stable and distinguish inputs")
|
||||||
|
}
|
||||||
|
if got := tokenID(""); len(got) != 8 {
|
||||||
|
t.Fatalf("tokenID('') = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASCIIEqualFold(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"Bearer", "bearer", true},
|
||||||
|
{"BEARER", "bearer", true},
|
||||||
|
{"bearer", "bearer", true},
|
||||||
|
{"bearer", "bearer ", false},
|
||||||
|
{"bearerx", "bearer", false},
|
||||||
|
{"bearer", "bearex", false},
|
||||||
|
{"", "", true},
|
||||||
|
{"ı", "i", false},
|
||||||
|
{"[", "{", false},
|
||||||
|
{"@", "`", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := asciiEqualFold(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("asciiEqualFold(%q, %q) = %v", c.a, c.b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// containersPath is the only application route
|
||||||
|
const containersPath = "/api/v1/containers"
|
||||||
|
|
||||||
|
// Routes registers the application routes
|
||||||
|
func (s *WebServer) Routes(r *mux.Router) {
|
||||||
|
r.HandleFunc(containersPath, s.handleContainers).MatcherFunc(noEncodedSlash).Methods(http.MethodGet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// noEncodedSlash rejects paths containing a percent-encoded slash: %2F is not
|
||||||
|
// equivalent to a path separator, so such a path is a different (unknown) route
|
||||||
|
func noEncodedSlash(r *http.Request, _ *mux.RouteMatch) bool {
|
||||||
|
return !strings.Contains(strings.ToLower(r.URL.EscapedPath()), "%2f")
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP server timeouts
|
||||||
|
const (
|
||||||
|
readHeaderTimeout = 10 * time.Second
|
||||||
|
readTimeout = 30 * time.Second
|
||||||
|
writeTimeout = 30 * time.Second
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultShutdownTimeout bounds the graceful shutdown
|
||||||
|
const defaultShutdownTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// WebServer holds the HTTP server and its dependencies
|
||||||
|
type WebServer struct {
|
||||||
|
HTTPServer *http.Server
|
||||||
|
log *slog.Logger
|
||||||
|
tokens TokenLookup
|
||||||
|
audit AuditRecorder
|
||||||
|
docker ContainerLister
|
||||||
|
encode func(v any) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebServer builds the server, its router and its middleware
|
||||||
|
func NewWebServer(log *slog.Logger, tokens TokenLookup, audit AuditRecorder, docker ContainerLister) *WebServer {
|
||||||
|
s := &WebServer{
|
||||||
|
log: log,
|
||||||
|
tokens: tokens,
|
||||||
|
audit: audit,
|
||||||
|
docker: docker,
|
||||||
|
encode: json.Marshal,
|
||||||
|
}
|
||||||
|
s.HTTPServer = &http.Server{
|
||||||
|
Handler: s.Handler(),
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the complete request handler: logging middleware around the router
|
||||||
|
func (s *WebServer) Handler() http.Handler {
|
||||||
|
defer traceFunc(context.Background(), s.log, "WebServer.Handler")()
|
||||||
|
|
||||||
|
// Create the router; paths are matched exactly (no cleaning redirects, no strict-slash redirects)
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.SkipClean(true)
|
||||||
|
r.NotFoundHandler = http.HandlerFunc(s.handleNotFound)
|
||||||
|
r.MethodNotAllowedHandler = http.HandlerFunc(s.handleMethodNotAllowed)
|
||||||
|
|
||||||
|
// Associate the various handlers (routes)
|
||||||
|
s.Routes(r)
|
||||||
|
s.log.Debug("routes registered", "route", http.MethodGet+" "+containersPath)
|
||||||
|
|
||||||
|
return s.logRequests(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve serves HTTP on ln until ctx is cancelled, then shuts down gracefully within shutdownTimeout
|
||||||
|
// It returns an error if the server stops unexpectedly or the graceful shutdown fails
|
||||||
|
func (s *WebServer) Serve(ctx context.Context, ln net.Listener, shutdownTimeout time.Duration) error {
|
||||||
|
defer traceFunc(ctx, s.log, "WebServer.Serve")()
|
||||||
|
|
||||||
|
// Start serving in the background
|
||||||
|
s.log.InfoContext(ctx, "server listening", "addr", ln.Addr().String(), "version", APP_VERSION, "commit", COMMIT_ID)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- s.HTTPServer.Serve(ln)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for either a server failure or the shutdown request
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return fmt.Errorf("http server: %w", err)
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
s.log.DebugContext(ctx, "shutdown requested, draining http server", "timeout", shutdownTimeout)
|
||||||
|
shCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.HTTPServer.Shutdown(shCtx); err != nil {
|
||||||
|
s.HTTPServer.Close()
|
||||||
|
return fmt.Errorf("http server shutdown: %w", err)
|
||||||
|
}
|
||||||
|
<-errCh
|
||||||
|
s.log.DebugContext(ctx, "http server shut down")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in new issue
Block a user