pgserve 2.2.2 → 2.2.4
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 +56 -0
- package/package.json +1 -1
- package/src/cli-install.cjs +345 -4
- package/src/cli-ui.cjs +62 -4
- package/src/cluster.js +64 -2
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,62 @@ All notable changes to `pgserve` are documented here. The format follows
|
|
|
14
14
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
15
15
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
16
16
|
|
|
17
|
+
## [2.2.3] - 2026-05-03
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`autopg install` now auto-supervises the console UI under pm2** as a
|
|
22
|
+
separate process named `autopg-ui`. The bundled SPA from v2.2.2 is now
|
|
23
|
+
always available at `http://127.0.0.1:8433` after a fresh install — no
|
|
24
|
+
more "operator runs install, doesn't know the UI exists" gap.
|
|
25
|
+
- **The console now requires a password** (Basic Auth). On first install
|
|
26
|
+
`autopg install` generates a 24-char admin password, prints it ONCE to
|
|
27
|
+
stdout, and stores the scrypt hash in `~/.autopg/admin.json` (mode
|
|
28
|
+
0600). Browsers prompt natively for the password on first visit and
|
|
29
|
+
cache it for the session.
|
|
30
|
+
- **`autopg uninstall` removes both processes** (`autopg-ui` + `pgserve`)
|
|
31
|
+
cleanly.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`autopg auth rotate-admin-password`** — generates a new admin
|
|
36
|
+
password, prints once, updates `admin.json`. Existing browser sessions
|
|
37
|
+
re-prompt on their next request.
|
|
38
|
+
- **`autopg auth show-admin-path`** — prints the path to `admin.json`.
|
|
39
|
+
- **`--with-ui` flag on `autopg install`** — UI-only path. Refreshes
|
|
40
|
+
(or registers) just the `autopg-ui` pm2 process without touching the
|
|
41
|
+
daemon. Useful for changing UI host/port post-install or for
|
|
42
|
+
retrofitting the UI onto a v2.2.2 host without restarting postgres.
|
|
43
|
+
- **`--redeploy` flag on `autopg install`** — full redeploy: tears down
|
|
44
|
+
both pm2 processes and reinstalls fresh. Equivalent to
|
|
45
|
+
`autopg uninstall && autopg install` in one command.
|
|
46
|
+
- **`--no-ui` flag on `autopg install`** — opt out of the UI process for
|
|
47
|
+
CI / headless / server hosts that don't need a permanent localhost web
|
|
48
|
+
server.
|
|
49
|
+
- **`--ui-port N` flag on `autopg install`** — override the default UI
|
|
50
|
+
port (8433).
|
|
51
|
+
- **`--ui-host H` flag on `autopg install`** — override the default UI
|
|
52
|
+
bind host (127.0.0.1). Non-loopback values trigger a loud warning at
|
|
53
|
+
the UI server because the console has no TLS.
|
|
54
|
+
- **`AUTOPG_DISABLE_AUTH=1` env var** — escape hatch for CI / smoke tests.
|
|
55
|
+
Only honored when the request comes from `127.0.0.1` / `::1`; cannot
|
|
56
|
+
accidentally expose an unauthenticated UI on a LAN.
|
|
57
|
+
|
|
58
|
+
### Notes
|
|
59
|
+
|
|
60
|
+
- **Re-run `autopg install` on existing v2.2.2 hosts** to pick up the UI
|
|
61
|
+
auto-supervise + admin password. Idempotent — the daemon is left
|
|
62
|
+
untouched. The first re-run prints the new admin password.
|
|
63
|
+
- **UI process memory cap is 256MB**. Restart budget + exp-backoff are
|
|
64
|
+
shared with the daemon's hardened defaults.
|
|
65
|
+
- **Single-user dev tool boundary, with auth at the door.** Loopback
|
|
66
|
+
binding + Basic Auth + scrypt-hashed password covers the
|
|
67
|
+
"random-local-process-curl'ing-settings" case. Multi-user hosts where
|
|
68
|
+
intra-UID isolation matters should use `--no-ui`.
|
|
69
|
+
- **Hash scheme**: scrypt (RFC 7914, Node built-in since v10.5),
|
|
70
|
+
N=16384, r=8, p=1, 32-byte derived key, 32-byte salt. No npm dep
|
|
71
|
+
added.
|
|
72
|
+
|
|
17
73
|
## [2.2.2] - 2026-05-03
|
|
18
74
|
|
|
19
75
|
### Changed
|
package/package.json
CHANGED
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
|
}
|
|
@@ -462,12 +466,66 @@ function serveFile(res, filePath) {
|
|
|
462
466
|
* `bin/pgserve-wrapper.cjs` (used for shell-outs). `ctx.consoleRoot`
|
|
463
467
|
* defaults to the repo's `console/` directory.
|
|
464
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
|
+
|
|
465
521
|
function createHandler(ctx = {}) {
|
|
466
522
|
const consoleRoot = ctx.consoleRoot || resolveConsoleRoot();
|
|
467
523
|
return function handler(req, res) {
|
|
468
524
|
const url = req.url || '/';
|
|
469
525
|
const method = req.method || 'GET';
|
|
470
526
|
|
|
527
|
+
if (!requireAuth(req, res)) return;
|
|
528
|
+
|
|
471
529
|
if (url.startsWith('/api/')) {
|
|
472
530
|
if (url === '/api/settings' && method === 'GET') return handleGetSettings(req, res);
|
|
473
531
|
if (url === '/api/settings' && method === 'PUT') return handlePutSettings(req, res);
|
package/src/cluster.js
CHANGED
|
@@ -395,6 +395,51 @@ class ClusterRouter extends EventEmitter {
|
|
|
395
395
|
}
|
|
396
396
|
|
|
397
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Build a `backendExited` handler for cluster mode supervision.
|
|
400
|
+
*
|
|
401
|
+
* On unexpected exit (`expected: false`) — postgres SIGKILL'd, OOM-killed,
|
|
402
|
+
* segfaulted, etc. — the handler:
|
|
403
|
+
* 1. logs the exit code,
|
|
404
|
+
* 2. flips `shuttingDown` so the cluster.on('exit') worker-respawn path
|
|
405
|
+
* no longer forks new workers,
|
|
406
|
+
* 3. SIGTERMs every live worker (no point routing to a dead backend), and
|
|
407
|
+
* 4. calls `exitFn(1)` so the parent process supervisor restarts us.
|
|
408
|
+
*
|
|
409
|
+
* On clean exit (`expected: true`, initiated by `pgManager.stop()`) the
|
|
410
|
+
* handler is silent — the surrounding shutdown logic handles teardown.
|
|
411
|
+
*
|
|
412
|
+
* Exported so the supervision contract can be unit-tested without spawning
|
|
413
|
+
* a real cluster (the integration path is already covered for single-process
|
|
414
|
+
* mode by tests/wrapper-supervision.test.js).
|
|
415
|
+
*
|
|
416
|
+
* @param {object} args
|
|
417
|
+
* @param {Map<number, {kill: (sig: string) => void}>} args.workers - live worker registry
|
|
418
|
+
* @param {(v: boolean) => void} args.setShuttingDown - flips outer-scope `shuttingDown`
|
|
419
|
+
* @param {(code: number) => void} [args.exitFn=process.exit] - test seam
|
|
420
|
+
* @param {(...args: unknown[]) => void} [args.log=console.error] - test seam
|
|
421
|
+
* @returns {(info: {code: number, expected: boolean}) => void}
|
|
422
|
+
*/
|
|
423
|
+
export function buildClusterSupervisionHandler({
|
|
424
|
+
workers,
|
|
425
|
+
setShuttingDown,
|
|
426
|
+
exitFn = process.exit,
|
|
427
|
+
log = console.error,
|
|
428
|
+
}) {
|
|
429
|
+
return ({ code, expected }) => {
|
|
430
|
+
if (expected) return;
|
|
431
|
+
log(
|
|
432
|
+
`[pgserve] postgres backend exited unexpectedly (code=${code}) in cluster mode; ` +
|
|
433
|
+
`the primary is exiting so a process supervisor can restart it.`
|
|
434
|
+
);
|
|
435
|
+
setShuttingDown(true);
|
|
436
|
+
for (const worker of workers.values()) {
|
|
437
|
+
try { worker.kill('SIGTERM'); } catch { /* worker may already be dead */ }
|
|
438
|
+
}
|
|
439
|
+
exitFn(1);
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
398
443
|
/**
|
|
399
444
|
* Start pgserve in cluster mode
|
|
400
445
|
*/
|
|
@@ -425,9 +470,27 @@ export async function startClusterServer(options = {}) {
|
|
|
425
470
|
console.log(`[pgserve] Embedded PostgreSQL started`);
|
|
426
471
|
console.log(`[pgserve] Socket: ${pgSocketPath || `TCP port ${pgPort}`}`);
|
|
427
472
|
|
|
473
|
+
// Track shutdown state and worker registry early so the supervision
|
|
474
|
+
// handler below can tear workers down on unexpected backend death.
|
|
475
|
+
let shuttingDown = false;
|
|
428
476
|
const workers = new Map();
|
|
429
477
|
const workerStats = new Map(); // Track stats from each worker
|
|
430
478
|
|
|
479
|
+
// Supervision: when the embedded postgres backend dies unexpectedly
|
|
480
|
+
// (SIGKILL/OOM/segfault — anything other than a clean stop()), exit the
|
|
481
|
+
// primary so a process supervisor (`genie serve`, pm2, systemd) restarts
|
|
482
|
+
// the cluster cleanly with a fresh backend. Without this, the primary
|
|
483
|
+
// keeps running with a zombie pgManager (socketDir nulled) and every
|
|
484
|
+
// worker fails StartupMessages with "Connection closed" forever while
|
|
485
|
+
// pm2 reports the process as healthy. Mirrors the single-process fix
|
|
486
|
+
// in bin/postgres-server.js (PgserveDaemon.on('backendDiedUnexpectedly'))
|
|
487
|
+
// — pgserve#45 only protected the daemon path, not cluster mode (default
|
|
488
|
+
// on multi-core systems).
|
|
489
|
+
pgManager.on('backendExited', buildClusterSupervisionHandler({
|
|
490
|
+
workers,
|
|
491
|
+
setShuttingDown: (v) => { shuttingDown = v; },
|
|
492
|
+
}));
|
|
493
|
+
|
|
431
494
|
// Fork workers with PostgreSQL connection info.
|
|
432
495
|
//
|
|
433
496
|
// Pass through using AUTOPG_<X> (the primary names) so workers don't
|
|
@@ -456,8 +519,7 @@ export async function startClusterServer(options = {}) {
|
|
|
456
519
|
workers.set(worker.id, worker);
|
|
457
520
|
}
|
|
458
521
|
|
|
459
|
-
//
|
|
460
|
-
let shuttingDown = false;
|
|
522
|
+
// (shuttingDown declared above with the supervision handler.)
|
|
461
523
|
|
|
462
524
|
// Restart dead workers (unless shutting down)
|
|
463
525
|
cluster.on('exit', (worker, code, signal) => {
|