pgserve 1.2.0 → 2.0.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.
- package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
- package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
- package/.github/workflows/ci.yml +8 -4
- package/.github/workflows/version.yml +2 -2
- package/CHANGELOG.md +150 -0
- package/README.md +186 -1
- package/bin/pglite-server.js +253 -1
- package/eslint.config.js +2 -0
- package/package.json +1 -1
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +11 -0
- package/src/protocol.js +131 -0
- package/src/router.js +8 -0
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +109 -0
- package/tests/daemon-pr24-regression.test.js +201 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
package/src/gc.js
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve GC — 3-layer lifecycle sweep (Group 5).
|
|
3
|
+
*
|
|
4
|
+
* Decides which user databases to reap based on:
|
|
5
|
+
* 1. `persist=true` — exempt from GC, audited as `db_persist_honored`.
|
|
6
|
+
* 2. Liveness — if `liveness_pid` points at a running process, slide
|
|
7
|
+
* `last_connection_at` forward to "now" (the peer is alive, the row is
|
|
8
|
+
* a heartbeat) and never reap.
|
|
9
|
+
* 3. TTL — peer is gone AND `now - last_connection_at > ttlMs` (default
|
|
10
|
+
* 24h) → `DROP DATABASE`, delete the meta row, audit reap event.
|
|
11
|
+
*
|
|
12
|
+
* Audit reap event is `db_reaped_liveness` when the row had a non-null
|
|
13
|
+
* liveness_pid that is now dead, otherwise `db_reaped_ttl` (the row never
|
|
14
|
+
* registered a liveness_pid — pure idle expiry).
|
|
15
|
+
*
|
|
16
|
+
* `installSweepTriggers(daemon, …)` wires the three call sites:
|
|
17
|
+
* - boot: a single sweep right after the daemon is listening, with a
|
|
18
|
+
* summary log line so operators see GC activity at startup.
|
|
19
|
+
* - hourly `setInterval` (configurable via `intervalMs`).
|
|
20
|
+
* - on-connect sampling: subscribe to the daemon's `'accept'` event and
|
|
21
|
+
* fire `gcSweep` async at rate 1/N where `N = max(1, dbCount/10)`. The
|
|
22
|
+
* listener never awaits the sweep, so accept latency is unaffected.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { audit, AUDIT_EVENTS } from './audit.js';
|
|
26
|
+
import { forEachReapable, deleteMetaRow, touchLastConnection } from './control-db.js';
|
|
27
|
+
|
|
28
|
+
const TTL_MS_DEFAULT = 24 * 60 * 60 * 1000;
|
|
29
|
+
const HOURLY_MS = 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default liveness probe — POSIX `kill(pid, 0)` returns 0 if the process is
|
|
33
|
+
* alive, throws ESRCH if gone, EPERM if owned by another user (still alive).
|
|
34
|
+
*
|
|
35
|
+
* @param {number|null|undefined} pid
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function defaultIsProcessAlive(pid) {
|
|
39
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return err.code === 'EPERM';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} GcSweepOptions
|
|
50
|
+
* @property {{query: Function}} adminClient — pgserve admin DB connection
|
|
51
|
+
* @property {{adminPool: any, createdDatabases?: Set<string>}} [pgManager] —
|
|
52
|
+
* optional; used to evict from the in-process createdDatabases cache after
|
|
53
|
+
* a successful DROP. Tests can omit; gcSweep always falls back to the
|
|
54
|
+
* adminClient's `query()` for the actual DROP.
|
|
55
|
+
* @property {number|Date} [now]
|
|
56
|
+
* @property {number} [ttlMs] — defaults to 24h
|
|
57
|
+
* @property {boolean} [dryRun] — when true, never DROP / DELETE / audit reap
|
|
58
|
+
* @property {(pid: number|null|undefined) => boolean} [isProcessAlive]
|
|
59
|
+
* @property {{warn?: Function, info?: Function, error?: Function, debug?: Function}} [logger]
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} GcSweepResult
|
|
64
|
+
* @property {number} examined
|
|
65
|
+
* @property {number} reaped
|
|
66
|
+
* @property {number} kept
|
|
67
|
+
* @property {number} persistSkipped
|
|
68
|
+
* @property {number} aliveSkipped
|
|
69
|
+
* @property {string[]} reapedNames
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run one GC sweep. Returns counts so callers can log a summary or assert
|
|
74
|
+
* in tests.
|
|
75
|
+
*
|
|
76
|
+
* @param {GcSweepOptions} opts
|
|
77
|
+
* @returns {Promise<GcSweepResult>}
|
|
78
|
+
*/
|
|
79
|
+
export async function gcSweep({
|
|
80
|
+
adminClient,
|
|
81
|
+
pgManager = null,
|
|
82
|
+
now = new Date(),
|
|
83
|
+
ttlMs = TTL_MS_DEFAULT,
|
|
84
|
+
dryRun = false,
|
|
85
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
86
|
+
logger,
|
|
87
|
+
} = {}) {
|
|
88
|
+
if (!adminClient) throw new Error('gcSweep: adminClient required');
|
|
89
|
+
|
|
90
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
91
|
+
if (!Number.isFinite(nowMs)) throw new Error('gcSweep: now must be Date or numeric ms');
|
|
92
|
+
|
|
93
|
+
const result = {
|
|
94
|
+
examined: 0,
|
|
95
|
+
reaped: 0,
|
|
96
|
+
kept: 0,
|
|
97
|
+
persistSkipped: 0,
|
|
98
|
+
aliveSkipped: 0,
|
|
99
|
+
reapedNames: [],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Snapshot so we don't iterate while we DELETE — pg's async iterator
|
|
103
|
+
// protocols vary across drivers, but materialising 240 rows is cheap and
|
|
104
|
+
// sidesteps any cursor-vs-DELETE quirks.
|
|
105
|
+
const candidates = [];
|
|
106
|
+
for await (const row of forEachReapable(adminClient)) {
|
|
107
|
+
candidates.push(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const row of candidates) {
|
|
111
|
+
result.examined += 1;
|
|
112
|
+
|
|
113
|
+
// Persist=true rows never appear from forEachReapable (the query filters
|
|
114
|
+
// them out), but if the schema changes that contract we still defend
|
|
115
|
+
// here — and emit the audit event the wish promises.
|
|
116
|
+
if (row.persist) {
|
|
117
|
+
result.persistSkipped += 1;
|
|
118
|
+
result.kept += 1;
|
|
119
|
+
if (!dryRun) {
|
|
120
|
+
audit(AUDIT_EVENTS.DB_PERSIST_HONORED, {
|
|
121
|
+
database: row.databaseName,
|
|
122
|
+
fingerprint: row.fingerprint,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const livenessPid = row.livenessPid;
|
|
129
|
+
const hadLivenessPid = Number.isInteger(livenessPid) && livenessPid > 0;
|
|
130
|
+
const alive = hadLivenessPid && isProcessAlive(livenessPid);
|
|
131
|
+
|
|
132
|
+
if (alive) {
|
|
133
|
+
result.aliveSkipped += 1;
|
|
134
|
+
result.kept += 1;
|
|
135
|
+
if (!dryRun) {
|
|
136
|
+
// Slide the window: an alive process means the row is effectively
|
|
137
|
+
// current, even if the pgserve_meta last_connection_at value lags.
|
|
138
|
+
try {
|
|
139
|
+
await touchLastConnection(adminClient, {
|
|
140
|
+
databaseName: row.databaseName,
|
|
141
|
+
livenessPid,
|
|
142
|
+
});
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger?.warn?.(
|
|
145
|
+
{ err: err?.message || String(err), database: row.databaseName },
|
|
146
|
+
'gcSweep: touchLastConnection failed for live row (non-fatal)',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const lastMs = row.lastConnectionAt instanceof Date
|
|
154
|
+
? row.lastConnectionAt.getTime()
|
|
155
|
+
: Number(row.lastConnectionAt);
|
|
156
|
+
const ageMs = Number.isFinite(lastMs) ? nowMs - lastMs : Infinity;
|
|
157
|
+
|
|
158
|
+
if (ageMs <= ttlMs) {
|
|
159
|
+
result.kept += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
result.reaped += 1;
|
|
165
|
+
result.reapedNames.push(row.databaseName);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await dropDatabaseSafely(adminClient, row.databaseName, logger);
|
|
171
|
+
pgManager?.createdDatabases?.delete(row.databaseName);
|
|
172
|
+
await deleteMetaRow(adminClient, row.databaseName);
|
|
173
|
+
const reapEvent = hadLivenessPid
|
|
174
|
+
? AUDIT_EVENTS.DB_REAPED_LIVENESS
|
|
175
|
+
: AUDIT_EVENTS.DB_REAPED_TTL;
|
|
176
|
+
audit(reapEvent, {
|
|
177
|
+
database: row.databaseName,
|
|
178
|
+
fingerprint: row.fingerprint,
|
|
179
|
+
last_connection_at: row.lastConnectionAt instanceof Date
|
|
180
|
+
? row.lastConnectionAt.toISOString()
|
|
181
|
+
: row.lastConnectionAt,
|
|
182
|
+
liveness_pid: livenessPid ?? null,
|
|
183
|
+
age_ms: Number.isFinite(ageMs) ? ageMs : null,
|
|
184
|
+
});
|
|
185
|
+
result.reaped += 1;
|
|
186
|
+
result.reapedNames.push(row.databaseName);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logger?.error?.(
|
|
189
|
+
{ err: err?.message || String(err), database: row.databaseName },
|
|
190
|
+
'gcSweep: failed to reap database',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function dropDatabaseSafely(adminClient, databaseName, logger) {
|
|
199
|
+
const escaped = `"${databaseName.replace(/"/g, '""')}"`;
|
|
200
|
+
// Terminate any lingering backends so DROP DATABASE doesn't refuse with
|
|
201
|
+
// 55006 (object_in_use). The peer's pgserve daemon socket is already gone
|
|
202
|
+
// (liveness dead) but Postgres can hold idle backends a while longer.
|
|
203
|
+
try {
|
|
204
|
+
await adminClient.query(
|
|
205
|
+
`SELECT pg_terminate_backend(pid)
|
|
206
|
+
FROM pg_stat_activity
|
|
207
|
+
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
|
208
|
+
[databaseName],
|
|
209
|
+
);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logger?.debug?.(
|
|
212
|
+
{ err: err?.message || String(err), database: databaseName },
|
|
213
|
+
'gcSweep: pg_terminate_backend failed (non-fatal)',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
await adminClient.query(`DROP DATABASE IF EXISTS ${escaped}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Wire the three sweep call sites onto a running daemon.
|
|
221
|
+
*
|
|
222
|
+
* Returns a `{stop()}` handle so tests (and `daemon.stop()`) can detach.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} daemon — PgserveDaemon instance
|
|
225
|
+
* @param {object} [opts]
|
|
226
|
+
* @param {{query: Function}} [opts.adminClient] — defaults to daemon._adminClient
|
|
227
|
+
* @param {number} [opts.intervalMs] — hourly default; pass 0 to disable
|
|
228
|
+
* @param {number} [opts.ttlMs]
|
|
229
|
+
* @param {(pid: number) => boolean} [opts.isProcessAlive]
|
|
230
|
+
* @param {() => Promise<number>|number} [opts.getDbCount] — defaults to a
|
|
231
|
+
* COUNT(*) query against pgserve_meta
|
|
232
|
+
* @param {boolean} [opts.bootSweep=true]
|
|
233
|
+
* @returns {{stop: () => Promise<void>, sweep: () => Promise<GcSweepResult>}}
|
|
234
|
+
*/
|
|
235
|
+
export function installSweepTriggers(daemon, opts = {}) {
|
|
236
|
+
const adminClient = opts.adminClient || daemon._adminClient;
|
|
237
|
+
if (!adminClient) {
|
|
238
|
+
throw new Error('installSweepTriggers: daemon has no admin client');
|
|
239
|
+
}
|
|
240
|
+
const intervalMs = opts.intervalMs == null ? HOURLY_MS : opts.intervalMs;
|
|
241
|
+
const ttlMs = opts.ttlMs == null ? TTL_MS_DEFAULT : opts.ttlMs;
|
|
242
|
+
const logger = daemon.logger;
|
|
243
|
+
const pgManager = daemon.pgManager;
|
|
244
|
+
const isProcessAlive = opts.isProcessAlive || defaultIsProcessAlive;
|
|
245
|
+
const getDbCount = opts.getDbCount || (async () => {
|
|
246
|
+
try {
|
|
247
|
+
const r = await adminClient.query('SELECT count(*)::int AS n FROM pgserve_meta');
|
|
248
|
+
return r.rows?.[0]?.n ?? 0;
|
|
249
|
+
} catch {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
let stopped = false;
|
|
255
|
+
let inflight = false;
|
|
256
|
+
let lastDbCount = 0;
|
|
257
|
+
|
|
258
|
+
const runSweep = async () => {
|
|
259
|
+
if (stopped) return null;
|
|
260
|
+
if (inflight) return null;
|
|
261
|
+
inflight = true;
|
|
262
|
+
try {
|
|
263
|
+
const res = await gcSweep({
|
|
264
|
+
adminClient,
|
|
265
|
+
pgManager,
|
|
266
|
+
now: new Date(),
|
|
267
|
+
ttlMs,
|
|
268
|
+
isProcessAlive,
|
|
269
|
+
logger,
|
|
270
|
+
});
|
|
271
|
+
lastDbCount = Math.max(0, lastDbCount - res.reaped);
|
|
272
|
+
return res;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger?.error?.(
|
|
275
|
+
{ err: err?.message || String(err) },
|
|
276
|
+
'gcSweep failed',
|
|
277
|
+
);
|
|
278
|
+
return null;
|
|
279
|
+
} finally {
|
|
280
|
+
inflight = false;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
let timer = null;
|
|
285
|
+
if (intervalMs > 0) {
|
|
286
|
+
timer = setInterval(() => {
|
|
287
|
+
void runSweep();
|
|
288
|
+
}, intervalMs);
|
|
289
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const acceptListener = () => {
|
|
293
|
+
// Sample 1/N where N = max(1, ceil(dbCount/10)). Always async and
|
|
294
|
+
// detached so accept latency isn't blocked.
|
|
295
|
+
const n = Math.max(1, Math.ceil(lastDbCount / 10));
|
|
296
|
+
if (n === 1 || Math.random() * n < 1) {
|
|
297
|
+
setImmediate(() => {
|
|
298
|
+
if (stopped) return;
|
|
299
|
+
// Refresh count opportunistically before each sweep so on-connect
|
|
300
|
+
// sampling tracks the live row count without polling.
|
|
301
|
+
Promise.resolve(getDbCount())
|
|
302
|
+
.then((c) => { lastDbCount = Number(c) || 0; })
|
|
303
|
+
.then(runSweep)
|
|
304
|
+
.catch(() => { /* swallowed by runSweep */ });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
daemon.on?.('accept', acceptListener);
|
|
309
|
+
|
|
310
|
+
const handle = {
|
|
311
|
+
sweep: runSweep,
|
|
312
|
+
async stop() {
|
|
313
|
+
stopped = true;
|
|
314
|
+
if (timer) {
|
|
315
|
+
clearInterval(timer);
|
|
316
|
+
timer = null;
|
|
317
|
+
}
|
|
318
|
+
daemon.off?.('accept', acceptListener);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
if (opts.bootSweep !== false) {
|
|
323
|
+
// Boot sweep + count refresh + summary log. Detached so we don't block
|
|
324
|
+
// start() — the daemon is already listening at this point.
|
|
325
|
+
setImmediate(async () => {
|
|
326
|
+
try {
|
|
327
|
+
lastDbCount = Number(await getDbCount()) || 0;
|
|
328
|
+
const res = await runSweep();
|
|
329
|
+
if (res) {
|
|
330
|
+
logger?.info?.(
|
|
331
|
+
{
|
|
332
|
+
examined: res.examined,
|
|
333
|
+
reaped: res.reaped,
|
|
334
|
+
kept: res.kept,
|
|
335
|
+
persist_skipped: res.persistSkipped,
|
|
336
|
+
alive_skipped: res.aliveSkipped,
|
|
337
|
+
},
|
|
338
|
+
'pgserve GC: boot sweep complete',
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
} catch (err) {
|
|
342
|
+
logger?.warn?.(
|
|
343
|
+
{ err: err?.message || String(err) },
|
|
344
|
+
'pgserve GC: boot sweep failed',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return handle;
|
|
351
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,17 @@ export { RestoreManager } from './restore.js';
|
|
|
13
13
|
export { Dashboard } from './dashboard.js';
|
|
14
14
|
export { StatsCollector } from './stats-collector.js';
|
|
15
15
|
export { StatsDashboard } from './stats-dashboard.js';
|
|
16
|
+
export {
|
|
17
|
+
PgserveDaemon,
|
|
18
|
+
startDaemon,
|
|
19
|
+
stopDaemon,
|
|
20
|
+
resolveControlSocketDir,
|
|
21
|
+
resolveControlSocketPath,
|
|
22
|
+
resolvePidLockPath,
|
|
23
|
+
resolveLibpqCompatPath,
|
|
24
|
+
acquirePidLock,
|
|
25
|
+
isProcessAlive,
|
|
26
|
+
} from './daemon.js';
|
|
16
27
|
|
|
17
28
|
// Default export
|
|
18
29
|
export { startMultiTenantServer as default } from './router.js';
|
package/src/protocol.js
CHANGED
|
@@ -133,6 +133,137 @@ export function extractDatabaseName(data) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Extract `application_name` from a startup message buffer. Returns null when
|
|
138
|
+
* absent or when the buffer is malformed (callers fall back to no-auth).
|
|
139
|
+
*
|
|
140
|
+
* @param {Buffer} data
|
|
141
|
+
* @returns {string|null}
|
|
142
|
+
*/
|
|
143
|
+
export function extractApplicationName(data) {
|
|
144
|
+
try {
|
|
145
|
+
const params = parseStartupMessage(data, /* fastPath */ false);
|
|
146
|
+
return typeof params.application_name === 'string' ? params.application_name : null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Return a new startup-message buffer with the `database` parameter replaced
|
|
154
|
+
* by `newDbName`. All other parameters (and their order) are preserved by
|
|
155
|
+
* default; pass `dropParams: ['application_name', ...]` to strip noisy
|
|
156
|
+
* fields the daemon would rather not forward to PG verbatim. The 4-byte
|
|
157
|
+
* length prefix at the start of the buffer is recomputed.
|
|
158
|
+
*
|
|
159
|
+
* Group 6 uses this on TCP-authenticated connections so a peer that presents
|
|
160
|
+
* a token for fingerprint X is forced into fingerprint X's database, even
|
|
161
|
+
* if the libpq client requested a different one.
|
|
162
|
+
*
|
|
163
|
+
* @param {Buffer} data — original startup message
|
|
164
|
+
* @param {string} newDbName
|
|
165
|
+
* @param {{dropParams?: string[]}} [opts]
|
|
166
|
+
* @returns {Buffer}
|
|
167
|
+
*/
|
|
168
|
+
export function rewriteDatabaseName(data, newDbName, opts = {}) {
|
|
169
|
+
if (!Buffer.isBuffer(data)) throw new Error('rewriteDatabaseName: buffer required');
|
|
170
|
+
if (typeof newDbName !== 'string' || newDbName.length === 0) {
|
|
171
|
+
throw new Error('rewriteDatabaseName: non-empty newDbName required');
|
|
172
|
+
}
|
|
173
|
+
const length = data.readInt32BE(0);
|
|
174
|
+
const version = data.readInt32BE(4);
|
|
175
|
+
const drop = new Set(opts.dropParams || []);
|
|
176
|
+
|
|
177
|
+
// Walk parameters; build a list of (key, value) pairs replacing 'database'.
|
|
178
|
+
const pairs = [];
|
|
179
|
+
let offset = 8;
|
|
180
|
+
let sawDatabase = false;
|
|
181
|
+
while (offset < length - 1) {
|
|
182
|
+
const keyEnd = data.indexOf(0, offset);
|
|
183
|
+
if (keyEnd === -1 || keyEnd >= length) break;
|
|
184
|
+
const key = data.toString('utf8', offset, keyEnd);
|
|
185
|
+
offset = keyEnd + 1;
|
|
186
|
+
const valueEnd = data.indexOf(0, offset);
|
|
187
|
+
if (valueEnd === -1 || valueEnd >= length) break;
|
|
188
|
+
const value = data.toString('utf8', offset, valueEnd);
|
|
189
|
+
offset = valueEnd + 1;
|
|
190
|
+
if (drop.has(key)) continue;
|
|
191
|
+
if (key === 'database') {
|
|
192
|
+
pairs.push(['database', newDbName]);
|
|
193
|
+
sawDatabase = true;
|
|
194
|
+
} else {
|
|
195
|
+
pairs.push([key, value]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!sawDatabase) pairs.push(['database', newDbName]);
|
|
199
|
+
|
|
200
|
+
// Compute new buffer size: 4 (length) + 4 (version) + sum(key+1 + value+1) + 1 (terminator).
|
|
201
|
+
let bodyLen = 0;
|
|
202
|
+
for (const [k, v] of pairs) {
|
|
203
|
+
bodyLen += Buffer.byteLength(k, 'utf8') + 1 + Buffer.byteLength(v, 'utf8') + 1;
|
|
204
|
+
}
|
|
205
|
+
const total = 4 + 4 + bodyLen + 1;
|
|
206
|
+
const out = Buffer.alloc(total);
|
|
207
|
+
out.writeInt32BE(total, 0);
|
|
208
|
+
out.writeInt32BE(version, 4);
|
|
209
|
+
let cur = 8;
|
|
210
|
+
for (const [k, v] of pairs) {
|
|
211
|
+
cur += out.write(k, cur, 'utf8');
|
|
212
|
+
out[cur++] = 0;
|
|
213
|
+
cur += out.write(v, cur, 'utf8');
|
|
214
|
+
out[cur++] = 0;
|
|
215
|
+
}
|
|
216
|
+
out[cur++] = 0; // final terminator
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a PostgreSQL ErrorResponse (`'E'`) frame.
|
|
222
|
+
*
|
|
223
|
+
* Used by the daemon to reject cross-fingerprint connection attempts
|
|
224
|
+
* with SQLSTATE `28P01 invalid_authorization_specification` before the
|
|
225
|
+
* peer's startup message ever reaches the underlying PG instance.
|
|
226
|
+
*
|
|
227
|
+
* Frame layout (PG protocol v3):
|
|
228
|
+
* 'E' (1 byte) | length (4 bytes, includes itself) | <fields...> | '\0'
|
|
229
|
+
*
|
|
230
|
+
* Each field: type-byte | utf8 string | '\0'
|
|
231
|
+
* Required fields per PG docs: 'S' (Severity), 'C' (SQLSTATE), 'M' (Message).
|
|
232
|
+
* 'V' (localized severity, server >= 9.6) is included for parity with the
|
|
233
|
+
* frames real Postgres emits — psql / pg drivers parse both transparently.
|
|
234
|
+
*
|
|
235
|
+
* @param {{severity?: string, sqlstate: string, message: string}} args
|
|
236
|
+
* @returns {Buffer}
|
|
237
|
+
*/
|
|
238
|
+
export function buildErrorResponse({ severity = 'FATAL', sqlstate, message }) {
|
|
239
|
+
if (typeof sqlstate !== 'string' || sqlstate.length !== 5) {
|
|
240
|
+
throw new TypeError('buildErrorResponse: sqlstate must be a 5-character string');
|
|
241
|
+
}
|
|
242
|
+
if (typeof message !== 'string' || message.length === 0) {
|
|
243
|
+
throw new TypeError('buildErrorResponse: message must be a non-empty string');
|
|
244
|
+
}
|
|
245
|
+
const field = (typeChar, value) => {
|
|
246
|
+
const valBytes = Buffer.byteLength(value, 'utf8');
|
|
247
|
+
const buf = Buffer.alloc(1 + valBytes + 1);
|
|
248
|
+
buf.writeUInt8(typeChar.charCodeAt(0), 0);
|
|
249
|
+
buf.write(value, 1, 'utf8');
|
|
250
|
+
buf.writeUInt8(0, 1 + valBytes);
|
|
251
|
+
return buf;
|
|
252
|
+
};
|
|
253
|
+
const body = Buffer.concat([
|
|
254
|
+
field('S', severity),
|
|
255
|
+
field('V', severity),
|
|
256
|
+
field('C', sqlstate),
|
|
257
|
+
field('M', message),
|
|
258
|
+
Buffer.from([0]),
|
|
259
|
+
]);
|
|
260
|
+
const frameLength = 4 + body.length;
|
|
261
|
+
const header = Buffer.alloc(5);
|
|
262
|
+
header.writeUInt8(0x45, 0); // 'E'
|
|
263
|
+
header.writeUInt32BE(frameLength, 1);
|
|
264
|
+
return Buffer.concat([header, body]);
|
|
265
|
+
}
|
|
266
|
+
|
|
136
267
|
// Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
|
|
137
268
|
const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
|
|
138
269
|
const bufferPool = [];
|
package/src/router.js
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* - Memory mode (default) or persistent storage
|
|
13
13
|
*
|
|
14
14
|
* PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
|
|
15
|
+
*
|
|
16
|
+
* v2 NOTE: The MultiTenantRouter is the **direct-embed** path — callers that
|
|
17
|
+
* spawn their own PostgresManager and bind a TCP port get a per-pid Unix
|
|
18
|
+
* socket from `pgManager.getSocketPath()` (preserved by PR #24). The new
|
|
19
|
+
* **daemon** path (`src/daemon.js`) binds a singleton control socket at
|
|
20
|
+
* `$XDG_RUNTIME_DIR/pgserve/control.sock` and is the v2 default for the
|
|
21
|
+
* `pgserve daemon` CLI subcommand. Both paths coexist; direct-embed callers
|
|
22
|
+
* are not affected by daemon mode.
|
|
15
23
|
*/
|
|
16
24
|
|
|
17
25
|
import fs from 'fs';
|
package/src/tenancy.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve tenancy — fingerprint-to-database name resolution + kill-switch.
|
|
3
|
+
*
|
|
4
|
+
* Group 4 wires the kernel-rooted fingerprint (Group 3) to the per-tenant
|
|
5
|
+
* Postgres database. Each `(fingerprint, name)` pair maps deterministically
|
|
6
|
+
* to a database called `app_<sanitized-name>_<12hex>` (≤63 chars, the PG
|
|
7
|
+
* identifier limit).
|
|
8
|
+
*
|
|
9
|
+
* Sanitization rules (per WISH §Group 4):
|
|
10
|
+
* - non-[a-z0-9] runs collapse to a single `_`
|
|
11
|
+
* - lowercased
|
|
12
|
+
* - truncated to 30 chars (so `app_<30>_<12>` ≤ 47 chars, well under 63)
|
|
13
|
+
*
|
|
14
|
+
* The kill switch (`PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`) is read
|
|
15
|
+
* once per process via `isFingerprintEnforcementDisabled()`. The daemon
|
|
16
|
+
* logs a deprecation warning at boot when the env var is observed; the
|
|
17
|
+
* audit event `enforcement_kill_switch_used` fires on every bypassed
|
|
18
|
+
* cross-fingerprint connection.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const KILL_SWITCH_ENV = 'PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT';
|
|
22
|
+
|
|
23
|
+
const NAME_TRUNCATE = 30;
|
|
24
|
+
const MAX_DB_IDENT = 63;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Collapse non-alphanumeric runs to a single `_`, lowercase, truncate.
|
|
28
|
+
*
|
|
29
|
+
* Empty or null names fall back to `'anon'` so we always emit a usable
|
|
30
|
+
* database identifier — a peer with no resolvable package name still
|
|
31
|
+
* deserves a tenant DB, just one that visibly says "anonymous".
|
|
32
|
+
*
|
|
33
|
+
* @param {string|null|undefined} name
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizeName(name) {
|
|
37
|
+
const raw = (typeof name === 'string' ? name : '').toLowerCase();
|
|
38
|
+
const collapsed = raw.replace(/[^a-z0-9]+/g, '_');
|
|
39
|
+
if (!collapsed || collapsed === '_') return 'anon';
|
|
40
|
+
return collapsed.slice(0, NAME_TRUNCATE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the canonical per-tenant database name `app_<sanitized>_<fingerprint>`.
|
|
45
|
+
*
|
|
46
|
+
* Throws if fingerprint is not the documented 12 lowercase-hex blob —
|
|
47
|
+
* any caller that managed to slip a malformed fingerprint through deserves
|
|
48
|
+
* a loud failure rather than a silent identifier mismatch later.
|
|
49
|
+
*
|
|
50
|
+
* @param {{name: string|null|undefined, fingerprint: string}} args
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function resolveTenantDatabaseName({ name, fingerprint }) {
|
|
54
|
+
if (!/^[0-9a-f]{12}$/.test(fingerprint || '')) {
|
|
55
|
+
throw new Error(`resolveTenantDatabaseName: fingerprint must be 12 hex chars, got "${fingerprint}"`);
|
|
56
|
+
}
|
|
57
|
+
const sanitized = sanitizeName(name);
|
|
58
|
+
const ident = `app_${sanitized}_${fingerprint}`;
|
|
59
|
+
if (ident.length > MAX_DB_IDENT) {
|
|
60
|
+
// Truncation already bounds sanitized to 30; the fingerprint adds 12;
|
|
61
|
+
// the prefix `app_` adds 4 + two underscores = 48. We are safe by
|
|
62
|
+
// construction, but assert anyway: a future change to NAME_TRUNCATE
|
|
63
|
+
// must not silently produce >63-char identifiers.
|
|
64
|
+
throw new Error(`resolveTenantDatabaseName: identifier "${ident}" exceeds ${MAX_DB_IDENT} chars`);
|
|
65
|
+
}
|
|
66
|
+
return ident;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
export function isFingerprintEnforcementDisabled(env = process.env) {
|
|
74
|
+
return env[KILL_SWITCH_ENV] === '1';
|
|
75
|
+
}
|
package/src/tokens.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve TCP bearer-token helpers (Group 6).
|
|
3
|
+
*
|
|
4
|
+
* Tokens are random 256-bit secrets shown to the operator exactly once
|
|
5
|
+
* (the output of `pgserve daemon issue-token`). Only their sha256 hash
|
|
6
|
+
* is persisted in `pgserve_meta.allowed_tokens`. Verification therefore
|
|
7
|
+
* compares hashes, never cleartext.
|
|
8
|
+
*
|
|
9
|
+
* Token id: short hex prefix used for revocation by humans
|
|
10
|
+
* (`pgserve daemon revoke-token <id>`). It is also persisted alongside
|
|
11
|
+
* the hash so `tcp_token_used` audit events can name which credential
|
|
12
|
+
* authorised the connection without leaking the secret.
|
|
13
|
+
*
|
|
14
|
+
* Wire format on the TCP path: peers pass an `application_name` shaped
|
|
15
|
+
* `?fingerprint=<12hex>&token=<bearer>` (a leading `?` is tolerated so
|
|
16
|
+
* libpq URL-style strings round-trip cleanly). Both keys are required;
|
|
17
|
+
* any missing or extra-long value is treated as auth-fail by the
|
|
18
|
+
* daemon's accept hook, never bubbling further.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import crypto from 'crypto';
|
|
22
|
+
|
|
23
|
+
const TOKEN_BYTES = 32; // 256 bits — plenty of entropy
|
|
24
|
+
const TOKEN_ID_BYTES = 6; // 12 hex chars — collision-bound at ~10^14
|
|
25
|
+
const MAX_TOKEN_LEN = 256; // sanity guard for parse path
|
|
26
|
+
const FP_RE = /^[0-9a-f]{12}$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Mint a fresh `(id, cleartext, hash)` triple. The cleartext is meant to
|
|
30
|
+
* leave this process exactly once (printed to stdout by `issue-token`);
|
|
31
|
+
* only the hash gets stored.
|
|
32
|
+
*
|
|
33
|
+
* @returns {{id: string, cleartext: string, hash: string}}
|
|
34
|
+
*/
|
|
35
|
+
export function mintToken() {
|
|
36
|
+
const id = crypto.randomBytes(TOKEN_ID_BYTES).toString('hex');
|
|
37
|
+
const cleartext = crypto.randomBytes(TOKEN_BYTES).toString('hex');
|
|
38
|
+
const hash = hashToken(cleartext);
|
|
39
|
+
return { id, cleartext, hash };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Sha256 of the bearer token in lowercase hex. Centralised so daemon
|
|
44
|
+
* accept code, issue-token CLI, and tests cannot drift.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} cleartext
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function hashToken(cleartext) {
|
|
50
|
+
if (typeof cleartext !== 'string' || cleartext.length === 0) {
|
|
51
|
+
throw new Error('hashToken: non-empty string required');
|
|
52
|
+
}
|
|
53
|
+
return crypto.createHash('sha256').update(cleartext).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse `?fingerprint=<12hex>&token=<bearer>` — or its prefix-less form —
|
|
58
|
+
* out of an `application_name` startup parameter.
|
|
59
|
+
*
|
|
60
|
+
* Returns `null` for any malformed input. Caller never inspects details
|
|
61
|
+
* beyond presence: the daemon emits a single `tcp_token_denied` audit
|
|
62
|
+
* event regardless of which validation step failed, to deny the peer
|
|
63
|
+
* any oracle that distinguishes "unknown fingerprint" from "wrong token".
|
|
64
|
+
*
|
|
65
|
+
* @param {string|undefined|null} applicationName
|
|
66
|
+
* @returns {{fingerprint: string, token: string} | null}
|
|
67
|
+
*/
|
|
68
|
+
export function parseTcpAuth(applicationName) {
|
|
69
|
+
if (typeof applicationName !== 'string' || applicationName.length === 0) return null;
|
|
70
|
+
if (applicationName.length > MAX_TOKEN_LEN + 64) return null;
|
|
71
|
+
const stripped = applicationName.startsWith('?') ? applicationName.slice(1) : applicationName;
|
|
72
|
+
const params = new Map();
|
|
73
|
+
for (const segment of stripped.split('&')) {
|
|
74
|
+
const eq = segment.indexOf('=');
|
|
75
|
+
if (eq <= 0) continue;
|
|
76
|
+
const key = segment.slice(0, eq);
|
|
77
|
+
const val = segment.slice(eq + 1);
|
|
78
|
+
if (key && val) params.set(key, val);
|
|
79
|
+
}
|
|
80
|
+
const fingerprint = params.get('fingerprint');
|
|
81
|
+
const token = params.get('token');
|
|
82
|
+
if (!fingerprint || !token) return null;
|
|
83
|
+
if (!FP_RE.test(fingerprint)) return null;
|
|
84
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LEN) return null;
|
|
85
|
+
return { fingerprint, token };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Constant-time string compare. Bearer-token verification path uses this
|
|
90
|
+
* after sha256 to avoid leaking length-mismatch via timing.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} a
|
|
93
|
+
* @param {string} b
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
export function timingSafeEqual(a, b) {
|
|
97
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
98
|
+
if (a.length !== b.length) return false;
|
|
99
|
+
const bufA = Buffer.from(a);
|
|
100
|
+
const bufB = Buffer.from(b);
|
|
101
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
102
|
+
}
|