pgserve 2.2.1 → 2.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/console/dist/app.js +121 -0
- package/console/dist/index.html +13 -0
- package/package.json +8 -4
- package/src/cli-install.cjs +345 -4
- package/src/cli-ui.cjs +81 -6
- package/console/README.md +0 -131
- package/console/api.js +0 -173
- package/console/app.jsx +0 -483
- package/console/components.jsx +0 -167
- package/console/data.jsx +0 -350
- package/console/index.html +0 -31
- package/console/screens/databases.jsx +0 -5
- package/console/screens/health.jsx +0 -5
- package/console/screens/ingress.jsx +0 -5
- package/console/screens/optimizer.jsx +0 -5
- package/console/screens/rlm-sim.jsx +0 -5
- package/console/screens/rlm-trace.jsx +0 -5
- package/console/screens/security.jsx +0 -5
- package/console/screens/settings.jsx +0 -611
- package/console/screens/sql.jsx +0 -5
- package/console/screens/sync.jsx +0 -5
- package/console/screens/tables.jsx +0 -5
- package/console/tweaks-panel.jsx +0 -425
- /package/console/{colors_and_type.css → dist/colors_and_type.css} +0 -0
- /package/console/{console.css → dist/console.css} +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="mdr" data-screen-label="autopg console">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>autopg · console</title>
|
|
6
|
+
<link rel="stylesheet" href="colors_and_type.css" />
|
|
7
|
+
<link rel="stylesheet" href="console.css?v=settings1" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./app.js"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pgserve",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/",
|
|
13
13
|
"src/",
|
|
14
|
-
"console/",
|
|
14
|
+
"console/dist/",
|
|
15
15
|
"README.md",
|
|
16
16
|
"CHANGELOG.md",
|
|
17
17
|
"LICENSE",
|
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
"start": "bun bin/postgres-server.js",
|
|
27
27
|
"build": "bun build --compile bin/postgres-server.js --outfile dist/pgserve",
|
|
28
28
|
"build:all": "make build-all",
|
|
29
|
+
"console:build": "bun build console/src/main.jsx --target browser --minify --define 'process.env.NODE_ENV=\"production\"' --outfile console/dist/app.js && cp console/src/index.html console/dist/index.html && cp console/src/*.css console/dist/",
|
|
30
|
+
"console:dev": "bun build console/src/main.jsx --target browser --define 'process.env.NODE_ENV=\"development\"' --watch --outfile console/dist/app.js",
|
|
29
31
|
"lint": "eslint src/ bin/",
|
|
30
32
|
"lint:fix": "eslint src/ bin/ --fix",
|
|
31
33
|
"deadcode": "knip",
|
|
32
34
|
"test:npx": "scripts/test-npx.sh",
|
|
33
35
|
"test:bun-self-heal": "scripts/test-bun-self-heal.sh",
|
|
34
|
-
"prepublishOnly": "npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
|
|
36
|
+
"prepublishOnly": "npm run console:build && npm run lint && npm run deadcode && npm run test:npx && npm run test:bun-self-heal",
|
|
35
37
|
"prepare": "husky",
|
|
36
38
|
"postinstall": "node scripts/postinstall.cjs"
|
|
37
39
|
},
|
|
@@ -76,6 +78,8 @@
|
|
|
76
78
|
]
|
|
77
79
|
},
|
|
78
80
|
"dependencies": {
|
|
79
|
-
"bun": "^1.3.4"
|
|
81
|
+
"bun": "^1.3.4",
|
|
82
|
+
"react": "^18.3.1",
|
|
83
|
+
"react-dom": "^18.3.1"
|
|
80
84
|
}
|
|
81
85
|
}
|
package/src/cli-install.cjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
'use strict';
|
|
23
23
|
|
|
24
24
|
const { spawnSync, execFileSync } = require('node:child_process');
|
|
25
|
+
const crypto = require('node:crypto');
|
|
25
26
|
const fs = require('node:fs');
|
|
26
27
|
const os = require('node:os');
|
|
27
28
|
const path = require('node:path');
|
|
@@ -29,6 +30,24 @@ const path = require('node:path');
|
|
|
29
30
|
const PM2_PROCESS_NAME = 'pgserve';
|
|
30
31
|
const DEFAULT_PORT = 8432;
|
|
31
32
|
|
|
33
|
+
// Console UI is auto-supervised under pm2 alongside the daemon since v2.2.3.
|
|
34
|
+
// The bundled SPA (console/dist/) is served on this port; operator-facing
|
|
35
|
+
// only, 127.0.0.1, no auth, no TLS — matches autopg's "single-user dev tool"
|
|
36
|
+
// posture. Opt-out with `autopg install --no-ui` for headless/CI hosts.
|
|
37
|
+
const UI_PM2_PROCESS_NAME = 'autopg-ui';
|
|
38
|
+
const DEFAULT_UI_PORT = 8433;
|
|
39
|
+
const DEFAULT_UI_HOST = '127.0.0.1';
|
|
40
|
+
const UI_MAX_MEMORY = '256M';
|
|
41
|
+
|
|
42
|
+
// Admin password file shape (~/.autopg/admin.json, mode 0600):
|
|
43
|
+
// { scheme: 'scrypt', salt: <b64>, hash: <b64>, createdAt, rotatedAt }
|
|
44
|
+
// Generated on first `autopg install`; rotated via `autopg auth
|
|
45
|
+
// rotate-admin-password`. cli-ui.cjs reads this via getAdminFilePath()
|
|
46
|
+
// and gates Basic Auth against it.
|
|
47
|
+
const ADMIN_FILE_NAME = 'admin.json';
|
|
48
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, dkLen: 32 };
|
|
49
|
+
const ADMIN_PASSWORD_BYTES = 12; // 96 bits → ~24 hex chars; plenty for a localhost dev tool
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* Hardening defaults — tuned for production-grade elasticity, NOT
|
|
34
53
|
* the toy-machine values an initial draft of the wish carried.
|
|
@@ -268,13 +287,248 @@ function ok(message) {
|
|
|
268
287
|
}
|
|
269
288
|
|
|
270
289
|
/**
|
|
271
|
-
*
|
|
290
|
+
* Resolve the autopg wrapper used to launch the UI under pm2. The wrapper
|
|
291
|
+
* lives next to `postgres-server.js` (same `bin/` dir).
|
|
292
|
+
*/
|
|
293
|
+
function getUiBinPath(scriptPath) {
|
|
294
|
+
return path.join(path.dirname(scriptPath), 'autopg-wrapper.cjs');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* pm2 start args for the UI process. Smaller memory cap than the daemon
|
|
299
|
+
* (idle node http server, no postgres backend), shares the same restart
|
|
300
|
+
* budget + exp-backoff as pgserve.
|
|
301
|
+
*/
|
|
302
|
+
function buildUiPm2StartArgs({ uiBinPath, uiPort, uiHost }) {
|
|
303
|
+
const logs = {
|
|
304
|
+
out: path.join(getLogsDir(), `${UI_PM2_PROCESS_NAME}-out.log`),
|
|
305
|
+
error: path.join(getLogsDir(), `${UI_PM2_PROCESS_NAME}-error.log`),
|
|
306
|
+
};
|
|
307
|
+
const supervision = getEffectiveSupervision();
|
|
308
|
+
return [
|
|
309
|
+
'start',
|
|
310
|
+
uiBinPath,
|
|
311
|
+
'--name',
|
|
312
|
+
UI_PM2_PROCESS_NAME,
|
|
313
|
+
'--interpreter',
|
|
314
|
+
'none',
|
|
315
|
+
'--max-restarts',
|
|
316
|
+
String(supervision.maxRestarts),
|
|
317
|
+
'--restart-delay',
|
|
318
|
+
String(supervision.restartDelayMs),
|
|
319
|
+
'--exp-backoff-restart-delay',
|
|
320
|
+
String(supervision.expBackoffRestartDelayMs),
|
|
321
|
+
'--max-memory-restart',
|
|
322
|
+
UI_MAX_MEMORY,
|
|
323
|
+
'--kill-timeout',
|
|
324
|
+
String(supervision.killTimeoutMs),
|
|
325
|
+
'--log-date-format',
|
|
326
|
+
supervision.logDateFormat,
|
|
327
|
+
'--output',
|
|
328
|
+
logs.out,
|
|
329
|
+
'--error',
|
|
330
|
+
logs.error,
|
|
331
|
+
'--',
|
|
332
|
+
'ui',
|
|
333
|
+
'--no-open',
|
|
334
|
+
'--port',
|
|
335
|
+
String(uiPort),
|
|
336
|
+
'--host',
|
|
337
|
+
uiHost,
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Register `autopg-ui` under pm2. Soft-fails: if pm2 is missing, the bin
|
|
343
|
+
* is missing, or the spawn fails — log a note and return 0 anyway. The
|
|
344
|
+
* daemon is the load-bearing process; the UI is convenience.
|
|
345
|
+
*/
|
|
346
|
+
function cmdInstallUi(ctx, options = {}) {
|
|
347
|
+
if (!pm2IsAvailable()) {
|
|
348
|
+
note('pm2 not found; skipping UI install (run `autopg ui` on demand)');
|
|
349
|
+
return 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const uiBinPath = getUiBinPath(ctx.scriptPath);
|
|
353
|
+
if (!fs.existsSync(uiBinPath)) {
|
|
354
|
+
note(`UI bin not found at ${uiBinPath}; skipping UI install`);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const uiPort = options.uiPort ?? DEFAULT_UI_PORT;
|
|
359
|
+
const uiHost = options.uiHost ?? DEFAULT_UI_HOST;
|
|
360
|
+
const refresh = options.refresh === true;
|
|
361
|
+
|
|
362
|
+
// If a UI process already exists: refresh-mode replaces it (pick up new
|
|
363
|
+
// host/port/etc); idempotent-mode keeps it. Default behavior is
|
|
364
|
+
// idempotent — operators who want to apply new flags pass --with-ui
|
|
365
|
+
// (which sets refresh=true) so the change takes effect without a
|
|
366
|
+
// separate uninstall step.
|
|
367
|
+
const existing = pm2GetProcess(UI_PM2_PROCESS_NAME);
|
|
368
|
+
if (existing && !refresh) {
|
|
369
|
+
ok(`UI already installed (pm2 process "${UI_PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
if (existing && refresh) {
|
|
373
|
+
spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
ensureLogsDir();
|
|
377
|
+
const pm2Args = buildUiPm2StartArgs({ uiBinPath, uiPort, uiHost });
|
|
378
|
+
const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
|
|
379
|
+
if (result.status !== 0) {
|
|
380
|
+
note(`UI install failed (exit ${result.status}); daemon is unaffected. Run \`autopg ui\` manually.`);
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
ok(`UI ${refresh && existing ? 'refreshed' : 'installed'}: pm2 process "${UI_PM2_PROCESS_NAME}" on http://${uiHost}:${uiPort}`);
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function cmdUninstallUi() {
|
|
388
|
+
if (!pm2IsAvailable()) return 0;
|
|
389
|
+
// Always attempt the delete — `pm2 delete <name>` is idempotent and
|
|
390
|
+
// exits non-zero on a missing process, which is fine to swallow. This
|
|
391
|
+
// is more robust than a pre-existence check against `pm2 jlist`, which
|
|
392
|
+
// can lag the actual process state (or, in test stubs, return a
|
|
393
|
+
// partial registry).
|
|
394
|
+
const result = spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], {
|
|
395
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
396
|
+
});
|
|
397
|
+
if (result.status === 0) {
|
|
398
|
+
ok(`UI uninstalled (pm2 process "${UI_PM2_PROCESS_NAME}" removed)`);
|
|
399
|
+
}
|
|
400
|
+
return 0;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Admin password (Basic Auth for `autopg ui`) ─────────────────────────
|
|
404
|
+
|
|
405
|
+
function getAdminFilePath() {
|
|
406
|
+
return path.join(getConfigDir(), ADMIN_FILE_NAME);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function generateAdminPassword() {
|
|
410
|
+
// 12 bytes → 24 hex chars, grouped in 4-char chunks for human transcription:
|
|
411
|
+
// "7f3a-92c1-8ed4-1b6c-..." (no ambiguous chars beyond hex; matches the
|
|
412
|
+
// "really simple, don't reinvent" bar — operators copy-paste from a single
|
|
413
|
+
// stdout line into a browser dialog).
|
|
414
|
+
const raw = crypto.randomBytes(ADMIN_PASSWORD_BYTES).toString('hex');
|
|
415
|
+
return raw.match(/.{1,4}/g).join('-');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function hashAdminPassword(password, salt) {
|
|
419
|
+
// scrypt is RFC 7914 + built into Node since 10.5. No npm dep.
|
|
420
|
+
return crypto.scryptSync(password, salt, SCRYPT_PARAMS.dkLen, {
|
|
421
|
+
N: SCRYPT_PARAMS.N,
|
|
422
|
+
r: SCRYPT_PARAMS.r,
|
|
423
|
+
p: SCRYPT_PARAMS.p,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function writeAdminFile({ password, rotated = false }) {
|
|
428
|
+
const salt = crypto.randomBytes(32);
|
|
429
|
+
const hash = hashAdminPassword(password, salt);
|
|
430
|
+
const file = getAdminFilePath();
|
|
431
|
+
const now = new Date().toISOString();
|
|
432
|
+
const payload = {
|
|
433
|
+
scheme: 'scrypt',
|
|
434
|
+
params: SCRYPT_PARAMS,
|
|
435
|
+
salt: salt.toString('base64'),
|
|
436
|
+
hash: hash.toString('base64'),
|
|
437
|
+
createdAt: rotated ? readAdminFile()?.createdAt ?? now : now,
|
|
438
|
+
rotatedAt: rotated ? now : null,
|
|
439
|
+
};
|
|
440
|
+
ensureConfigDir();
|
|
441
|
+
// Atomic write: tmp + rename. mode 0600 enforced via fchmod after write.
|
|
442
|
+
const tmp = `${file}.tmp`;
|
|
443
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
444
|
+
fs.renameSync(tmp, file);
|
|
445
|
+
fs.chmodSync(file, 0o600);
|
|
446
|
+
return payload;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function readAdminFile() {
|
|
450
|
+
try {
|
|
451
|
+
const raw = fs.readFileSync(getAdminFilePath(), 'utf8');
|
|
452
|
+
return JSON.parse(raw);
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function ensureConfigDir() {
|
|
459
|
+
const dir = getConfigDir();
|
|
460
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Verify a candidate password against the stored hash. Returns true on match.
|
|
465
|
+
* Used by cli-ui.cjs at every Basic Auth check. Constant-time comparison
|
|
466
|
+
* via crypto.timingSafeEqual.
|
|
467
|
+
*/
|
|
468
|
+
function verifyAdminPassword(candidate) {
|
|
469
|
+
const stored = readAdminFile();
|
|
470
|
+
if (!stored || stored.scheme !== 'scrypt') return false;
|
|
471
|
+
const salt = Buffer.from(stored.salt, 'base64');
|
|
472
|
+
const expected = Buffer.from(stored.hash, 'base64');
|
|
473
|
+
const params = stored.params || SCRYPT_PARAMS;
|
|
474
|
+
let actual;
|
|
475
|
+
try {
|
|
476
|
+
actual = crypto.scryptSync(candidate, salt, params.dkLen, {
|
|
477
|
+
N: params.N,
|
|
478
|
+
r: params.r,
|
|
479
|
+
p: params.p,
|
|
480
|
+
});
|
|
481
|
+
} catch {
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
if (actual.length !== expected.length) return false;
|
|
485
|
+
return crypto.timingSafeEqual(actual, expected);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function ensureAdminPassword({ rotate = false } = {}) {
|
|
489
|
+
const existing = readAdminFile();
|
|
490
|
+
if (existing && !rotate) return null;
|
|
491
|
+
const password = generateAdminPassword();
|
|
492
|
+
writeAdminFile({ password, rotated: !!existing });
|
|
493
|
+
return password;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function cmdAuthRotate() {
|
|
497
|
+
const password = ensureAdminPassword({ rotate: true });
|
|
498
|
+
if (!password) {
|
|
499
|
+
fail('admin password rotation produced no new password (unexpected)');
|
|
500
|
+
}
|
|
501
|
+
process.stdout.write(`pgserve: admin password rotated. New password (printed ONCE):\n\n ${password}\n\n`);
|
|
502
|
+
process.stdout.write(`Saved hash to ${getAdminFilePath()} (mode 0600).\n`);
|
|
503
|
+
process.stdout.write(`Existing browser sessions will be re-prompted on their next request.\n`);
|
|
504
|
+
return 0;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function cmdAuthDispatch(args) {
|
|
508
|
+
const sub = args[0];
|
|
509
|
+
switch (sub) {
|
|
510
|
+
case 'rotate-admin-password':
|
|
511
|
+
return cmdAuthRotate();
|
|
512
|
+
case 'show-admin-path':
|
|
513
|
+
process.stdout.write(`${getAdminFilePath()}\n`);
|
|
514
|
+
return 0;
|
|
515
|
+
default:
|
|
516
|
+
fail(`pgserve auth: unknown subcommand "${sub ?? ''}". Try: rotate-admin-password | show-admin-path`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* `pgserve install [--port N] [--data PATH] [--no-ui] [--ui-port N]`
|
|
272
522
|
*
|
|
273
523
|
* Idempotent. When the process is already registered, prints a reuse line
|
|
274
524
|
* and exits 0 without touching anything. Otherwise: writes `~/.pgserve/
|
|
275
525
|
* config.json` (creating the dir if needed), then registers the process
|
|
276
526
|
* under pm2 with the hardened defaults.
|
|
277
527
|
*
|
|
528
|
+
* Since v2.2.3: also auto-supervises the autopg console UI under pm2 as
|
|
529
|
+
* `autopg-ui` (default port 8433). Opt out via `--no-ui`. The UI is a thin
|
|
530
|
+
* http server bound to 127.0.0.1 — single-user dev tool, no auth, no TLS.
|
|
531
|
+
*
|
|
278
532
|
* `scriptPath` is the path to `bin/postgres-server.js` resolved by the
|
|
279
533
|
* wrapper before this module is required (avoids re-resolving here).
|
|
280
534
|
*/
|
|
@@ -286,14 +540,48 @@ function cmdInstall(args, ctx) {
|
|
|
286
540
|
const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
|
|
287
541
|
const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
|
|
288
542
|
|
|
289
|
-
|
|
290
|
-
const
|
|
543
|
+
const noUi = args.includes('--no-ui');
|
|
544
|
+
const withUi = args.includes('--with-ui');
|
|
545
|
+
const redeploy = args.includes('--redeploy');
|
|
546
|
+
const uiPort = parseUiPort(args) ?? DEFAULT_UI_PORT;
|
|
547
|
+
const uiHost = parseUiHost(args) ?? DEFAULT_UI_HOST;
|
|
548
|
+
|
|
549
|
+
if (noUi && withUi) {
|
|
550
|
+
fail('--no-ui and --with-ui are mutually exclusive');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// --with-ui: UI-only path. Don't touch the daemon — register or refresh
|
|
554
|
+
// autopg-ui only. Useful for v2.2.2 → v2.2.3 upgrades where the daemon
|
|
555
|
+
// is fine and only the UI is missing, AND for changing UI host/port
|
|
556
|
+
// post-install without restarting postgres.
|
|
557
|
+
if (withUi) {
|
|
558
|
+
cmdInstallUi(ctx, { uiPort, uiHost, refresh: true });
|
|
559
|
+
return 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// --redeploy: full reset. Tear down both processes, then proceed with
|
|
563
|
+
// a fresh install. Equivalent to `autopg uninstall && autopg install`
|
|
564
|
+
// but in one verb.
|
|
565
|
+
if (redeploy) {
|
|
566
|
+
const had = pm2GetProcess(PM2_PROCESS_NAME);
|
|
567
|
+
if (had) {
|
|
568
|
+
spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
569
|
+
}
|
|
570
|
+
spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
571
|
+
note('--redeploy: removed any existing pm2 processes; reinstalling fresh');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Idempotent: already-registered = no-op success. Still reconcile the UI
|
|
575
|
+
// process so re-running `autopg install` after an upgrade picks up the UI
|
|
576
|
+
// even on hosts where the daemon was registered pre-v2.2.3.
|
|
577
|
+
const existing = redeploy ? null : pm2GetProcess(PM2_PROCESS_NAME);
|
|
291
578
|
if (existing) {
|
|
292
579
|
ok(`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
|
|
293
580
|
// Refresh config in case install was re-run with new flags — but
|
|
294
581
|
// don't tear down the live process. Operators wanting a port change
|
|
295
|
-
// should `uninstall` then `install
|
|
582
|
+
// should `uninstall` then `install` (or pass --redeploy).
|
|
296
583
|
writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
|
|
584
|
+
if (!noUi) cmdInstallUi(ctx, { uiPort, uiHost });
|
|
297
585
|
return 0;
|
|
298
586
|
}
|
|
299
587
|
|
|
@@ -309,6 +597,25 @@ function cmdInstall(args, ctx) {
|
|
|
309
597
|
writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
|
|
310
598
|
ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
|
|
311
599
|
ok(`url: postgres://localhost:${port}/postgres`);
|
|
600
|
+
|
|
601
|
+
if (noUi) {
|
|
602
|
+
note('--no-ui set; skipping console install. Run `autopg ui` on demand.');
|
|
603
|
+
} else {
|
|
604
|
+
// Generate admin password BEFORE starting the UI process, so the UI
|
|
605
|
+
// server reads admin.json on first request without a race. Print
|
|
606
|
+
// ONCE — operator must copy now or run `autopg auth rotate-admin-
|
|
607
|
+
// password` to get a new one.
|
|
608
|
+
const newPassword = ensureAdminPassword();
|
|
609
|
+
if (newPassword) {
|
|
610
|
+
process.stdout.write('\n');
|
|
611
|
+
process.stdout.write(` 🔑 ADMIN PASSWORD (printed ONCE — saved hash at ${getAdminFilePath()}):\n`);
|
|
612
|
+
process.stdout.write(` ${newPassword}\n`);
|
|
613
|
+
process.stdout.write('\n');
|
|
614
|
+
process.stdout.write(' Browser will prompt on first access. Rotate via:\n');
|
|
615
|
+
process.stdout.write(' autopg auth rotate-admin-password\n\n');
|
|
616
|
+
}
|
|
617
|
+
cmdInstallUi(ctx, { uiPort, uiHost, refresh: redeploy });
|
|
618
|
+
}
|
|
312
619
|
return 0;
|
|
313
620
|
}
|
|
314
621
|
|
|
@@ -320,9 +627,13 @@ function cmdInstall(args, ctx) {
|
|
|
320
627
|
* downstream service still depends on the data.
|
|
321
628
|
*/
|
|
322
629
|
function cmdUninstall() {
|
|
630
|
+
// Delete the daemon first — it's the load-bearing process and the order
|
|
631
|
+
// of "what existed at uninstall start" is determined by the daemon's
|
|
632
|
+
// pm2 registry, not the UI's. UI cleanup follows; soft-fails if absent.
|
|
323
633
|
const existing = pm2GetProcess(PM2_PROCESS_NAME);
|
|
324
634
|
if (!existing) {
|
|
325
635
|
ok(`not registered under pm2 (nothing to uninstall)`);
|
|
636
|
+
cmdUninstallUi();
|
|
326
637
|
return 0;
|
|
327
638
|
}
|
|
328
639
|
const result = spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: 'inherit' });
|
|
@@ -330,6 +641,7 @@ function cmdUninstall() {
|
|
|
330
641
|
fail(`pm2 delete failed (exit ${result.status})`);
|
|
331
642
|
}
|
|
332
643
|
ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
|
|
644
|
+
cmdUninstallUi();
|
|
333
645
|
return 0;
|
|
334
646
|
}
|
|
335
647
|
|
|
@@ -435,6 +747,25 @@ function parseDataDir(args) {
|
|
|
435
747
|
return path.resolve(v);
|
|
436
748
|
}
|
|
437
749
|
|
|
750
|
+
function parseUiPort(args) {
|
|
751
|
+
const i = args.indexOf('--ui-port');
|
|
752
|
+
if (i < 0) return null;
|
|
753
|
+
const v = args[i + 1];
|
|
754
|
+
if (!v) fail('--ui-port requires a value');
|
|
755
|
+
const n = Number.parseInt(v, 10);
|
|
756
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`invalid --ui-port "${v}"`);
|
|
757
|
+
return n;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function parseUiHost(args) {
|
|
761
|
+
const i = args.indexOf('--ui-host');
|
|
762
|
+
if (i < 0) return null;
|
|
763
|
+
const v = args[i + 1];
|
|
764
|
+
if (!v) fail('--ui-host requires a value');
|
|
765
|
+
// Pass through verbatim. cli-ui.cjs warns on non-loopback at bind time.
|
|
766
|
+
return v;
|
|
767
|
+
}
|
|
768
|
+
|
|
438
769
|
/**
|
|
439
770
|
* One-shot migration check from `~/.pgserve/` → `~/.autopg/`. Runs once
|
|
440
771
|
* per process at the top of dispatch() so every CLI entry point gets
|
|
@@ -510,6 +841,8 @@ function dispatch(subcommand, args, ctx) {
|
|
|
510
841
|
const ui = require('./cli-ui.cjs');
|
|
511
842
|
return ui.dispatch(args, { scriptPath: ctx.wrapperPath });
|
|
512
843
|
}
|
|
844
|
+
case 'auth':
|
|
845
|
+
return cmdAuthDispatch(args);
|
|
513
846
|
default:
|
|
514
847
|
throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
|
|
515
848
|
}
|
|
@@ -518,6 +851,10 @@ function dispatch(subcommand, args, ctx) {
|
|
|
518
851
|
module.exports = {
|
|
519
852
|
// Public API for the wrapper.
|
|
520
853
|
dispatch,
|
|
854
|
+
// Auth surface used by cli-ui.cjs.
|
|
855
|
+
verifyAdminPassword,
|
|
856
|
+
getAdminFilePath,
|
|
857
|
+
readAdminFile,
|
|
521
858
|
// Test surface.
|
|
522
859
|
_internals: {
|
|
523
860
|
HARDENED_DEFAULTS,
|
|
@@ -533,5 +870,9 @@ module.exports = {
|
|
|
533
870
|
getEffectiveSupervision,
|
|
534
871
|
parsePort,
|
|
535
872
|
parseDataDir,
|
|
873
|
+
generateAdminPassword,
|
|
874
|
+
hashAdminPassword,
|
|
875
|
+
writeAdminFile,
|
|
876
|
+
ensureAdminPassword,
|
|
536
877
|
},
|
|
537
878
|
};
|
package/src/cli-ui.cjs
CHANGED
|
@@ -80,11 +80,15 @@ function parseArgs(args) {
|
|
|
80
80
|
} else if (a === '--no-open') {
|
|
81
81
|
out.noOpen = true;
|
|
82
82
|
} else if (a === '--host') {
|
|
83
|
-
// Defense: still bind 127.0.0.1 unless explicitly opted out via env.
|
|
84
|
-
// We accept --host for parity but ignore non-loopback values.
|
|
85
83
|
const v = args[++i];
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
out.host = v;
|
|
85
|
+
// Loud warning on non-loopback. The console has no auth, no TLS —
|
|
86
|
+
// exposing it on the LAN means any host on the network reads your
|
|
87
|
+
// settings file. Accepted (operator opted in) but flagged.
|
|
88
|
+
if (v !== '127.0.0.1' && v !== 'localhost') {
|
|
89
|
+
process.stderr.write(
|
|
90
|
+
`autopg ui: WARNING binding to ${v} (not loopback) — console has no auth, anyone reaching this address can read settings\n`,
|
|
91
|
+
);
|
|
88
92
|
}
|
|
89
93
|
}
|
|
90
94
|
}
|
|
@@ -142,8 +146,25 @@ function listenWithFallback(server, host, preferredPort) {
|
|
|
142
146
|
* via npm the `files` allowlist preserves the layout.
|
|
143
147
|
*/
|
|
144
148
|
function resolveConsoleRoot() {
|
|
145
|
-
//
|
|
146
|
-
|
|
149
|
+
// After autopg-console-dist (v2.2.2): the SPA ships pre-bundled in
|
|
150
|
+
// console/dist/ instead of as flat .jsx files at console/. Prefer dist/;
|
|
151
|
+
// fall back to console/src/ for repo-checkout dev mode (where dist/ is
|
|
152
|
+
// gitignored and only built on demand).
|
|
153
|
+
const consoleParent = path.resolve(__dirname, '..', 'console');
|
|
154
|
+
const distRoot = path.join(consoleParent, 'dist');
|
|
155
|
+
if (fs.existsSync(distRoot)) return distRoot;
|
|
156
|
+
|
|
157
|
+
const srcRoot = path.join(consoleParent, 'src');
|
|
158
|
+
if (fs.existsSync(srcRoot)) {
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
'autopg ui: running unbuilt sources from console/src/ — run `bun run console:build` for production behavior\n',
|
|
161
|
+
);
|
|
162
|
+
return srcRoot;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new Error(
|
|
166
|
+
'console assets not found: expected console/dist/ (run `bun run console:build`) or console/src/ (repo checkout)',
|
|
167
|
+
);
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
/**
|
|
@@ -445,12 +466,66 @@ function serveFile(res, filePath) {
|
|
|
445
466
|
* `bin/pgserve-wrapper.cjs` (used for shell-outs). `ctx.consoleRoot`
|
|
446
467
|
* defaults to the repo's `console/` directory.
|
|
447
468
|
*/
|
|
469
|
+
// Lazy-load the auth verifier to avoid a require cycle with cli-install.
|
|
470
|
+
function getAuthVerifier() {
|
|
471
|
+
try {
|
|
472
|
+
return require('./cli-install.cjs').verifyAdminPassword;
|
|
473
|
+
} catch {
|
|
474
|
+
return () => false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Basic Auth gate. Returns true if the request is authorized (or auth is
|
|
480
|
+
* disabled via env), false if the response has been sent (401). The handler
|
|
481
|
+
* MUST stop processing when this returns false.
|
|
482
|
+
*/
|
|
483
|
+
function requireAuth(req, res) {
|
|
484
|
+
// Escape hatch: AUTOPG_DISABLE_AUTH=1 only honored when bound loopback.
|
|
485
|
+
// The startServer loop binds to opts.host (default 127.0.0.1); this check
|
|
486
|
+
// refuses the bypass when the request's interface isn't loopback to keep
|
|
487
|
+
// it useful for CI/tests but useless for accidentally-exposed UIs.
|
|
488
|
+
if (process.env.AUTOPG_DISABLE_AUTH === '1') {
|
|
489
|
+
const remote = req.socket?.remoteAddress || '';
|
|
490
|
+
if (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1') {
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const verify = getAuthVerifier();
|
|
496
|
+
const header = req.headers['authorization'] || '';
|
|
497
|
+
const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/.exec(header);
|
|
498
|
+
if (m) {
|
|
499
|
+
const decoded = Buffer.from(m[1], 'base64').toString('utf8');
|
|
500
|
+
const colonIdx = decoded.indexOf(':');
|
|
501
|
+
if (colonIdx >= 0) {
|
|
502
|
+
// Username is ignored — single-tenant tool. Only the password matters.
|
|
503
|
+
const pw = decoded.slice(colonIdx + 1);
|
|
504
|
+
if (verify(pw)) return true;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
res.writeHead(401, {
|
|
509
|
+
'www-authenticate': 'Basic realm="autopg console"',
|
|
510
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
511
|
+
'cache-control': 'no-store',
|
|
512
|
+
});
|
|
513
|
+
res.end(
|
|
514
|
+
'autopg console requires authentication.\n' +
|
|
515
|
+
'Username: any (e.g. "admin")\n' +
|
|
516
|
+
'Password: see the value printed by `autopg install` or run `autopg auth rotate-admin-password`.\n',
|
|
517
|
+
);
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
448
521
|
function createHandler(ctx = {}) {
|
|
449
522
|
const consoleRoot = ctx.consoleRoot || resolveConsoleRoot();
|
|
450
523
|
return function handler(req, res) {
|
|
451
524
|
const url = req.url || '/';
|
|
452
525
|
const method = req.method || 'GET';
|
|
453
526
|
|
|
527
|
+
if (!requireAuth(req, res)) return;
|
|
528
|
+
|
|
454
529
|
if (url.startsWith('/api/')) {
|
|
455
530
|
if (url === '/api/settings' && method === 'GET') return handleGetSettings(req, res);
|
|
456
531
|
if (url === '/api/settings' && method === 'PUT') return handlePutSettings(req, res);
|