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/wrangler.local.json ./tools/wrangler.local.json
|
||||||
COPY tools/admin-access.mjs ./tools/admin-access.mjs
|
COPY tools/admin-access.mjs ./tools/admin-access.mjs
|
||||||
COPY deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh
|
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
|
COPY deploy/serve.mjs ./deploy/serve.mjs
|
||||||
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
|
RUN mkdir -p /data /app/.wrangler && chown node:node /data /app/.wrangler
|
||||||
USER node
|
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,
|
The **Administration** link opens `/admin`, with separate administrator sign-in,
|
||||||
session controls and a searchable room directory. **Room controls** lets owners
|
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).
|
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
|
Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with
|
||||||
an explicitly owned new host seat and initial AI players.
|
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 \
|
node node_modules/wrangler/bin/wrangler.js d1 migrations apply DB \
|
||||||
--local --config tools/wrangler.local.json --persist-to "$state_path"
|
--local --config tools/wrangler.local.json --persist-to "$state_path"
|
||||||
|
node deploy/bootstrap-admin.mjs
|
||||||
exec node deploy/serve.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
|
### Initial owner and access recovery
|
||||||
|
|
||||||
From the development checkout, apply additive migrations, then create a personal
|
The NAS container provisions an **Initial owner** automatically after migrations
|
||||||
owner key in a new private directory outside the checkout:
|
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
|
```sh
|
||||||
npm run db:local
|
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
|
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.
|
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,
|
The container serves the built Worker directly through pinned Miniflare/workerd,
|
||||||
with persisted D1-compatible SQLite. Wrangler only applies startup migrations.
|
with persisted D1-compatible SQLite. Wrangler only applies startup migrations.
|
||||||
The development proxy produced intermittent POST failures after rejected request
|
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);
|
'-e', `DUNE_PUBLIC_ORIGIN=${publicOrigin}`, image);
|
||||||
base = `http://${docker('port', name, '3000/tcp')}`;
|
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 {
|
try {
|
||||||
docker('volume', 'create', volume);
|
docker('volume', 'create', volume);
|
||||||
start();
|
start();
|
||||||
await ready();
|
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('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})"], {
|
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.
|
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.
|
// Docker may assign a different ephemeral host port after a restart.
|
||||||
base = `http://${docker('port', name, '3000/tcp')}`;
|
base = `http://${docker('port', name, '3000/tcp')}`;
|
||||||
await ready();
|
await ready();
|
||||||
|
assert.equal(checkBootstrap(1), bootstrapKey, 'Restart must not replace the owner key');
|
||||||
await restored();
|
await restored();
|
||||||
docker('stop', '--time', '60', name);
|
docker('stop', '--time', '60', name);
|
||||||
docker('rm', name);
|
docker('rm', name);
|
||||||
start();
|
start();
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
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' });
|
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.
|
adminCookie = undefined; // The acceptance test intentionally signs out all sessions.
|
||||||
execFileSync('npm', ['run', 'test:integration'], {
|
execFileSync('npm', ['run', 'test:integration'], {
|
||||||
@@ -90,6 +104,7 @@ try {
|
|||||||
start(publicOrigin);
|
start(publicOrigin);
|
||||||
await ready();
|
await ready();
|
||||||
await restored();
|
await restored();
|
||||||
|
checkBootstrap(0);
|
||||||
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
const proxyAdmin = await fetch(`${base}/api/admin/session`, {
|
||||||
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
method: 'POST', headers: { 'content-type': 'application/json', origin: publicOrigin },
|
||||||
body: JSON.stringify({ action: 'login', key: adminKey }),
|
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.');
|
console.log('Container origin, restart, replacement, saved seat and HTTP integration checks passed.');
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
// Names are unique to this invocation; never remove an existing application.
|
// Names are unique to this invocation; never remove an existing application.
|
||||||
|
|||||||
Reference in new issue
Block a user