From 26757a409f553879da24a1b18729a0a1c38b985d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Mon, 7 Sep 2026 01:30:00 +0300 Subject: [PATCH 1/3] deploy: prepare public homelab demo --- .dockerignore | 13 +++++++ .github/workflows/ci.yml | 25 ++++++++++++ Dockerfile | 50 ++++++++++++++++++++++++ README.md | 34 ++++++++++++++++ compose.yaml | 27 +++++++++++++ package.json | 1 + src/server.ts | 83 ++++++++++++++++++++++++++++++++++------ test/server.test.ts | 34 ++++++++++++++-- 8 files changed, 252 insertions(+), 15 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2df27da --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.DS_Store +.env +.env.* +node_modules +dist +coverage +*.log +docs +test +README.md +LICENSE diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db0ac74..6c22f73 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@v4 + - name: Build smoke image + uses: docker/build-push-action@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 5 --retry-connrefused --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..75e576a --- /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 + +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +COPY public ./public +RUN npm run build +RUN npm prune --omit=dev + +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..41ccfbd 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,40 @@ 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 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..63c29bd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,31 @@ 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; + } +} const assets = new Map([ ['/', { file: 'index.html', contentType: 'text/html; charset=utf-8' }], @@ -86,6 +111,7 @@ async function serveAsset( async function handleRequest( request: IncomingMessage, response: ServerResponse, + runCapacity: RunCapacity, ): Promise { const url = new URL(request.url ?? '/', 'http://localhost'); @@ -111,8 +137,21 @@ async function handleRequest( ) { throw new Error('fixture must be a string'); } - const result = await runProofGate(getFixture(body.fixture)); - sendJson(response, 200, result); + 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 result = await runProofGate(fixture); + sendJson(response, 200, result); + } finally { + runCapacity.release(); + } } catch (error) { sendJson(response, 400, { error: error instanceof Error ? error.message : 'invalid request', @@ -128,16 +167,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 +196,15 @@ function parsePort(value: string | undefined): number { return port; } +function parseMaximumConcurrentRuns(value: string | undefined): number { + if (!value) 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; +} + const entrypoint = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; @@ -157,9 +212,13 @@ 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 }); 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..42d1668 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'); @@ -74,4 +79,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 new Promise((resolve) => setTimeout(resolve, 10)); + 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); + }); }); From 44b84f3fba4c4ce06c5e99b3fb5e2933e20245db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Mon, 7 Sep 2026 01:38:56 +0300 Subject: [PATCH 2/3] fix: harden deployment checks --- .github/workflows/ci.yml | 6 +-- Dockerfile | 4 +- src/server.ts | 104 +++++++++++++++++++++++++-------------- 3 files changed, 73 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c22f73..2e3f08a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,9 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: docker/setup-buildx-action@v4 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 - name: Build smoke image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . load: true @@ -43,7 +43,7 @@ jobs: --cap-drop ALL --security-opt no-new-privileges:true --publish 4173:4173 proofgate:smoke - name: Verify health endpoint - run: curl --fail --retry 5 --retry-connrefused --retry-delay 1 http://127.0.0.1:4173/api/health + 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 index 75e576a..56f0f95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,13 +5,13 @@ FROM --platform=$BUILDPLATFORM node:26-trixie-slim AS build WORKDIR /build COPY package.json package-lock.json ./ -RUN npm ci +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 +RUN npm prune --omit=dev --ignore-scripts FROM node:26-trixie-slim AS runtime diff --git a/src/server.ts b/src/server.ts index 63c29bd..c01ec6b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -108,55 +108,60 @@ 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 fixture = getFixture(body.fixture); - if (!runCapacity.tryAcquire()) { - response.setHeader('Retry-After', '1'); - sendJson(response, 429, { - error: 'too many evaluations in progress', - }); - return; - } - - try { - const result = await runProofGate(fixture); - sendJson(response, 200, result); - } finally { - runCapacity.release(); - } - } catch (error) { - sendJson(response, 400, { - error: error instanceof Error ? error.message : 'invalid request', - }); + const result = await runProofGate(fixture); + sendJson(response, 200, result); + } 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' }); + return; + } + + if (request.method === 'POST' && url.pathname === '/api/runs') { + await handleRunRequest(request, response, runCapacity); return; } @@ -205,6 +210,32 @@ function parseMaximumConcurrentRuns(value: string | undefined): number { 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; @@ -216,6 +247,7 @@ if (import.meta.url === entrypoint) { 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}`); From b41188b392db407cbe5ea07e2b283820e0774042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jegors=20=C4=8Cemisovs?= Date: Mon, 7 Sep 2026 01:42:54 +0300 Subject: [PATCH 3/3] fix: address deployment review --- .dockerignore | 8 ++++++++ README.md | 3 ++- src/server.ts | 18 ++++++++++++++++-- test/server.test.ts | 12 +++++++++++- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 2df27da..0cad9bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,16 @@ .git .github .DS_Store +.npmrc +.cloudflared +.cloudflared/** .env .env.* +*.pem +*.key +*.db +*.sqlite +*.sqlite3 node_modules dist coverage diff --git a/README.md b/README.md index 41ccfbd..8c592d6 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,8 @@ Build and start the hardened, non-root container: ```bash docker compose up --build --detach -curl --fail http://127.0.0.1:4173/api/health +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 diff --git a/src/server.ts b/src/server.ts index c01ec6b..7808444 100644 --- a/src/server.ts +++ b/src/server.ts @@ -36,6 +36,16 @@ class RunCapacity { 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([ @@ -156,7 +166,11 @@ async function handleRequest( 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' }); + sendJson(response, 200, { + status: 'ok', + mode: 'synthetic-read-only', + ...runCapacity.snapshot(), + }); return; } @@ -202,7 +216,7 @@ function parsePort(value: string | undefined): number { } function parseMaximumConcurrentRuns(value: string | undefined): number { - if (!value) return defaultMaximumConcurrentRuns; + 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'); diff --git a/test/server.test.ts b/test/server.test.ts index 42d1668..028cfe3 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -33,6 +33,16 @@ async function startServer( 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(); @@ -88,7 +98,7 @@ describe('ProofGate Control Room server', () => { body: JSON.stringify({ fixture: 'ready' }), }); - await new Promise((resolve) => setTimeout(resolve, 10)); + await waitForActiveRuns(origin, 1); const rejectedRun = await fetch(`${origin}/api/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json' },