diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0cad9bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.github +.DS_Store +.npmrc +.cloudflared +.cloudflared/** +.env +.env.* +*.pem +*.key +*.db +*.sqlite +*.sqlite3 +node_modules +dist +coverage +*.log +docs +test +README.md +LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0ac74..2e3f08a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,28 @@ jobs: - run: npm ci - run: npm run check - run: npm run build + + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 + - name: Build smoke image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + with: + context: . + load: true + tags: proofgate:smoke + - name: Start smoke container + run: >- + docker run --detach --name proofgate-smoke + --read-only --tmpfs /tmp:size=16m,mode=1777 + --cap-drop ALL --security-opt no-new-privileges:true + --publish 4173:4173 proofgate:smoke + - name: Verify health endpoint + run: curl --fail --retry 10 --retry-all-errors --retry-delay 1 http://127.0.0.1:4173/api/health + - name: Stop smoke container + if: always() + run: docker rm --force proofgate-smoke diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56f0f95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1 + +FROM --platform=$BUILDPLATFORM node:26-trixie-slim AS build + +WORKDIR /build + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +COPY public ./public +RUN npm run build +RUN npm prune --omit=dev --ignore-scripts + +FROM node:26-trixie-slim AS runtime + +LABEL org.opencontainers.image.title="ProofGate" \ + org.opencontainers.image.description="Read-only release and experiment control room built with concurrent Mozaik agents" \ + org.opencontainers.image.url="https://github.com/fortemate/proofgate" \ + org.opencontainers.image.source="https://github.com/fortemate/proofgate" \ + org.opencontainers.image.documentation="https://github.com/fortemate/proofgate#readme" \ + org.opencontainers.image.vendor="Fortemate" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.authors="Jegors Čemisovs" \ + org.opencontainers.image.base.name="docker.io/library/node:26-trixie-slim" + +RUN groupadd --system --gid 10001 app \ + && useradd --system --uid 10001 --gid app app + +WORKDIR /app + +COPY --from=build --chown=app:app /build/package.json /build/package-lock.json ./ +COPY --from=build --chown=app:app /build/node_modules ./node_modules +COPY --from=build --chown=app:app /build/dist ./dist +COPY --from=build --chown=app:app /build/public ./public + +USER app + +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=4173 \ + MAX_CONCURRENT_RUNS=2 + +EXPOSE 4173 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));"] + +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index 7970568..8c592d6 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,41 @@ npm run demo -- blocked npm run demo -- failure ``` +## Public demo deployment + +The Control Room can run as a public, synthetic-only demo behind a +[named Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/). +The tunnel provides the public HTTPS hostname while the origin remains bound to +the homelab loopback interface; no router port needs to be opened. + +```mermaid +flowchart LR + J[Judge browser] -->|HTTPS| C[Cloudflare edge] + C -->|named Tunnel| T[cloudflared] + T -->|http://127.0.0.1:4173| P[ProofGate container] +``` + +Build and start the hardened, non-root container: + +```bash +docker compose up --build --detach +curl --fail --retry 10 --retry-all-errors --retry-delay 1 \ + http://127.0.0.1:4173/api/health +``` + +Create a **named** Cloudflare Tunnel route from the chosen public hostname to +`http://localhost:4173`. Keep the application publicly reachable for judges; +do not place Cloudflare Access authentication in front of the submitted demo. + +The container accepts at most two evaluations at once by default. Additional +requests receive `429 Too Many Requests` instead of building an unbounded +queue. Override `MAX_CONCURRENT_RUNS` only after testing the homelab capacity. + +> [!CAUTION] +> Do not provide `MOZAIK_API_KEY`, pairing credentials, production connectors, +> or private Fortemate data to the public deployment. Mozaik Cloud traces used +> for judging remain available in the recorded demo and screenshots. + Add `--json` to inspect the redacted event timeline, evidence contract, case digest, and three loop IDs: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2006159 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,27 @@ +services: + proofgate: + build: + context: . + image: proofgate:local + restart: unless-stopped + environment: + HOST: 0.0.0.0 + PORT: 4173 + MAX_CONCURRENT_RUNS: 2 + ports: + - '127.0.0.1:4173:4173' + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + mem_limit: 512m + cpus: 1.0 + pids_limit: 128 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: 10m + max-file: '3' diff --git a/package.json b/package.json index ed6e7aa..793f645 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "start": "tsx src/server.ts", + "start:prod": "node dist/server.js", "test": "vitest run", "typecheck": "tsc --noEmit" }, diff --git a/src/server.ts b/src/server.ts index 5414eda..7808444 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,41 @@ import { runProofGate } from './proofgate.js'; const publicDirectory = fileURLToPath(new URL('../public/', import.meta.url)); const maximumBodyBytes = 4_096; +const defaultMaximumConcurrentRuns = 2; + +export interface ControlRoomServerOptions { + readonly maximumConcurrentRuns?: number; +} + +class RunCapacity { + private activeRuns = 0; + + public constructor(private readonly maximumConcurrentRuns: number) { + if (!Number.isInteger(maximumConcurrentRuns) || maximumConcurrentRuns < 1) { + throw new Error('maximumConcurrentRuns must be a positive integer'); + } + } + + public tryAcquire(): boolean { + if (this.activeRuns >= this.maximumConcurrentRuns) return false; + this.activeRuns += 1; + return true; + } + + public release(): void { + this.activeRuns -= 1; + } + + public snapshot(): { + readonly activeRuns: number; + readonly maximumConcurrentRuns: number; + } { + return { + activeRuns: this.activeRuns, + maximumConcurrentRuns: this.maximumConcurrentRuns, + }; + } +} const assets = new Map([ ['/', { file: 'index.html', contentType: 'text/html; charset=utf-8' }], @@ -83,41 +118,64 @@ async function serveAsset( return true; } -async function handleRequest( +async function handleRunRequest( request: IncomingMessage, response: ServerResponse, + runCapacity: RunCapacity, ): Promise { - const url = new URL(request.url ?? '/', 'http://localhost'); - - if (request.method === 'GET' && url.pathname === '/api/health') { - sendJson(response, 200, { status: 'ok', mode: 'synthetic-read-only' }); + if (!request.headers['content-type']?.startsWith('application/json')) { + sendJson(response, 415, { + error: 'content-type must be application/json', + }); return; } - if (request.method === 'POST' && url.pathname === '/api/runs') { - if (!request.headers['content-type']?.startsWith('application/json')) { - sendJson(response, 415, { - error: 'content-type must be application/json', + try { + const body = (await readJsonBody(request)) as { fixture?: unknown }; + if (!body || typeof body !== 'object' || typeof body.fixture !== 'string') { + throw new Error('fixture must be a string'); + } + + const fixture = getFixture(body.fixture); + if (!runCapacity.tryAcquire()) { + response.setHeader('Retry-After', '1'); + sendJson(response, 429, { + error: 'too many evaluations in progress', }); return; } try { - const body = (await readJsonBody(request)) as { fixture?: unknown }; - if ( - !body || - typeof body !== 'object' || - typeof body.fixture !== 'string' - ) { - throw new Error('fixture must be a string'); - } - const result = await runProofGate(getFixture(body.fixture)); + const result = await runProofGate(fixture); sendJson(response, 200, result); - } catch (error) { - sendJson(response, 400, { - error: error instanceof Error ? error.message : 'invalid request', - }); + } finally { + runCapacity.release(); } + } catch (error) { + sendJson(response, 400, { + error: error instanceof Error ? error.message : 'invalid request', + }); + } +} + +async function handleRequest( + request: IncomingMessage, + response: ServerResponse, + runCapacity: RunCapacity, +): Promise { + const url = new URL(request.url ?? '/', 'http://localhost'); + + if (request.method === 'GET' && url.pathname === '/api/health') { + sendJson(response, 200, { + status: 'ok', + mode: 'synthetic-read-only', + ...runCapacity.snapshot(), + }); + return; + } + + if (request.method === 'POST' && url.pathname === '/api/runs') { + await handleRunRequest(request, response, runCapacity); return; } @@ -128,16 +186,23 @@ async function handleRequest( sendJson(response, 404, { error: 'not found' }); } -export function createControlRoomServer() { +export function createControlRoomServer( + options: ControlRoomServerOptions = {}, +) { + const runCapacity = new RunCapacity( + options.maximumConcurrentRuns ?? defaultMaximumConcurrentRuns, + ); return createServer((request, response) => { - void handleRequest(request, response).catch((error: unknown) => { - console.error(error); - if (!response.headersSent) { - sendJson(response, 500, { error: 'internal server error' }); - } else { - response.destroy(); - } - }); + void handleRequest(request, response, runCapacity).catch( + (error: unknown) => { + console.error(error); + if (!response.headersSent) { + sendJson(response, 500, { error: 'internal server error' }); + } else { + response.destroy(); + } + }, + ); }); } @@ -150,6 +215,41 @@ function parsePort(value: string | undefined): number { return port; } +function parseMaximumConcurrentRuns(value: string | undefined): number { + if (value === undefined) return defaultMaximumConcurrentRuns; + const maximumConcurrentRuns = Number(value); + if (!Number.isInteger(maximumConcurrentRuns) || maximumConcurrentRuns < 1) { + throw new Error('MAX_CONCURRENT_RUNS must be a positive integer'); + } + return maximumConcurrentRuns; +} + +function installShutdownHandlers( + server: ReturnType, +): void { + let shuttingDown = false; + const shutdown = (signal: NodeJS.Signals): void => { + if (shuttingDown) return; + shuttingDown = true; + console.log(`Received ${signal}; closing the Control Room.`); + + const deadline = setTimeout(() => { + console.error('Graceful shutdown timed out.'); + process.exit(1); + }, 5_000); + deadline.unref(); + + server.close((error) => { + clearTimeout(deadline); + if (error) console.error(error); + process.exit(error ? 1 : 0); + }); + }; + + process.once('SIGTERM', () => shutdown('SIGTERM')); + process.once('SIGINT', () => shutdown('SIGINT')); +} + const entrypoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; @@ -157,9 +257,14 @@ const entrypoint = process.argv[1] if (import.meta.url === entrypoint) { const host = process.env.HOST ?? '127.0.0.1'; const port = parsePort(process.env.PORT); - const server = createControlRoomServer(); + const maximumConcurrentRuns = parseMaximumConcurrentRuns( + process.env.MAX_CONCURRENT_RUNS, + ); + const server = createControlRoomServer({ maximumConcurrentRuns }); + installShutdownHandlers(server); server.listen(port, host, () => { console.log(`ProofGate Control Room: http://${host}:${port}`); + console.log(`Concurrent evaluation limit: ${maximumConcurrentRuns}`); console.log( 'Synthetic evidence only. No production actions are available.', ); diff --git a/test/server.test.ts b/test/server.test.ts index 9a605df..028cfe3 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -2,7 +2,10 @@ import { once } from 'node:events'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; -import { createControlRoomServer } from '../src/server.js'; +import { + createControlRoomServer, + type ControlRoomServerOptions, +} from '../src/server.js'; process.env.MOZAIK_API_KEY = ''; @@ -19,8 +22,10 @@ afterEach(async () => { ); }); -async function startServer(): Promise { - const server = createControlRoomServer(); +async function startServer( + options: ControlRoomServerOptions = {}, +): Promise { + const server = createControlRoomServer(options); servers.push(server); server.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -28,6 +33,16 @@ async function startServer(): Promise { return `http://127.0.0.1:${address.port}`; } +async function waitForActiveRuns(origin: string, expected: number) { + for (let attempt = 0; attempt < 50; attempt += 1) { + const response = await fetch(`${origin}/api/health`); + const health = (await response.json()) as { activeRuns: number }; + if (health.activeRuns === expected) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error(`activeRuns did not reach ${expected}`); +} + describe('ProofGate Control Room server', () => { it('serves the control room with restrictive browser headers', async () => { const origin = await startServer(); @@ -74,4 +89,27 @@ describe('ProofGate Control Room server', () => { 'Unknown fixture "toString". Choose one of: ready, blocked, failure.', }); }); + + it('rejects excess concurrent evaluations without queueing them', async () => { + const origin = await startServer({ maximumConcurrentRuns: 1 }); + const firstRun = fetch(`${origin}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fixture: 'ready' }), + }); + + await waitForActiveRuns(origin, 1); + const rejectedRun = await fetch(`${origin}/api/runs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fixture: 'blocked' }), + }); + + expect(rejectedRun.status).toBe(429); + expect(rejectedRun.headers.get('retry-after')).toBe('1'); + expect(await rejectedRun.json()).toEqual({ + error: 'too many evaluations in progress', + }); + expect((await firstRun).status).toBe(200); + }); });