From f64b5fd33bb9cbd49bcb0506bbab7b307a4528c8 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:25:18 +1000 Subject: [PATCH] Bootstrap initial NAS administrator on empty database --- Dockerfile | 1 + README.md | 5 ++- deploy/bootstrap-admin.mjs | 29 ++++++++++++ deploy/container-entrypoint.sh | 1 + docs/ADMIN_PANEL.md | 19 +++++++- docs/TRUENAS.md | 7 +++ tests/container-admin-bootstrap.test.ts | 59 +++++++++++++++++++++++++ tools/verify-container.mjs | 17 ++++++- 8 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 deploy/bootstrap-admin.mjs create mode 100644 tests/container-admin-bootstrap.test.ts diff --git a/Dockerfile b/Dockerfile index f36c0fb9..7b053bf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY package.json ./ COPY tools/wrangler.local.json ./tools/wrangler.local.json COPY tools/admin-access.mjs ./tools/admin-access.mjs COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh +COPY deploy/bootstrap-admin.mjs ./deploy/bootstrap-admin.mjs COPY deploy/serve.mjs ./deploy/serve.mjs RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler USER node diff --git a/README.md b/README.md index fa393717..efa0f482 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,10 @@ with new games; the requested host port is 33046. The **Administration** link opens `/admin`, with separate administrator sign-in, session controls and a searchable room directory. **Room controls** lets owners -and operators pause/resume rooms and lock/unlock new joins. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) +and operators pause/resume rooms and lock/unlock new joins. The NAS container +prints a newly generated initial owner access key in its log only if there are +no administrator accounts. Save it privately: log readers can use it, and later +starts do not print it again. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat and initial AI players. diff --git a/deploy/bootstrap-admin.mjs b/deploy/bootstrap-admin.mjs new file mode 100644 index 00000000..f70a1e76 --- /dev/null +++ b/deploy/bootstrap-admin.mjs @@ -0,0 +1,29 @@ +// Run after migrations, before the container accepts requests. The conditional +// insert is atomic: existing accounts (including disabled ones) are never replaced. +import { execFileSync } from 'node:child_process'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +const id = randomUUID(); +const key = `dune-admin.${id}.${randomBytes(32).toString('hex')}`; +const hash = createHash('sha256').update(key).digest('hex'); +const now = Date.now(); +const sql = `INSERT INTO admin_accounts (id, name, role, key_hash, enabled, session_generation, created_at, updated_at) +SELECT '${id}', 'Initial owner', 'owner', '${hash}', 1, 0, ${now}, ${now} +WHERE NOT EXISTS (SELECT 1 FROM admin_accounts) +RETURNING id;`; +const output = execFileSync(process.execPath, [ + 'node_modules/wrangler/bin/wrangler.js', 'd1', 'execute', 'DB', + '--local', '--config', 'tools/wrangler.local.json', + '--persist-to', process.env.DUNE_STATE_PATH || '/data', + '--command', sql, '--json', +], { encoding: 'utf8' }); +const result = JSON.parse(output); +if (!Array.isArray(result) || result.length !== 1 || result[0].success !== true || + !Array.isArray(result[0].results)) { + throw new Error('Initial administrator provisioning did not return a valid result'); +} +if (result[0].results.length === 1 && result[0].results[0].id === id) { + console.log(`Initial administrator access key (save privately; shown only on first provisioning): ${key}`); +} else if (result[0].results.length !== 0) { + throw new Error('Initial administrator provisioning returned an unexpected account'); +} diff --git a/deploy/container-entrypoint.sh b/deploy/container-entrypoint.sh index f6af6748..e9271af1 100644 --- a/deploy/container-entrypoint.sh +++ b/deploy/container-entrypoint.sh @@ -15,4 +15,5 @@ node --input-type=module -e ' ' node node_modules/wrangler/bin/wrangler.js d1 migrations apply DB \ --local --config tools/wrangler.local.json --persist-to "$state_path" +node deploy/bootstrap-admin.mjs exec node deploy/serve.mjs diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 8212eb91..e73bae07 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -78,8 +78,23 @@ them when generating future schema changes. Audit browsing is still pending. ### Initial owner and access recovery -From the development checkout, apply additive migrations, then create a personal -owner key in a new private directory outside the checkout: +The NAS container provisions an **Initial owner** automatically after migrations +only if `admin_accounts` has no rows. Its newly generated personal access key is +printed once in that container's startup log (`docker logs `). +Retrieve it privately, save it in a password manager and sign in at `/admin`; +it is not a chosen password or an environment variable. Anyone able to read +that log can use the key. Restrict log access and retention; do not paste the +log into support reports. Restarts, +container replacements and existing accounts (even disabled ones) never generate +or print another key. Save the key before replacing the first container, since +its original log may disappear. Losing it requires manual owner recovery below. + +The automatic bootstrap applies only to the bundled NAS container startup, not +`npm run dev` or an external D1 deployment. + +For manual provisioning or access recovery from the development checkout, +apply additive migrations, then create a personal owner key in a new private +directory outside the checkout: ```sh npm run db:local diff --git a/docs/TRUENAS.md b/docs/TRUENAS.md index 655e103b..525e9e88 100644 --- a/docs/TRUENAS.md +++ b/docs/TRUENAS.md @@ -42,6 +42,13 @@ 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. +On a database without administrator accounts, startup creates one owner and +prints its personal access key once in the container log. Save it privately +before replacing the container; its original log may be lost. Anyone with log +access can use that key, so restrict log access and do not publish diagnostics +containing it. Existing accounts never trigger a new key on restart or update. +See [admin access recovery](ADMIN_PANEL.md#initial-owner-and-access-recovery). + 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 diff --git a/tests/container-admin-bootstrap.test.ts b/tests/container-admin-bootstrap.test.ts new file mode 100644 index 00000000..572488b1 --- /dev/null +++ b/tests/container-admin-bootstrap.test.ts @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const checkout = fileURLToPath(new URL('../', import.meta.url)); +const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url)); +const bootstrap = fileURLToPath(new URL('../deploy/bootstrap-admin.mjs', import.meta.url)); + +void test('container bootstrap logs a usable owner key only for an empty database', () => { + let state = mkdtempSync(join(tmpdir(), 'dune-admin-bootstrap-')); + const d1 = (...args: string[]) => spawnSync(process.execPath, [wrangler, 'd1', ...args, + '--local', '--config', 'tools/wrangler.local.json', '--persist-to', state], + { cwd: checkout, encoding: 'utf8' }); + const query = (sql: string) => { + const result = d1('execute', 'DB', '--command', sql, '--json'); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout)[0].results as Record[]; + }; + const start = () => spawnSync(process.execPath, [bootstrap], { + cwd: checkout, env: { ...process.env, DUNE_STATE_PATH: state }, encoding: 'utf8', + }); + try { + const migrated = d1('migrations', 'apply', 'DB'); + assert.equal(migrated.status, 0, migrated.stderr); + const first = start(); + assert.equal(first.status, 0, first.stderr); + const key = first.stdout.match(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/)?.[0]; + assert.ok(key, 'Initial owner key must be printed in the startup log'); + const account = query('SELECT id, role, key_hash FROM admin_accounts'); + assert.equal(account.length, 1); + assert.equal(account[0].role, 'owner'); + assert.equal(account[0].id, key.split('.')[1]); + assert.equal(account[0].key_hash, createHash('sha256').update(key).digest('hex')); + assert.equal(query("SELECT id FROM admin_audit WHERE action = 'provision'").length, 1); + + const restart = start(); + assert.equal(restart.status, 0, restart.stderr); + assert.equal(restart.stdout.includes('dune-admin.'), false); + assert.equal(query('SELECT id FROM admin_accounts').length, 1); + + // An existing disabled account must not cause a new owner to appear. + rmSync(state, { recursive: true, force: true }); + state = mkdtempSync(join(tmpdir(), 'dune-admin-bootstrap-')); + const remigrated = d1('migrations', 'apply', 'DB'); + assert.equal(remigrated.status, 0, remigrated.stderr); + query("INSERT INTO admin_accounts (id, name, role, key_hash, enabled, session_generation, created_at, updated_at) VALUES ('disabled', 'Disabled', 'viewer', 'unused-hash', 0, 0, 0, 0)"); + const noOwner = start(); + assert.equal(noOwner.status, 0, noOwner.stderr); + assert.equal(noOwner.stdout.includes('dune-admin.'), false); + assert.equal(query('SELECT id FROM admin_accounts').length, 1); + } finally { + rmSync(state, { recursive: true, force: true }); + } +}); diff --git a/tools/verify-container.mjs b/tools/verify-container.mjs index f9b11c02..ebc55d5d 100644 --- a/tools/verify-container.mjs +++ b/tools/verify-container.mjs @@ -32,10 +32,22 @@ function start(publicOrigin = '') { '-e', `DUNE_PUBLIC_ORIGIN=${publicOrigin}`, image); base = `http://${docker('port', name, '3000/tcp')}`; } +const bootstrapKeys = (logs) => logs.match(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g) || []; +function checkBootstrap(count) { + const keys = bootstrapKeys(docker('logs', name)); + assert.equal(keys.length, count, 'First owner key must be logged exactly once per new database'); + return keys[0]; +} try { docker('volume', 'create', volume); start(); await ready(); + const bootstrapKey = checkBootstrap(1); + const ownerLogin = await fetch(`${base}/api/admin/session`, { + method: 'POST', headers: { 'content-type': 'application/json', origin: base }, + body: JSON.stringify({ action: 'login', key: bootstrapKey }), + }); + assert.equal(ownerLogin.status, 200, 'Bootstrapped owner must be able to sign in'); execFileSync('node', ['tools/admin-access.mjs', '--name', 'Container QA', '--role', 'operator', '--out', joinPath(adminFiles, 'key')]); execFileSync('docker', ['exec', '-i', name, 'node', '-e', "require('node:fs').writeFileSync('/tmp/dune-admin-provision.sql', require('node:fs').readFileSync(0), {mode:0o600})"], { input: readFileSync(joinPath(adminFiles, 'key/provision.sql')), // Container USER owns this private file. @@ -73,12 +85,14 @@ try { // Docker may assign a different ephemeral host port after a restart. base = `http://${docker('port', name, '3000/tcp')}`; await ready(); + assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key'); await restored(); docker('stop', '--time', '60', name); docker('rm', name); start(); await ready(); await restored(); + checkBootstrap(0); execFileSync('node', ['tools/verify-admin.mjs', '--url', base, '--key-file', joinPath(adminFiles, 'key/access-key.txt'), '--qa-account', adminKey.split('.')[1], '--out', joinPath(adminFiles, 'http')], { stdio: 'inherit' }); adminCookie = undefined; // The acceptance test intentionally signs out all sessions. execFileSync('npm', ['run', 'test:integration'], { @@ -90,6 +104,7 @@ try { start(publicOrigin); await ready(); await restored(); + checkBootstrap(0); const proxyAdmin = await fetch(`${base}/api/admin/session`, { method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin }, body: JSON.stringify({ action: 'login', key: adminKey }), @@ -122,7 +137,7 @@ try { } 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. */ } + try { console.error(docker('logs', name).replaceAll(/dune-admin\.[0-9a-f-]{36}\.[0-9a-f]{64}/g, '[REDACTED ADMIN KEY]')); } catch { /* May not exist yet. */ } throw error; } finally { // Names are unique to this invocation; never remove an existing application.