Bootstrap initial NAS administrator on empty database

This commit is contained in:
Quintine committed 2026-09-28 08:25:18 +10:00
1 parent 2e39ea00bb
commit f64b5fd33b
8 files changed
+134 -4

No files matched your search

+1
View File
@@ -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
+4 -1
View File
@@ -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.
+29
View File
@@ -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');
}
+1
View File
@@ -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
+17 -2
View File
@@ -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 <container-name>`).
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
+7
View File
@@ -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
+59
View File
@@ -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<string, unknown>[];
};
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 });
}
});
+16 -1
View File
@@ -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.