Bootstrap initial NAS administrator on empty database
This commit is contained in:
1 parent
2e39ea00bb
commit
f64b5fd33b
8 files changed
+134
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user