From b792ad256e7b28db3544464003b16afa80302a6e Mon Sep 17 00:00:00 2001 From: Bryan Joshua Pedini Date: Tue, 6 Oct 2026 10:52:25 +0200 Subject: [PATCH] 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. --- auth.go | 81 +++++ auth_test.go | 149 ++++++++ e2e/tests/host/audit.spec.ts | 145 ++++++++ e2e/tests/host/auth.spec.ts | 253 ++++++++++++++ e2e/tests/host/http-surface.spec.ts | 272 +++++++++++++++ e2e/tests/host/lifecycle.spec.ts | 99 ++++++ handlers.go | 163 +++++++++ handlers_test.go | 521 ++++++++++++++++++++++++++++ logger_test.go | 214 ++++++++++++ routes.go | 25 ++ server.go | 106 ++++++ 11 files changed, 2028 insertions(+) create mode 100644 auth.go create mode 100644 auth_test.go create mode 100644 e2e/tests/host/audit.spec.ts create mode 100644 e2e/tests/host/auth.spec.ts create mode 100644 e2e/tests/host/http-surface.spec.ts create mode 100644 e2e/tests/host/lifecycle.spec.ts create mode 100644 handlers.go create mode 100644 handlers_test.go create mode 100644 logger_test.go create mode 100644 routes.go create mode 100644 server.go diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..c5480ab --- /dev/null +++ b/auth.go @@ -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 " " 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 +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..aa0db2e --- /dev/null +++ b/auth_test.go @@ -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) + } + } +} diff --git a/e2e/tests/host/audit.spec.ts b/e2e/tests/host/audit.spec.ts new file mode 100644 index 0000000..6d1710f --- /dev/null +++ b/e2e/tests/host/audit.spec.ts @@ -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> = [ + { '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 }); +}); diff --git a/e2e/tests/host/auth.spec.ts b/e2e/tests/host/auth.spec.ts new file mode 100644 index 0000000..0045b04 --- /dev/null +++ b/e2e/tests/host/auth.spec.ts @@ -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): Promise { + 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, target = '/api/v1/containers'): Promise { + 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]> = [ + ['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]> = [ + ['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([]); +}); diff --git a/e2e/tests/host/http-surface.spec.ts b/e2e/tests/host/http-surface.spec.ts new file mode 100644 index 0000000..f065436 --- /dev/null +++ b/e2e/tests/host/http-surface.spec.ts @@ -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) { + 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 = { 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); +}); diff --git a/e2e/tests/host/lifecycle.spec.ts b/e2e/tests/host/lifecycle.spec.ts new file mode 100644 index 0000000..d483af5 --- /dev/null +++ b/e2e/tests/host/lifecycle.spec.ts @@ -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 }); +}); diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..95bfd70 --- /dev/null +++ b/handlers.go @@ -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), + ) + }) +} diff --git a/handlers_test.go b/handlers_test.go new file mode 100644 index 0000000..42df327 --- /dev/null +++ b/handlers_test.go @@ -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()) + } +} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..4cb6944 --- /dev/null +++ b/logger_test.go @@ -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) + } + } +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..78dd000 --- /dev/null +++ b/routes.go @@ -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") +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..b524679 --- /dev/null +++ b/server.go @@ -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 +}