diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3c592822 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Allow only build inputs. Never send saved games, credentials or Git history. +** +!package.json +!package-lock.json +!tsconfig.json +!next.config.ts +!vite.config.ts +!.oxlintrc.json +!components.json +!drizzle.config.ts +!README.md +!AGENTS.md +!THIRD_PARTY_NOTICES.md +!docs/** +!.openai/ +!.openai/hosting.json +!app/** +!components/** +!db/** +!drizzle/** +!game/** +!hooks/** +!lib/** +!public/** +!tests/** +!tools/** +!deploy/container-entrypoint.sh +!deploy/serve.mjs +**/*.orig +**/*.rej +**/.env* +**/.dev.vars* +**/*.sqlite* +**/*.pem diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 00000000..a82842ab --- /dev/null +++ b/.github/workflows/container.yml @@ -0,0 +1,54 @@ +name: Verify and publish checkpoint container + +on: + push: + branches: [main, 'codex/**'] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: dune-checkpoint-container + cancel-in-progress: false + +jobs: + container: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - run: npm ci + - uses: docker/setup-buildx-action@v3 + - name: Build and run offline checks + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: dune:verify + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Verify isolated storage and HTTP behavior + run: node tools/verify-container.mjs dune:verify + - uses: docker/login-action@v3 + if: github.ref == 'refs/heads/main' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish the verified image + if: github.ref == 'refs/heads/main' + env: + REPOSITORY: ${{ github.repository }} + REVISION: ${{ github.sha }} + run: | + image="ghcr.io/${REPOSITORY,,}" + docker tag dune:verify "$image:sha-$REVISION" + docker tag dune:verify "$image:checkpoint" + docker push "$image:sha-$REVISION" + docker push "$image:checkpoint" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..7565e209 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +ENV CI=true WRANGLER_SEND_METRICS=false +RUN apt-get update && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run check && npm run build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production CI=true WRANGLER_SEND_METRICS=false \ + WRANGLER_WRITE_LOGS=false MINIFLARE_REGISTRY_PATH=/tmp/dune-registry +LABEL org.opencontainers.image.source="https://github.com/Quintine/Dune" +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/drizzle ./drizzle +COPY package.json ./ +COPY tools/wrangler.local.json ./tools/wrangler.local.json +COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh +COPY deploy/serve.mjs ./deploy/serve.mjs +RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/', {signal: AbortSignal.timeout(8000)}).then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" +ENTRYPOINT ["sh", "/app/deploy/container-entrypoint.sh"] diff --git a/README.md b/README.md index f4519f4b..e3f78a3b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ room; lesson progress stays in your browser and does not change saved games. Local games persist in `.wrangler/state`. Keep this directory across restarts. Apply new additive migrations before running code that depends on them. Never delete the local database to resolve a connection or rules problem. `.openai/hosting.json` belongs to the existing Sites project; do not recreate the hosting project. +For private NAS hosting and manually applied checkpoint image updates, see the +[TrueNAS container guide](docs/TRUENAS.md). Its separate persistent storage starts +with new games; the requested host port is 33046. + ## Saved seats and reconnecting The room cookie identifies your seat. Refreshing or reopening the invitation with that cookie restores your private hand and pending decisions. A failed restoration offers an explicit retry. Requests time out after 15 seconds; uncertain game actions are reconciled with a read rather than automatically repeated. diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts index 58d070ef..5a3657d6 100644 --- a/app/api/rooms/[code]/control/route.ts +++ b/app/api/rooms/[code]/control/route.ts @@ -1,3 +1,4 @@ +import { requestOrigin } from '@/lib/request-origin'; import { authenticate, createSeatHandover, @@ -14,12 +15,12 @@ import { import { RuleError } from '@/game/engine'; import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation'; import { resumeRoom } from '@/db/room-continuation'; -import { waitUntil } from 'cloudflare:workers'; +import { env, waitUntil } from 'cloudflare:workers'; const noStore = { 'Cache-Control': 'no-store' }; export async function POST(req: Request) { try { const url = new URL(req.url); - if (req.headers.get('origin') && req.headers.get('origin') !== url.origin) + if (req.headers.get('origin') && req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN)) throw new SeatControlError( 'Invalid request origin.', 'INVALID_ORIGIN', @@ -158,7 +159,7 @@ export async function POST(req: Request) { { headers: { ...noStore, - 'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${url.protocol === 'https:' ? '; Secure' : ''}`, + 'Set-Cookie': `dune_${code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`, }, }, ); diff --git a/app/api/rooms/[code]/messages/route.ts b/app/api/rooms/[code]/messages/route.ts index 93c1a9b3..0e7ac1b2 100644 --- a/app/api/rooms/[code]/messages/route.ts +++ b/app/api/rooms/[code]/messages/route.ts @@ -1,3 +1,5 @@ +import { env } from 'cloudflare:workers'; +import { requestOrigin } from '@/lib/request-origin'; import { authenticate } from '@/db/rooms'; import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk'; import { RuleError } from '@/game/engine'; @@ -8,7 +10,7 @@ async function handle(req: Request, write: boolean) { if ( write && req.headers.get('origin') && - req.headers.get('origin') !== url.origin + req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN) ) throw new TableTalkError('Invalid request origin.', 403); const code = url.pathname.split('/').at(-2)!.toUpperCase(); diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts index 765b5d65..100e1dbb 100644 --- a/app/api/rooms/[code]/route.ts +++ b/app/api/rooms/[code]/route.ts @@ -1,3 +1,4 @@ +import { requestOrigin } from '@/lib/request-origin'; import { readSeatView, authenticate, @@ -9,7 +10,7 @@ import { } from '@/db/rooms'; import { RuleError } from '@/game/engine'; import type { FactionId } from '@/game/catalog'; -import { waitUntil } from 'cloudflare:workers'; +import { env, waitUntil } from 'cloudflare:workers'; import { resumeRoom } from '@/db/room-continuation'; const codeOf = (req: Request) => { @@ -68,7 +69,7 @@ export async function POST(req: Request) { try { if ( req.headers.get('origin') && - req.headers.get('origin') !== new URL(req.url).origin + req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN) ) return Response.json( { error: 'Invalid request origin.' }, @@ -115,7 +116,7 @@ export async function POST(req: Request) { headers: { ...(r.token ? { - 'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`, + 'Set-Cookie': `dune_${code}=${r.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`, } : {}), 'Cache-Control': 'no-store', diff --git a/app/api/rooms/route.ts b/app/api/rooms/route.ts index 417092eb..0f692243 100644 --- a/app/api/rooms/route.ts +++ b/app/api/rooms/route.ts @@ -1,3 +1,5 @@ +import { env } from 'cloudflare:workers'; +import { requestOrigin } from '@/lib/request-origin'; import { createRoom, RoomEntryError } from '@/db/rooms'; import { RuleError } from '@/game/engine'; import type { FactionId } from '@/game/catalog'; @@ -5,7 +7,7 @@ export async function POST(req: Request) { try { if ( req.headers.get('origin') && - req.headers.get('origin') !== new URL(req.url).origin + req.headers.get('origin') !== requestOrigin(req, env.DUNE_PUBLIC_ORIGIN) ) return Response.json( { error: 'Invalid request origin.' }, @@ -40,7 +42,7 @@ export async function POST(req: Request) { { status: 201, headers: { - 'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${new URL(req.url).protocol === 'https:' ? '; Secure' : ''}`, + 'Set-Cookie': `dune_${result.view.code}=${result.token}; HttpOnly; SameSite=Strict; Path=/api/rooms/${result.view.code}; Max-Age=2592000${requestOrigin(req, env.DUNE_PUBLIC_ORIGIN).startsWith('https:') ? '; Secure' : ''}`, 'Cache-Control': 'no-store', }, }, diff --git a/components/seat-ai-delegation.tsx b/components/seat-ai-delegation.tsx index ab82eefd..e2301394 100644 --- a/components/seat-ai-delegation.tsx +++ b/components/seat-ai-delegation.tsx @@ -1,4 +1,5 @@ 'use client'; +import { randomId } from '@/lib/random-id'; import { useEffect, useId, useRef, useState } from 'react'; import type { GameView } from '@/game/engine'; @@ -300,7 +301,7 @@ export function SeatAiDelegation({ void submit({ type: 'setSeatAiDelegate', version: game.version, - grantId: crypto.randomUUID(), + grantId: randomId(), delegateId, difficulty, }) diff --git a/components/table-talk.tsx b/components/table-talk.tsx index 84b6f9b7..be285430 100644 --- a/components/table-talk.tsx +++ b/components/table-talk.tsx @@ -1,4 +1,5 @@ 'use client'; +import { randomId } from '@/lib/random-id'; import { useEffect, useId, useRef, useState } from 'react'; import type { GameView } from '@/game/engine'; import { faction } from '@/game/catalog'; @@ -152,7 +153,7 @@ export function TableTalk({ game }: { game: GameView }) { async function submit() { if (sending.current || !ready) return; const draft = record ?? { - id: crypto.randomUUID(), + id: randomId(), recipientId: recipient || null, text, }; diff --git a/db/env.d.ts b/db/env.d.ts index ec8799b9..5131ecf3 100644 --- a/db/env.d.ts +++ b/db/env.d.ts @@ -1,5 +1,6 @@ declare namespace Cloudflare { interface Env { DB: D1Database; + DUNE_PUBLIC_ORIGIN?: string; } } diff --git a/deploy/container-entrypoint.sh b/deploy/container-entrypoint.sh new file mode 100644 index 00000000..f6af6748 --- /dev/null +++ b/deploy/container-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# A failed migration must stop startup. Both commands use the same local D1 ID +# and storage root. Never reset or replace this directory during updates. +state_path=${DUNE_STATE_PATH:-/data} +# Validate before migration; this is an administrator setting, not a request header. +node --input-type=module -e ' + const value = process.env.DUNE_PUBLIC_ORIGIN; + if (value) { + const u = new URL(value); + if (!["http:", "https:"].includes(u.protocol) || u.username || u.password || + u.search || u.hash || u.pathname !== "/") throw new Error("Invalid DUNE_PUBLIC_ORIGIN"); + } +' +node node_modules/wrangler/bin/wrangler.js d1 migrations apply DB \ + --local --config tools/wrangler.local.json --persist-to "$state_path" +exec node deploy/serve.mjs diff --git a/deploy/serve.mjs b/deploy/serve.mjs new file mode 100644 index 00000000..d9ec988f --- /dev/null +++ b/deploy/serve.mjs @@ -0,0 +1,71 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { Miniflare } from 'miniflare'; + +const server = resolve('dist/server'); +const config = JSON.parse(await readFile(resolve(server, 'wrangler.json'), 'utf8')); +const migrations = JSON.parse(await readFile('tools/wrangler.local.json', 'utf8')); +const migrationIds = new Map(migrations.d1_databases.map((db) => [db.binding, db.database_id])); +for (const db of config.d1_databases) { + if (migrationIds.get(db.binding) !== db.database_id) { + throw new Error(`Migration/runtime database identity mismatch for ${db.binding}`); + } +} +const state = resolve(process.env.DUNE_STATE_PATH || '/data'); +const port = Number(process.env.DUNE_PORT || 3000); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid DUNE_PORT'); +async function modulesIn(directory) { + const modules = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) modules.push(...await modulesIn(path)); + else if (/\.m?js$/.test(entry.name)) modules.push({ type: 'ESModule', path }); + } + return modules; +} +const entrypoint = resolve(server, config.main); +// Vinext contains computed imports. Register the complete emitted module graph +// explicitly instead of asking Miniflare's static import scanner to infer it. +const modules = [ + { type: 'ESModule', path: entrypoint }, + ...(await modulesIn(server)).filter((module) => module.path !== entrypoint), +]; + +// Serve the immutable build directly. Wrangler's development proxy can fail a +// subsequent POST after Vinext cancels a rejected request body with assets on. +const runtime = new Miniflare({ + name: config.name, + host: '0.0.0.0', + port, + cf: false, + modules, + modulesRoot: server, + compatibilityDate: config.compatibility_date, + compatibilityFlags: config.compatibility_flags, + bindings: { ...config.vars, DUNE_PUBLIC_ORIGIN: process.env.DUNE_PUBLIC_ORIGIN || '' }, + d1Databases: Object.fromEntries(config.d1_databases.map((db) => [db.binding, db.database_id])), + // Match Wrangler's --persist-to layout, including its local migration ledger. + d1Persist: resolve(state, 'v3/d1'), + cachePersist: resolve(state, 'v3/cache'), + assets: { + directory: resolve(server, config.assets.directory), + binding: 'ASSETS', + routerConfig: { has_user_worker: true }, + }, +}); + +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + await runtime.dispose(); +} +process.on('SIGTERM', () => { void stop(); }); +process.on('SIGINT', () => { void stop(); }); +try { + await runtime.ready; + console.log(`Dune listening on HTTP port ${port}`); +} catch (error) { + await stop(); + throw error; +} diff --git a/deploy/truenas.compose.yaml b/deploy/truenas.compose.yaml new file mode 100644 index 00000000..d2ece2da --- /dev/null +++ b/deploy/truenas.compose.yaml @@ -0,0 +1,19 @@ +services: + dune: + image: ghcr.io/quintine/dune:checkpoint + init: true + restart: unless-stopped + environment: + DUNE_PUBLIC_ORIGIN: https://dune.procrastination.games + ports: + - "33046:3000" + volumes: + - dune-data:/data + stop_grace_period: 60s + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + +volumes: + dune-data: diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md new file mode 100644 index 00000000..0254f19e --- /dev/null +++ b/docs/TRUENAS.md @@ -0,0 +1,107 @@ +# Private TrueNAS container + +The requested instance starts with **new games**, serving HTTP at +`http://192.168.2.251:33046` behind the existing NGINX HTTPS endpoint +`https://dune.procrastination.games`. Development games remain in their original +`.wrangler/state`; they are never copied into an image. This private local +runtime prototype does not change public release or rules-mode gates. + +## Checkpoint images + +Every push to `main` starts `.github/workflows/container.yml`. The Docker build +runs types, lint, offline tests and the production build using the lockfile. +Before publishing, CI creates an isolated container and volume, checks accepted +and rejected HTTP origins, verifies saved-seat restoration through restart and +container replacement, then runs the HTTP integration suite. + +Only a passing image is pushed to `ghcr.io/quintine/dune:checkpoint`, alongside +`ghcr.io/quintine/dune:sha-`. GitHub Actions builds; TrueNAS pulls the +image. Failed checks leave the previous update tag untouched. This workflow +targets Linux AMD64, the architecture of ordinary x86 TrueNAS systems. + +The user selected a **public image**, matching the existing public source +repository, so TrueNAS needs no registry login. GitHub initially creates packages +privately; after the first successful publication, set this package's visibility +to public in GitHub package settings. Images contain application code and assets, +never saved games or credentials. Publishing uses the workflow's built-in token. +A source push alone does not prove image publication; repository permissions or +package policy may prevent the workflow from publishing. + +## Install + +On a Docker-based TrueNAS release, use **Apps → Discover Apps → Install via YAML** +and paste [the Compose configuration](../deploy/truenas.compose.yaml), naming the +app `dune`. It maps host port **33046** to container port 3000. A persistent named +volume mounts at `/data`, starts empty and inherits writable ownership from the +image. The container runs as UID/GID 1000. If using a dedicated host dataset +instead, mount it at `/data` and give UID/GID 1000 write access before starting. + +Startup applies pending additive migrations before serving. Both commands use +the same database ID and storage root. A failed migration stops startup without +deleting games. The inspector remains on container loopback and is not published. + +The container serves the built Worker directly through pinned Miniflare/workerd, +with persisted D1-compatible SQLite. Wrangler only applies startup migrations. +The development proxy produced intermittent POST failures after rejected request +bodies; the direct listener avoids that proxy. This preserves the application without a database +rewrite. It does not supply managed D1 backups, replication, high availability +or durable unattended bot scheduling. Keep this prototype on a trusted LAN; +public hosting remains separately gated. HTTP does not encrypt cookies or seat +recovery kits. Browser operation IDs now use cryptographic `getRandomValues`, +which also works at LAN HTTP origins where `randomUUID` is unavailable. + +## Update and preserve games + +### Existing NGINX HTTPS termination + +Keep the NGINX upstream **HTTP**, `192.168.2.251:33046`. Set the app environment +variable `DUNE_PUBLIC_ORIGIN` to the exact external HTTPS origin (scheme and +hostname, optional port, no path). This keeps strict origin validation and marks +seat cookies Secure, even though the proxy-to-app connection is HTTP. Dune does +not blindly trust client-supplied forwarding headers. When configured, use the +HTTPS address to play; direct HTTP browser POSTs are intentionally rejected. +The supplied Compose file already sets `https://dune.procrastination.games`. + +Keep NGINX's usual original Host and forwarded-protocol headers. Enable +WebSocket support in NGINX Proxy Manager, or forward Upgrade/Connection headers +with HTTP/1.1 in an existing NGINX location. Dune currently uses HTTP polling, +not game WebSockets; enabling proxy support does not change its transport. +See [NGINX WebSocket forwarding](https://nginx.org/en/docs/http/websocket.html). + +### Applying an update + +1. Wait for the checkpoint workflow to succeed. Check for image updates in + TrueNAS and apply the update manually at a safe point between decisions. +2. Before updating, stop the app and back up or snapshot its persistent storage. + Include the named volume in the Apps backup procedure; a dedicated host + dataset provides a more explicit ZFS snapshot boundary. +3. Update while retaining the same app identity and `/data` volume. Never delete + the volume, reinstall with empty storage or reset a database to resolve an + error. Restart and confirm the saved seat restores. +4. Retain the prior immutable image tag. Rollback may require its matching storage + snapshot if a subsequent migration is incompatible; switching the image alone + is not a guaranteed database rollback. + +No unattended updater is installed. The health probe checks HTTP availability; +the CI integration and saved-seat checks separately exercise persistence. + +## Verification and installation boundary + +On a machine with Docker: + +```sh +docker build -t dune:verify . +node tools/verify-container.mjs dune:verify +``` + +The verifier only removes containers and volumes with names it randomly creates. +It never mounts development games or an existing TrueNAS volume. + +Container execution and NAS installation require a successful CI run and an +authenticated TrueNAS session. Final acceptance includes create/join at the NAS +URL, saved-seat restoration, restart and update retaining storage. Source checks +alone do not certify these steps. + +Sources: [TrueNAS custom apps](https://apps.truenas.com/managing-apps/installing-custom-apps/), +[GitHub container registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), +[Cloudflare local runtime](https://developers.cloudflare.com/workers/local-development/). diff --git a/lib/random-id.ts b/lib/random-id.ts new file mode 100644 index 00000000..59a5bfc3 --- /dev/null +++ b/lib/random-id.ts @@ -0,0 +1,15 @@ +/** UUID v4 for browser operations, including private LAN HTTP origins. */ +export function randomId(): string { + // Unlike randomUUID(), getRandomValues is available outside secure contexts. + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')); + return [ + hex.slice(0, 4).join(''), + hex.slice(4, 6).join(''), + hex.slice(6, 8).join(''), + hex.slice(8, 10).join(''), + hex.slice(10).join(''), + ].join('-'); +} diff --git a/lib/request-origin.ts b/lib/request-origin.ts new file mode 100644 index 00000000..17726ff7 --- /dev/null +++ b/lib/request-origin.ts @@ -0,0 +1,10 @@ +/** The administrator configures the external origin; forwarded headers are untrusted. */ +export function requestOrigin(request: Request, configured?: string): string { + if (!configured) return new URL(request.url).origin; + const url = new URL(configured); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || url.password || url.search || url.hash || url.pathname !== '/' + ) throw new Error('DUNE_PUBLIC_ORIGIN must be an HTTP(S) origin without a path.'); + return url.origin; +} diff --git a/lib/room-entry.ts b/lib/room-entry.ts index 13afbcd5..5008f7b6 100644 --- a/lib/room-entry.ts +++ b/lib/room-entry.ts @@ -1,3 +1,4 @@ +import { randomId } from './random-id'; import { EXPANSIONS, FACTIONS } from '../game/catalog'; import { requestMayHaveCompleted } from './client-request'; @@ -108,7 +109,7 @@ export function createRoomEntry( roomCode?: string, ): RoomEntryAttempt { const entry = { - operationId: globalThis.crypto.randomUUID(), + operationId: randomId(), sessionToken: Array.from( globalThis.crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, '0'), diff --git a/lib/seat-handover.ts b/lib/seat-handover.ts index c90bce00..a65e81b1 100644 --- a/lib/seat-handover.ts +++ b/lib/seat-handover.ts @@ -1,3 +1,4 @@ +import { randomId } from './random-id'; import type { GameView } from '../game/engine'; export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1'; @@ -98,7 +99,7 @@ export function createHandoverKit( version: 1, roomCode, playerId, - offerId: crypto.randomUUID(), + offerId: randomId(), handoverSecret: secret(), }), ); @@ -111,7 +112,7 @@ export function createHandoverClaim(kit: SeatHandoverKit): SavedHandoverClaim { playerId: kit.playerId, offerId: kit.offerId, handoverSecret: kit.handoverSecret, - operationId: crypto.randomUUID(), + operationId: randomId(), newSessionToken: secret(), }), }); diff --git a/lib/seat-recovery.ts b/lib/seat-recovery.ts index 9fd599dc..edf31246 100644 --- a/lib/seat-recovery.ts +++ b/lib/seat-recovery.ts @@ -1,3 +1,4 @@ +import { randomId } from './random-id'; export type SeatRecoveryKit = Readonly<{ format: 'dune-seat-recovery'; version: 1; @@ -106,7 +107,7 @@ export function createRecoveryAttempt( type: 'recoverSeat', playerId: kit.playerId, recoverySecret: kit.recoverySecret, - operationId: globalThis.crypto.randomUUID(), + operationId: randomId(), newSessionToken: secret256(), }); } diff --git a/package-lock.json b/package-lock.json index 8689e6ec..95b0c92a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@vitejs/plugin-react": "6.0.2", "@vitejs/plugin-rsc": "0.5.26", "drizzle-kit": "0.31.10", + "miniflare": "4.20260515.0", "oxfmt": "0.61.0", "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", diff --git a/package.json b/package.json index 879c6929..a221019c 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "tailwindcss": "4.2.1", "typescript": "5.9.3", "vite": "8.0.13", + "miniflare": "4.20260515.0", "wrangler": "4.92.0", "drizzle-kit": "0.31.10", "tsx": "4.23.13" diff --git a/tests/automatic-response-recovery.test.ts b/tests/automatic-response-recovery.test.ts index 46e249f9..0d1670e8 100644 --- a/tests/automatic-response-recovery.test.ts +++ b/tests/automatic-response-recovery.test.ts @@ -1,3 +1,4 @@ +import { requestOrigin } from '../lib/request-origin'; import test from 'node:test'; import assert from 'node:assert/strict'; import { webcrypto } from 'node:crypto'; @@ -82,6 +83,7 @@ function unitStore(runBots = bots.runBots) { require: (name: string) => { if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === '@/game/engine') return engine; + if (name === '@/lib/request-origin') return { requestOrigin }; if (name === '@/game/bots') return { ...bots, runBots }; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; throw new Error('Unexpected module ' + name); @@ -300,8 +302,9 @@ function route( }; if (name === '@/db/room-continuation') return continuation; if (name === '@/game/engine') return engine; + if (name === '@/lib/request-origin') return { requestOrigin }; if (name === 'cloudflare:workers') - return { waitUntil: (work: Promise) => pending.push(work) }; + return { env: {}, waitUntil: (work: Promise) => pending.push(work) }; throw new Error('Unexpected route dependency ' + name); }, }, diff --git a/tests/bot-pacing.test.ts b/tests/bot-pacing.test.ts index 58ebb6a0..466eb1fe 100644 --- a/tests/bot-pacing.test.ts +++ b/tests/bot-pacing.test.ts @@ -1,3 +1,4 @@ +import { requestOrigin } from '../lib/request-origin'; import test from 'node:test'; import assert from 'node:assert/strict'; import { webcrypto } from 'node:crypto'; @@ -81,6 +82,7 @@ function unitStore(runBots = bots.runBots) { require: (name: string) => { if (name === 'cloudflare:workers') return { env: { DB: database } }; if (name === '@/game/engine') return engine; + if (name === '@/lib/request-origin') return { requestOrigin }; if (name === '@/game/bots') return { ...bots, runBots }; if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; throw new Error('Unexpected module ' + name); @@ -359,8 +361,10 @@ void test('authenticated GET schedules the persisted queue after reconnect while }; if (name === '@/db/room-continuation') return continuation; if (name === '@/game/engine') return engine; + if (name === '@/lib/request-origin') return { requestOrigin }; if (name === 'cloudflare:workers') return { + env: {}, waitUntil: (work: Promise) => pending.push(work), }; throw new Error('Unexpected route dependency ' + name); diff --git a/tests/random-id.test.ts b/tests/random-id.test.ts new file mode 100644 index 00000000..375d5964 --- /dev/null +++ b/tests/random-id.test.ts @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomId } from '../lib/random-id'; +import { createRoomEntry } from '../lib/room-entry'; + +void test('LAN HTTP operations work with getRandomValues and no randomUUID', (t) => { + const native = globalThis.crypto; + t.mock.getter(globalThis, 'crypto', () => ({ + getRandomValues: native.getRandomValues.bind(native), + }) as Crypto); + assert.equal(typeof globalThis.crypto.randomUUID, 'undefined'); + const id = randomId(); + assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.notEqual(randomId(), id); + const attempt = createRoomEntry({ + name: 'LAN player', faction: 'atreides', advanced: false, expansions: [], + }); + assert.ok(attempt); +}); + +void test('UUID creation fails closed when cryptographic randomness fails', (t) => { + t.mock.getter(globalThis, 'crypto', () => ({ + getRandomValues() { throw new Error('Randomness unavailable'); }, + }) as unknown as Crypto); + assert.throws(() => randomId(), /Randomness unavailable/); +}); diff --git a/tests/request-origin.test.ts b/tests/request-origin.test.ts new file mode 100644 index 00000000..be4dadd8 --- /dev/null +++ b/tests/request-origin.test.ts @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { requestOrigin } from '../lib/request-origin'; + +void test('HTTP upstream can explicitly use its HTTPS public origin without trusting forwarded headers', () => { + const request = new Request('http://192.168.2.251:33046/api/rooms', { + headers: { 'x-forwarded-host': 'attacker.invalid', 'x-forwarded-proto': 'https' }, + }); + assert.equal(requestOrigin(request), 'http://192.168.2.251:33046'); + assert.equal(requestOrigin(request, 'https://dune.example.test/'), 'https://dune.example.test'); + for (const invalid of ['https://dune.example.test/path', 'https://user:secret@dune.example.test', 'https://dune.example.test/?x=1', 'https://dune.example.test/#x', 'file:///tmp', 'invalid']) { + assert.throws(() => requestOrigin(request, invalid)); + } +}); diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs new file mode 100644 index 00000000..537fe4e5 --- /dev/null +++ b/tools/verify-container.mjs @@ -0,0 +1,99 @@ +// Runs only against a disposable container and volume created by this command. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +const image = process.argv[2]; +if (!image) throw new Error('Usage: node tools/verify-container.mjs IMAGE'); +const name = `dune-verify-${randomUUID()}`; +const volume = `${name}-data`; +const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); +let base; +async function ready() { + for (let attempt = 0; attempt < 90; attempt++) { + try { + const result = await fetch(base, { signal: AbortSignal.timeout(2000) }); + if (result.ok) return; + } catch { /* Startup includes all additive migrations. */ } + await delay(1000); + } + throw new Error('Container did not become ready'); +} +function start(publicOrigin = '') { + docker('run', '-d', '--name', name, '--init', '--cap-drop=ALL', + '--security-opt=no-new-privileges:true', + '-p', '127.0.0.1::3000', '-v', `${volume}:/data`, + '-e', `DUNE_PUBLIC_ORIGIN=${publicOrigin}`, image); + base = `http://${docker('port', name, '3000/tcp')}`; +} +try { + docker('volume', 'create', volume); + start(); + await ready(); + const headers = { 'content-type': 'application/json', origin: base }; + const body = JSON.stringify({ name: 'Container verification', faction: 'atreides', advanced: false, expansions: [] }); + const create = await fetch(`${base}/api/rooms`, { method: 'POST', headers, body }); + assert.equal(create.status, 201, 'Mapped host/port must pass origin validation'); + const cookie = create.headers.get('set-cookie')?.split(';')[0]; + assert.ok(cookie); + const room = await create.json(); + const foreign = await fetch(`${base}/api/rooms`, { + method: 'POST', headers: { ...headers, origin: 'http://foreign.invalid' }, body, + }); + assert.equal(foreign.status, 403); + async function restored() { + const result = await fetch(`${base}/api/rooms/${room.code}`, { headers: { cookie } }); + assert.equal(result.status, 200); + assert.deepEqual(await result.json(), room, 'Saved seat and room must survive replacement'); + } + docker('restart', '--time', '60', name); + await ready(); + await restored(); + docker('stop', '--time', '60', name); + docker('rm', name); + start(); + await ready(); + await restored(); + execFileSync('npm', ['run', 'test:integration'], { + stdio: 'inherit', env: { ...process.env, DUNE_TEST_URL: base }, + }); + docker('stop', '--time', '60', name); + docker('rm', name); + const publicOrigin = 'https://dune.example.test'; + start(publicOrigin); + await ready(); + await restored(); + const proxyCreate = await fetch(`${base}/api/rooms`, { + method: 'POST', headers: { ...headers, origin: publicOrigin }, body, + }); + assert.equal(proxyCreate.status, 201, 'HTTPS external origin must work over HTTP upstream'); + assert.ok(proxyCreate.headers.get('set-cookie')?.includes('; Secure')); + const proxyCookie = proxyCreate.headers.get('set-cookie').split(';')[0]; + const proxyRoom = await proxyCreate.json(); + const join = await fetch(`${base}/api/rooms/${proxyRoom.code}`, { + method: 'POST', headers: { ...headers, origin: publicOrigin }, + body: JSON.stringify({ type: 'join', name: 'Proxy peer', faction: 'emperor' }), + }); + assert.equal(join.status, 200); + assert.ok(join.headers.get('set-cookie')?.includes('; Secure')); + const message = await fetch(`${base}/api/rooms/${proxyRoom.code}/messages`, { + method: 'POST', headers: { ...headers, origin: publicOrigin, cookie: proxyCookie }, + body: JSON.stringify({ id: randomUUID(), recipientId: null, text: 'Proxy verification' }), + }); + assert.equal(message.status, 200, 'Table discussion must work through HTTPS proxy'); + for (const path of ['/api/rooms', `/api/rooms/${proxyRoom.code}`, `/api/rooms/${proxyRoom.code}/control`, `/api/rooms/${proxyRoom.code}/messages`]) { + const rejected = await fetch(base + path, { + method: 'POST', headers: { ...headers, origin: 'https://foreign.invalid' }, body, + }); + assert.equal(rejected.status, 403, path); + } + console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.'); +} catch (error) { + try { console.error(docker('logs', name)); } catch { /* May not exist yet. */ } + throw error; +} finally { + // Names are unique to this invocation; never remove an existing application. + try { docker('rm', '-f', name); } catch { /* Already removed or not created. */ } + try { docker('volume', 'rm', volume); } catch { /* Failure remains visible. */ } +}