Build verified checkpoint containers for TrueNAS
This commit is contained in:
1 parent
c5508b727a
commit
64e8299aeb
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 GOMEMLIMIT=2GiB GOMAXPROCS=2
|
||||||
|
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"]
|
||||||
@@ -35,6 +35,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.
|
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
|
## 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.
|
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 {
|
import {
|
||||||
authenticate,
|
authenticate,
|
||||||
createSeatHandover,
|
createSeatHandover,
|
||||||
@@ -14,12 +15,12 @@ import {
|
|||||||
import { RuleError } from '@/game/engine';
|
import { RuleError } from '@/game/engine';
|
||||||
import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation';
|
import { validSeatAiDelegateRequest } from '@/lib/seat-ai-delegation';
|
||||||
import { resumeRoom } from '@/db/room-continuation';
|
import { resumeRoom } from '@/db/room-continuation';
|
||||||
import { waitUntil } from 'cloudflare:workers';
|
import { env, waitUntil } from 'cloudflare:workers';
|
||||||
const noStore = { 'Cache-Control': 'no-store' };
|
const noStore = { 'Cache-Control': 'no-store' };
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
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(
|
throw new SeatControlError(
|
||||||
'Invalid request origin.',
|
'Invalid request origin.',
|
||||||
'INVALID_ORIGIN',
|
'INVALID_ORIGIN',
|
||||||
@@ -158,7 +159,7 @@ export async function POST(req: Request) {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
...noStore,
|
...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 { authenticate } from '@/db/rooms';
|
||||||
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
|
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
|
||||||
import { RuleError } from '@/game/engine';
|
import { RuleError } from '@/game/engine';
|
||||||
@@ -8,7 +10,7 @@ async function handle(req: Request, write: boolean) {
|
|||||||
if (
|
if (
|
||||||
write &&
|
write &&
|
||||||
req.headers.get('origin') &&
|
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);
|
throw new TableTalkError('Invalid request origin.', 403);
|
||||||
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
const code = url.pathname.split('/').at(-2)!.toUpperCase();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestOrigin } from '@/lib/request-origin';
|
||||||
import {
|
import {
|
||||||
readSeatView,
|
readSeatView,
|
||||||
authenticate,
|
authenticate,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
} from '@/db/rooms';
|
} from '@/db/rooms';
|
||||||
import { RuleError } from '@/game/engine';
|
import { RuleError } from '@/game/engine';
|
||||||
import type { FactionId } from '@/game/catalog';
|
import type { FactionId } from '@/game/catalog';
|
||||||
import { waitUntil } from 'cloudflare:workers';
|
import { env, waitUntil } from 'cloudflare:workers';
|
||||||
import { resumeRoom } from '@/db/room-continuation';
|
import { resumeRoom } from '@/db/room-continuation';
|
||||||
|
|
||||||
const codeOf = (req: Request) => {
|
const codeOf = (req: Request) => {
|
||||||
@@ -68,7 +69,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
req.headers.get('origin') &&
|
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(
|
return Response.json(
|
||||||
{ error: 'Invalid request origin.' },
|
{ error: 'Invalid request origin.' },
|
||||||
@@ -115,7 +116,7 @@ export async function POST(req: Request) {
|
|||||||
headers: {
|
headers: {
|
||||||
...(r.token
|
...(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',
|
'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 { createRoom, RoomEntryError } from '@/db/rooms';
|
||||||
import { RuleError } from '@/game/engine';
|
import { RuleError } from '@/game/engine';
|
||||||
import type { FactionId } from '@/game/catalog';
|
import type { FactionId } from '@/game/catalog';
|
||||||
@@ -5,7 +7,7 @@ export async function POST(req: Request) {
|
|||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
req.headers.get('origin') &&
|
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(
|
return Response.json(
|
||||||
{ error: 'Invalid request origin.' },
|
{ error: 'Invalid request origin.' },
|
||||||
@@ -40,7 +42,7 @@ export async function POST(req: Request) {
|
|||||||
{
|
{
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: {
|
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',
|
'Cache-Control': 'no-store',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { randomId } from '@/lib/random-id';
|
||||||
|
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
import type { GameView } from '@/game/engine';
|
import type { GameView } from '@/game/engine';
|
||||||
@@ -300,7 +301,7 @@ export function SeatAiDelegation({
|
|||||||
void submit({
|
void submit({
|
||||||
type: 'setSeatAiDelegate',
|
type: 'setSeatAiDelegate',
|
||||||
version: game.version,
|
version: game.version,
|
||||||
grantId: crypto.randomUUID(),
|
grantId: randomId(),
|
||||||
delegateId,
|
delegateId,
|
||||||
difficulty,
|
difficulty,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { randomId } from '@/lib/random-id';
|
||||||
import { useEffect, useId, useRef, useState } from 'react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
import type { GameView } from '@/game/engine';
|
import type { GameView } from '@/game/engine';
|
||||||
import { faction } from '@/game/catalog';
|
import { faction } from '@/game/catalog';
|
||||||
@@ -152,7 +153,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
|||||||
async function submit() {
|
async function submit() {
|
||||||
if (sending.current || !ready) return;
|
if (sending.current || !ready) return;
|
||||||
const draft = record ?? {
|
const draft = record ?? {
|
||||||
id: crypto.randomUUID(),
|
id: randomId(),
|
||||||
recipientId: recipient || null,
|
recipientId: recipient || null,
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -1,5 +1,6 @@
|
|||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface Env {
|
interface Env {
|
||||||
DB: D1Database;
|
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 { EXPANSIONS, FACTIONS } from '../game/catalog';
|
||||||
import { requestMayHaveCompleted } from './client-request';
|
import { requestMayHaveCompleted } from './client-request';
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export function createRoomEntry(
|
|||||||
roomCode?: string,
|
roomCode?: string,
|
||||||
): RoomEntryAttempt {
|
): RoomEntryAttempt {
|
||||||
const entry = {
|
const entry = {
|
||||||
operationId: globalThis.crypto.randomUUID(),
|
operationId: randomId(),
|
||||||
sessionToken: Array.from(
|
sessionToken: Array.from(
|
||||||
globalThis.crypto.getRandomValues(new Uint8Array(32)),
|
globalThis.crypto.getRandomValues(new Uint8Array(32)),
|
||||||
(byte) => byte.toString(16).padStart(2, '0'),
|
(byte) => byte.toString(16).padStart(2, '0'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomId } from './random-id';
|
||||||
import type { GameView } from '../game/engine';
|
import type { GameView } from '../game/engine';
|
||||||
|
|
||||||
export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1';
|
export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1';
|
||||||
@@ -98,7 +99,7 @@ export function createHandoverKit(
|
|||||||
version: 1,
|
version: 1,
|
||||||
roomCode,
|
roomCode,
|
||||||
playerId,
|
playerId,
|
||||||
offerId: crypto.randomUUID(),
|
offerId: randomId(),
|
||||||
handoverSecret: secret(),
|
handoverSecret: secret(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -111,7 +112,7 @@ export function createHandoverClaim(kit: SeatHandoverKit): SavedHandoverClaim {
|
|||||||
playerId: kit.playerId,
|
playerId: kit.playerId,
|
||||||
offerId: kit.offerId,
|
offerId: kit.offerId,
|
||||||
handoverSecret: kit.handoverSecret,
|
handoverSecret: kit.handoverSecret,
|
||||||
operationId: crypto.randomUUID(),
|
operationId: randomId(),
|
||||||
newSessionToken: secret(),
|
newSessionToken: secret(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomId } from './random-id';
|
||||||
export type SeatRecoveryKit = Readonly<{
|
export type SeatRecoveryKit = Readonly<{
|
||||||
format: 'dune-seat-recovery';
|
format: 'dune-seat-recovery';
|
||||||
version: 1;
|
version: 1;
|
||||||
@@ -106,7 +107,7 @@ export function createRecoveryAttempt(
|
|||||||
type: 'recoverSeat',
|
type: 'recoverSeat',
|
||||||
playerId: kit.playerId,
|
playerId: kit.playerId,
|
||||||
recoverySecret: kit.recoverySecret,
|
recoverySecret: kit.recoverySecret,
|
||||||
operationId: globalThis.crypto.randomUUID(),
|
operationId: randomId(),
|
||||||
newSessionToken: secret256(),
|
newSessionToken: secret256(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Generated
+1
@@ -40,6 +40,7 @@
|
|||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"@vitejs/plugin-rsc": "0.5.26",
|
"@vitejs/plugin-rsc": "0.5.26",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
|
"miniflare": "4.20260515.0",
|
||||||
"oxfmt": "0.61.0",
|
"oxfmt": "0.61.0",
|
||||||
"oxlint": "1.76.0",
|
"oxlint": "1.76.0",
|
||||||
"oxlint-tsgolint": "7.0.2001",
|
"oxlint-tsgolint": "7.0.2001",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"tailwindcss": "4.2.1",
|
"tailwindcss": "4.2.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"vite": "8.0.13",
|
"vite": "8.0.13",
|
||||||
|
"miniflare": "4.20260515.0",
|
||||||
"wrangler": "4.92.0",
|
"wrangler": "4.92.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"tsx": "4.23.13"
|
"tsx": "4.23.13"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestOrigin } from '../lib/request-origin';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { webcrypto } from 'node:crypto';
|
import { webcrypto } from 'node:crypto';
|
||||||
@@ -82,6 +83,7 @@ function unitStore(runBots = bots.runBots) {
|
|||||||
require: (name: string) => {
|
require: (name: string) => {
|
||||||
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
||||||
if (name === '@/game/engine') return engine;
|
if (name === '@/game/engine') return engine;
|
||||||
|
if (name === '@/lib/request-origin') return { requestOrigin };
|
||||||
if (name === '@/game/bots') return { ...bots, runBots };
|
if (name === '@/game/bots') return { ...bots, runBots };
|
||||||
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
||||||
throw new Error('Unexpected module ' + name);
|
throw new Error('Unexpected module ' + name);
|
||||||
@@ -300,8 +302,9 @@ function route(
|
|||||||
};
|
};
|
||||||
if (name === '@/db/room-continuation') return continuation;
|
if (name === '@/db/room-continuation') return continuation;
|
||||||
if (name === '@/game/engine') return engine;
|
if (name === '@/game/engine') return engine;
|
||||||
|
if (name === '@/lib/request-origin') return { requestOrigin };
|
||||||
if (name === 'cloudflare:workers')
|
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);
|
throw new Error('Unexpected route dependency ' + name);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestOrigin } from '../lib/request-origin';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { webcrypto } from 'node:crypto';
|
import { webcrypto } from 'node:crypto';
|
||||||
@@ -81,6 +82,7 @@ function unitStore(runBots = bots.runBots) {
|
|||||||
require: (name: string) => {
|
require: (name: string) => {
|
||||||
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
if (name === 'cloudflare:workers') return { env: { DB: database } };
|
||||||
if (name === '@/game/engine') return engine;
|
if (name === '@/game/engine') return engine;
|
||||||
|
if (name === '@/lib/request-origin') return { requestOrigin };
|
||||||
if (name === '@/game/bots') return { ...bots, runBots };
|
if (name === '@/game/bots') return { ...bots, runBots };
|
||||||
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
if (name === '@/lib/seat-ai-delegation') return seatAiDelegation;
|
||||||
throw new Error('Unexpected module ' + name);
|
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 === '@/db/room-continuation') return continuation;
|
||||||
if (name === '@/game/engine') return engine;
|
if (name === '@/game/engine') return engine;
|
||||||
|
if (name === '@/lib/request-origin') return { requestOrigin };
|
||||||
if (name === 'cloudflare:workers')
|
if (name === 'cloudflare:workers')
|
||||||
return {
|
return {
|
||||||
|
env: {},
|
||||||
waitUntil: (work: Promise<unknown>) => pending.push(work),
|
waitUntil: (work: Promise<unknown>) => pending.push(work),
|
||||||
};
|
};
|
||||||
throw new Error('Unexpected route dependency ' + name);
|
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