Package verified Dune checkpoints for TrueNAS and HTTPS proxying
Serve built Workers directly through pinned Miniflare with persistent D1-compatible storage and startup migrations. Build and verify Docker images in CI; publish main checkpoints to GHCR for manual TrueNAS updates on port 33046. Preserve explicit public-origin checks and LAN-compatible cryptographic operation IDs. Validation: 5,153 offline tests, typecheck, lint and build; 49 HTTP integration cases; HTTPS-origin create/join/chat/seat protection, foreign-origin rejection, exact saved-seat restart and browser asset serving. Independent persistence/proxy review completed. Clean Docker CI and actual NAS installation follow this source checkpoint.
This commit is contained in:
1 parent
0f79f28ccb
commit
903c7731e2
27 files changed
+536
-16
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
+28
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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' : ''}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
declare namespace Cloudflare {
|
||||
interface Env {
|
||||
DB: D1Database;
|
||||
DUNE_PUBLIC_ORIGIN?: string;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:
|
||||
+107
@@ -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-<full-commit>`. 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/).
|
||||
@@ -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('-');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+2
-1
@@ -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'),
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<void>) => pending.push(work) };
|
||||
return { env: {}, waitUntil: (work: Promise<void>) => pending.push(work) };
|
||||
throw new Error('Unexpected route dependency ' + name);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<unknown>) => pending.push(work),
|
||||
};
|
||||
throw new Error('Unexpected route dependency ' + name);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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. */ }
|
||||
}
|
||||
Reference in new issue
Block a user