pgserve 2.3.0 → 2.5.0

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.
Files changed (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
@@ -1,223 +0,0 @@
1
- /**
2
- * Admin DB client — small Bun.SQL wrapper exposing the `{query, end}`
3
- * surface that `src/control-db.js` expects.
4
- *
5
- * The daemon and the `pgserve daemon issue-token / revoke-token` CLI
6
- * subcommands both need a privileged connection to the underlying
7
- * Postgres instance owned by `PostgresManager`. The pg npm module is
8
- * a devDependency only (it backs the test harness); rather than promote
9
- * it to runtime we wrap Bun.SQL — which is shipped with the runtime —
10
- * in the parameterised-query interface control-db.js documents.
11
- *
12
- * Connection target:
13
- * - Local Unix socket when `socketDir` is provided (the daemon's
14
- * hot path) — drops the bytes onto the kernel-local socket.
15
- * - TCP fallback when `socketDir` is null (e.g. CI hosts without
16
- * the embedded socket directory present).
17
- *
18
- * The CLI side reads the daemon's discovery file at
19
- * `${controlSocketDir}/admin.json` to learn `{socketDir, port}`.
20
- */
21
-
22
- import { SQL } from 'bun';
23
- import fs from 'fs';
24
- import path from 'path';
25
-
26
- /**
27
- * @param {object} args
28
- * @param {string|null} [args.socketDir] — accepted for parity with the
29
- * embedded-postgres callers but unused; Bun.SQL's startup auth path
30
- * does not currently traverse `pg_hba.conf` Unix-socket trust rules
31
- * against `embedded-postgres`, so we always go TCP for admin work.
32
- * Keeping the parameter avoids a churning call-site signature.
33
- * @param {string} [args.host='127.0.0.1']
34
- * @param {number} args.port
35
- * @param {string} [args.database='postgres']
36
- * @param {string} [args.user='postgres']
37
- * @param {string} [args.password='postgres']
38
- * @param {number} [args.max=2]
39
- * @param {number} [args.idleTimeout=300]
40
- * @param {number} [args.queryTimeoutMs=0]
41
- * @returns {Promise<{supportsQueryOptions: boolean, query: (text: string, params?: any[], opts?: {timeoutMs?: number}) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
42
- */
43
- export async function createAdminClient({
44
- socketDir: _socketDir = null,
45
- host = '127.0.0.1',
46
- port,
47
- database = 'postgres',
48
- user = 'postgres',
49
- password = 'postgres',
50
- max = 2,
51
- idleTimeout = 300,
52
- queryTimeoutMs = 0,
53
- } = {}) {
54
- if (typeof port !== 'number') throw new Error('createAdminClient: port required');
55
- const options = {
56
- hostname: host,
57
- port,
58
- database,
59
- username: user,
60
- password,
61
- max,
62
- idleTimeout,
63
- };
64
- let sql = new SQL(options);
65
- // Light probe so a misconfigured daemon fails loudly here rather than at
66
- // first query.
67
- await sql`SELECT 1`;
68
-
69
- async function reopen() {
70
- const closing = sql;
71
- sql = new SQL(options);
72
- void closing.close().catch(() => { /* swallow */ });
73
- await sql`SELECT 1`;
74
- }
75
-
76
- return {
77
- supportsQueryOptions: true,
78
- get sql() {
79
- return sql;
80
- },
81
- async query(text, params = [], opts = {}) {
82
- // control-db.js is written for the pg npm module's contract, which
83
- // requires JSON-stringified payloads bound to JSONB parameters.
84
- // Bun.SQL goes the other way: it stringifies JS objects when they
85
- // hit JSONB columns, but a JS string headed for `::jsonb` is sent
86
- // as a JSON string literal (i.e. `"\"..."\"` rather than the array
87
- // it represents). Bridge the impedance mismatch here so the same
88
- // call sites work against either driver.
89
- const adapted = params.map(coerceJsonbParam);
90
- const timeoutMs = opts.timeoutMs ?? queryTimeoutMs;
91
- try {
92
- return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
93
- } catch (err) {
94
- if (!isRetriableAdminQueryError(err)) throw err;
95
- await reopen();
96
- return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
97
- }
98
- },
99
- async end() {
100
- try { await sql.close(); } catch { /* swallow */ }
101
- },
102
- };
103
- }
104
-
105
- async function runQueryWithTimeout(sql, text, params, queryTimeoutMs) {
106
- const query = runQuery(sql, text, params);
107
- return withTimeout(query, queryTimeoutMs);
108
- }
109
-
110
- async function runQuery(sql, text, params) {
111
- const rows = await sql.unsafe(text, params);
112
- // Bun returns an Array of plain objects with `count` set on it; turn
113
- // JSONB columns back into JS values so control-db.js's parseTokens
114
- // sees the array-of-objects shape it would receive from pg.
115
- const out = Array.from(rows).map(decodeJsonColumns);
116
- return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
117
- }
118
-
119
- function withTimeout(promise, timeoutMs) {
120
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
121
- let timer;
122
- const timeout = new Promise((_, reject) => {
123
- timer = setTimeout(() => {
124
- const err = new Error(`admin query timed out after ${timeoutMs}ms`);
125
- err.code = 'EADMINQUERYTIMEOUT';
126
- reject(err);
127
- }, timeoutMs);
128
- timer.unref?.();
129
- });
130
- promise.catch(() => { /* handled by the race winner */ });
131
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
132
- }
133
-
134
- function isRetriableAdminQueryError(err) {
135
- const code = err?.code;
136
- if (['EADMINQUERYTIMEOUT', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ConnectionClosed'].includes(code)) return true;
137
- const message = err?.message || String(err);
138
- return /connection (?:closed|terminated|reset)|socket closed|timeout|CONNECTION_ENDED|CONNECTION_DESTROYED/i.test(message);
139
- }
140
-
141
- /**
142
- * Strings shaped like a JSON array or object are unwrapped so Bun.SQL's
143
- * automatic JSONB serialiser sees the JS value (not a quoted JSON string).
144
- * Anything else is passed through untouched. This mirrors what node-pg
145
- * does implicitly when the column type is JSONB.
146
- */
147
- function coerceJsonbParam(p) {
148
- if (typeof p !== 'string') return p;
149
- const trimmed = p.trim();
150
- if (trimmed.length === 0) return p;
151
- const first = trimmed[0];
152
- if (first !== '[' && first !== '{') return p;
153
- try {
154
- return JSON.parse(p);
155
- } catch {
156
- return p;
157
- }
158
- }
159
-
160
- /**
161
- * Bun.SQL returns JSONB values as the JSON text rather than parsed JS.
162
- * Re-parse the obvious cases so callers expecting node-pg's auto-decoded
163
- * shape get arrays/objects.
164
- */
165
- function decodeJsonColumns(row) {
166
- const out = {};
167
- for (const key of Object.keys(row)) {
168
- const v = row[key];
169
- if (typeof v === 'string' && (v.startsWith('[') || v.startsWith('{'))) {
170
- try { out[key] = JSON.parse(v); } catch { out[key] = v; }
171
- } else {
172
- out[key] = v;
173
- }
174
- }
175
- return out;
176
- }
177
-
178
- /**
179
- * Daemon-side: write a small JSON file that issue-token / revoke-token
180
- * subcommands read to find the admin socket.
181
- *
182
- * @param {object} args
183
- * @param {string} args.controlSocketDir
184
- * @param {string|null} args.socketDir — PG socket directory (nullable on Windows)
185
- * @param {number} args.port
186
- * @returns {string} the absolute path to the discovery file
187
- */
188
- export function writeAdminDiscovery({ controlSocketDir, socketDir, port }) {
189
- const file = path.join(controlSocketDir, 'admin.json');
190
- const payload = {
191
- socketDir,
192
- port,
193
- host: socketDir ? null : '127.0.0.1',
194
- pid: process.pid,
195
- written_at: new Date().toISOString(),
196
- };
197
- fs.writeFileSync(file, JSON.stringify(payload), { mode: 0o600 });
198
- return file;
199
- }
200
-
201
- /**
202
- * CLI-side: read the daemon's discovery file.
203
- *
204
- * @param {string} controlSocketDir
205
- * @returns {{socketDir: string|null, port: number, host: string|null}}
206
- */
207
- export function readAdminDiscovery(controlSocketDir) {
208
- const file = path.join(controlSocketDir, 'admin.json');
209
- const raw = fs.readFileSync(file, 'utf8');
210
- return JSON.parse(raw);
211
- }
212
-
213
- /**
214
- * CLI-side: best-effort cleanup at daemon shutdown.
215
- *
216
- * @param {string} controlSocketDir
217
- */
218
- export function removeAdminDiscovery(controlSocketDir) {
219
- const file = path.join(controlSocketDir, 'admin.json');
220
- try { fs.unlinkSync(file); } catch (e) {
221
- if (e.code !== 'ENOENT') throw e;
222
- }
223
- }
package/src/audit.js DELETED
@@ -1,168 +0,0 @@
1
- /**
2
- * pgserve audit log — JSONL writer with in-process rotation + syslog tier.
3
- *
4
- * Tier 1 (default): `~/.pgserve/audit.log`, rotated 50 MB × 5 files.
5
- * Tier 2 (opt-in): local syslog via `logger -t pgserve-audit`, one spawn per event.
6
- * Tier 3 (HTTP webhook): deferred to v2.1.
7
- *
8
- * Configuration source-of-truth is the active package.json's
9
- * `pgserve.audit.target` field; the daemon (Group 3) resolves it per peer
10
- * and threads the value through `audit(event, fields, { target })`.
11
- *
12
- * The event names defined for v2.0 (one row per audit() call):
13
- * db_created
14
- * db_reaped_ttl
15
- * db_reaped_liveness
16
- * db_persist_honored
17
- * connection_routed
18
- * connection_denied_fingerprint_mismatch
19
- * enforcement_kill_switch_used
20
- * tcp_token_issued
21
- * tcp_token_used
22
- * tcp_token_denied
23
- */
24
-
25
- import fs from 'fs';
26
- import os from 'os';
27
- import path from 'path';
28
- import { spawn } from 'child_process';
29
-
30
- export const AUDIT_EVENTS = Object.freeze({
31
- DB_CREATED: 'db_created',
32
- DB_REAPED_TTL: 'db_reaped_ttl',
33
- DB_REAPED_LIVENESS: 'db_reaped_liveness',
34
- DB_PERSIST_HONORED: 'db_persist_honored',
35
- CONNECTION_ROUTED: 'connection_routed',
36
- CONNECTION_DENIED_FINGERPRINT_MISMATCH: 'connection_denied_fingerprint_mismatch',
37
- ENFORCEMENT_KILL_SWITCH_USED: 'enforcement_kill_switch_used',
38
- TCP_TOKEN_ISSUED: 'tcp_token_issued',
39
- TCP_TOKEN_USED: 'tcp_token_used',
40
- TCP_TOKEN_DENIED: 'tcp_token_denied',
41
- });
42
-
43
- const VALID_EVENTS = new Set(Object.values(AUDIT_EVENTS));
44
-
45
- const ROTATE_THRESHOLD_BYTES = 50 * 1024 * 1024; // 50 MB
46
- const ROTATE_KEEP = 5;
47
-
48
- let DEFAULT_LOG_DIR = path.join(os.homedir(), '.pgserve');
49
- let DEFAULT_LOG_PATH = path.join(DEFAULT_LOG_DIR, 'audit.log');
50
- let DEFAULT_TARGET = process.env.PGSERVE_AUDIT_TARGET || 'file';
51
-
52
- /**
53
- * Override the default log path. Used by tests and by the daemon if it
54
- * needs to redirect audit output (e.g. when XDG_DATA_HOME is set).
55
- *
56
- * @param {{logFile?: string, target?: 'file'|'syslog'}} cfg
57
- */
58
- export function configureAudit(cfg = {}) {
59
- if (cfg.logFile) {
60
- DEFAULT_LOG_PATH = cfg.logFile;
61
- DEFAULT_LOG_DIR = path.dirname(cfg.logFile);
62
- }
63
- if (cfg.target) {
64
- DEFAULT_TARGET = cfg.target;
65
- }
66
- }
67
-
68
- /**
69
- * Read pgserve.audit.target from a package.json (returns 'file' if absent).
70
- * Group 3 calls this per-peer once it has resolved the peer's package.json.
71
- *
72
- * @param {string} packageJsonPath
73
- * @returns {'file'|'syslog'}
74
- */
75
- export function readAuditTarget(packageJsonPath) {
76
- try {
77
- const raw = fs.readFileSync(packageJsonPath, 'utf8');
78
- const pkg = JSON.parse(raw);
79
- const target = pkg?.pgserve?.audit?.target;
80
- if (target === 'syslog') return 'syslog';
81
- return 'file';
82
- } catch {
83
- return 'file';
84
- }
85
- }
86
-
87
- /**
88
- * Write one audit event.
89
- *
90
- * @param {string} event — one of AUDIT_EVENTS values
91
- * @param {Record<string, unknown>} [fields] — event-specific payload
92
- * @param {object} [opts]
93
- * @param {'file'|'syslog'} [opts.target]
94
- * @param {string} [opts.logFile]
95
- */
96
- export function audit(event, fields = {}, opts = {}) {
97
- if (!VALID_EVENTS.has(event)) {
98
- throw new Error(`audit: unknown event "${event}". Allowed: ${[...VALID_EVENTS].join(', ')}`);
99
- }
100
- const record = {
101
- ts: new Date().toISOString(),
102
- event,
103
- ...fields,
104
- };
105
- const line = JSON.stringify(record);
106
- const target = opts.target || DEFAULT_TARGET;
107
-
108
- if (target === 'syslog') {
109
- writeSyslog(line);
110
- return;
111
- }
112
- writeFile(line, opts.logFile || DEFAULT_LOG_PATH);
113
- }
114
-
115
- function writeFile(line, logFile) {
116
- const dir = path.dirname(logFile);
117
- if (!fs.existsSync(dir)) {
118
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
119
- }
120
- rotateIfNeeded(logFile, Buffer.byteLength(line, 'utf8') + 1 /* newline */);
121
- fs.appendFileSync(logFile, line + '\n', { mode: 0o600 });
122
- }
123
-
124
- function rotateIfNeeded(logFile, incomingBytes) {
125
- let size = 0;
126
- try {
127
- size = fs.statSync(logFile).size;
128
- } catch {
129
- return; // file does not exist yet
130
- }
131
- if (size + incomingBytes <= ROTATE_THRESHOLD_BYTES) return;
132
-
133
- // Cascade .N → .(N+1), drop the eldest.
134
- const oldest = `${logFile}.${ROTATE_KEEP}`;
135
- if (fs.existsSync(oldest)) {
136
- fs.unlinkSync(oldest);
137
- }
138
- for (let i = ROTATE_KEEP - 1; i >= 1; i--) {
139
- const src = `${logFile}.${i}`;
140
- const dst = `${logFile}.${i + 1}`;
141
- if (fs.existsSync(src)) fs.renameSync(src, dst);
142
- }
143
- fs.renameSync(logFile, `${logFile}.1`);
144
- }
145
-
146
- function writeSyslog(line) {
147
- // logger -t <tag> is POSIX-standard; spawn detached, do not block.
148
- // Stderr/stdout discarded — audit must never throw at call sites.
149
- try {
150
- const child = spawn('logger', ['-t', 'pgserve-audit', line], {
151
- stdio: 'ignore',
152
- detached: false,
153
- });
154
- child.on('error', () => { /* logger missing — swallow */ });
155
- } catch {
156
- // ENOENT / EACCES — swallow; audit must never break the daemon.
157
- }
158
- }
159
-
160
- /**
161
- * Internal: expose rotation constants so tests can drive coverage cleanly
162
- * without depending on actual 50 MB writes.
163
- */
164
- export const _internals = Object.freeze({
165
- ROTATE_THRESHOLD_BYTES,
166
- ROTATE_KEEP,
167
- rotateIfNeeded,
168
- });