pgserve 2.2.4 → 2.4.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.
@@ -1,468 +0,0 @@
1
- /**
2
- * pgserve daemon — Unix control-socket accept path (Group 2 + Group 4).
3
- *
4
- * Accepted on `$XDG_RUNTIME_DIR/pgserve/control.sock`, peers identify via
5
- * SO_PEERCRED → /proc walk → package.json hash, then route into the
6
- * fingerprint's tenant database. The plain TCP path lives in
7
- * `daemon-tcp.js` and shares the same prototype owner.
8
- *
9
- * Methods are attached to `PgserveDaemon.prototype` from `daemon.js` so
10
- * the surface is one cohesive class — splitting modules here is purely
11
- * to honour the 1000-line discipline (AGENTS.md §8).
12
- */
13
-
14
- /* global Bun */
15
- import fs from 'fs';
16
- import { extractDatabaseName, rewriteDatabaseName, buildErrorResponse } from './protocol.js';
17
- import { handleControlAccept, readPersistFlag } from './fingerprint.js';
18
- import { audit, AUDIT_EVENTS } from './audit.js';
19
- import {
20
- findRowByFingerprint,
21
- recordDbCreated,
22
- touchLastConnection,
23
- markPersist,
24
- } from './control-db.js';
25
- import {
26
- resolveTenantDatabaseName,
27
- } from './tenancy.js';
28
- import { flushPending } from './daemon-shared.js';
29
-
30
- const PROTOCOL_VERSION_3 = 196608;
31
- const SSL_REQUEST_CODE = 80877103;
32
- const GSSAPI_REQUEST_CODE = 80877104;
33
- const CANCEL_REQUEST_CODE = 80877102;
34
-
35
- const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB — same bound as router.js
36
-
37
- /**
38
- * Install the Unix control-socket handlers on PgserveDaemon.prototype.
39
- * Called once from daemon.js at module load.
40
- */
41
- export function attachControlHandlers(PgserveDaemon) {
42
- PgserveDaemon.prototype.handleSocketOpen = handleSocketOpen;
43
- PgserveDaemon.prototype.handleSocketData = handleSocketData;
44
- PgserveDaemon.prototype.processStartupMessage = processStartupMessage;
45
- PgserveDaemon.prototype.handleSocketClose = handleSocketClose;
46
- PgserveDaemon.prototype.handleSocketError = handleSocketError;
47
- PgserveDaemon.prototype.resolveTenantDatabase = resolveTenantDatabase;
48
- }
49
-
50
- /**
51
- * Per-accept fingerprint derivation is live: SO_PEERCRED → /proc walk →
52
- * package.json hash. Fingerprint info is parked on socketState so the
53
- * tenant lookup in processStartupMessage doesn't re-derive on every byte.
54
- */
55
- function handleSocketOpen(socket) {
56
- let fingerprint = null;
57
- try {
58
- const opts = this._fingerprintAcceptOpts ? (this._fingerprintAcceptOpts(socket) || {}) : {};
59
- fingerprint = handleControlAccept(socket, opts);
60
- } catch (err) {
61
- this.logger.warn?.(
62
- { err: err?.message || String(err) },
63
- 'Failed to derive peer fingerprint on accept',
64
- );
65
- }
66
- this.socketState.set(socket, {
67
- buffer: null,
68
- pgSocket: null,
69
- dbName: null,
70
- handshakeComplete: false,
71
- startupInProgress: false,
72
- pendingToPg: null,
73
- pendingToClient: null,
74
- fingerprint,
75
- // Wall-clock timestamp when this socket was accepted. The watchdog
76
- // installed by PgserveDaemon.start() forcibly closes any socket that
77
- // hasn't completed its postgres handshake within
78
- // PGSERVE_HANDSHAKE_DEADLINE_MS. Without this, a peer that connects
79
- // and never sends the StartupMessage occupies the connection slot
80
- // forever — the file-descriptor leak documented in pgserve#45.
81
- acceptedAt: Date.now(),
82
- });
83
- this.connections.add(socket);
84
- if (fingerprint) {
85
- this.emit('accept', { fingerprint, socket });
86
- }
87
- }
88
-
89
- function handleSocketData(socket, data) {
90
- const state = this.socketState.get(socket);
91
- if (!state) return;
92
-
93
- if (state.handshakeComplete && state.pgSocket) {
94
- if (state.pendingToPg) {
95
- state.pendingToPg = Buffer.concat([state.pendingToPg, Buffer.from(data)]);
96
- socket.pause();
97
- return;
98
- }
99
- const written = state.pgSocket.write(data);
100
- if (written < data.byteLength) {
101
- state.pendingToPg = written === 0 ? Buffer.from(data) : Buffer.from(data.subarray(written));
102
- socket.pause();
103
- }
104
- return;
105
- }
106
-
107
- const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
108
- if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
109
- this.logger.warn?.(
110
- { incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
111
- 'Pre-handshake buffer exceeded limit — closing connection',
112
- );
113
- socket.end();
114
- return;
115
- }
116
- if (state.buffer) {
117
- state.buffer = Buffer.concat([state.buffer, Buffer.from(data)]);
118
- } else {
119
- state.buffer = Buffer.from(data);
120
- }
121
- this.processStartupMessage(socket, state).catch((err) => {
122
- this.logger.error?.({ err: err.message }, 'processStartupMessage failed');
123
- try { socket.end(); } catch { /* swallow */ }
124
- });
125
- }
126
-
127
- async function processStartupMessage(socket, state) {
128
- if (state.startupInProgress) return;
129
- const buffer = state.buffer;
130
- if (!buffer || buffer.length < 8) return;
131
-
132
- const messageLength = buffer.readUInt32BE(0);
133
- if (buffer.length < messageLength) return;
134
-
135
- const code = buffer.readUInt32BE(4);
136
-
137
- if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
138
- socket.write(Buffer.from('N'));
139
- state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
140
- if (state.buffer) await processStartupMessage.call(this, socket, state);
141
- return;
142
- }
143
-
144
- if (code === CANCEL_REQUEST_CODE) {
145
- socket.end();
146
- return;
147
- }
148
-
149
- if (code !== PROTOCOL_VERSION_3) {
150
- this.logger.warn?.({ code }, 'Unsupported protocol version on control socket');
151
- socket.end();
152
- return;
153
- }
154
-
155
- const startupMessage = buffer.subarray(0, messageLength);
156
- const requestedDb = extractDatabaseName(startupMessage);
157
- state.dbName = requestedDb;
158
- state.startupInProgress = true;
159
-
160
- try {
161
- const resolution = await this.resolveTenantDatabase(state, requestedDb);
162
- if (resolution.deny) {
163
- const errFrame = buildErrorResponse({
164
- severity: 'FATAL',
165
- sqlstate: '28P01',
166
- message: resolution.message,
167
- });
168
- try {
169
- // Bun's TCPSocket.end(data) writes then closes atomically — using
170
- // write()+end() can race the FIN past the data on some kernels and
171
- // leave the peer waiting for AuthOK indefinitely.
172
- socket.end(errFrame);
173
- } catch { /* swallow */ }
174
- this.emit('connection-denied', {
175
- fingerprint: state.fingerprint?.fingerprint || null,
176
- requested: requestedDb,
177
- owned: resolution.ownedDatabaseName,
178
- });
179
- return;
180
- }
181
-
182
- const dbName = resolution.databaseName;
183
- state.dbName = dbName;
184
- const outgoingStartup = (dbName !== requestedDb)
185
- ? rewriteDatabaseName(startupMessage, dbName)
186
- : startupMessage;
187
-
188
- if (this.autoProvision) {
189
- await this.pgManager.createDatabase(dbName);
190
- }
191
-
192
- const pgSocketPath = this.pgManager.getSocketPath();
193
- const daemon = this;
194
- const pgHandler = {
195
- data(_pgSocket, pgData) {
196
- if (state.pendingToClient) {
197
- state.pendingToClient = Buffer.concat([state.pendingToClient, Buffer.from(pgData)]);
198
- _pgSocket.pause();
199
- return;
200
- }
201
- const written = socket.write(pgData);
202
- if (written < pgData.byteLength) {
203
- state.pendingToClient = written === 0
204
- ? Buffer.from(pgData)
205
- : Buffer.from(pgData.subarray(written));
206
- _pgSocket.pause();
207
- }
208
- },
209
- open(pgSocket) {
210
- pgSocket.write(outgoingStartup);
211
- state.handshakeComplete = true;
212
- },
213
- close() {
214
- try { socket.end(); } catch { /* swallow */ }
215
- },
216
- error(_pgSocket, error) {
217
- daemon.logger.error?.({ dbName, err: error?.message || String(error) }, 'PG-side proxy socket error');
218
- try { socket.end(); } catch { /* swallow */ }
219
- },
220
- drain(_pgSocket) {
221
- if (state.pendingToPg) {
222
- state.pendingToPg = flushPending(_pgSocket, state.pendingToPg);
223
- }
224
- if (!state.pendingToPg) {
225
- socket.resume();
226
- }
227
- },
228
- };
229
-
230
- // Same #24 safety net as the router: socketPath might point at a
231
- // directory the PG manager has since cleaned up. Fall back to TCP
232
- // rather than hanging on a missing socket file.
233
- //
234
- // Single-retry-with-backoff: if the first connect attempt fails (the
235
- // backend is mid-restart, OOM-recovering, etc.), wait
236
- // BACKEND_CONNECT_RETRY_DELAY_MS and try once more before giving up.
237
- // On final failure, send the client a postgres ErrorResponse with
238
- // SQLSTATE 57P03 (cannot_connect_now) so libpq clients can distinguish
239
- // "transient backend unavailability" from real auth/network errors —
240
- // pgserve#45 noted that the previous "buffer forever" path was the
241
- // worst possible outcome.
242
- state.pgSocket = await connectBackendWithRetry({
243
- pgSocketPath,
244
- pgPort: this.pgManager.port,
245
- pgHandler,
246
- logger: this.logger,
247
- dbName,
248
- });
249
-
250
- this.emit('connection', { dbName, socket });
251
- } catch (err) {
252
- this.logger.error?.({ dbName: state.dbName, err: err?.message || String(err) }, 'Daemon connection error');
253
- // Tell the client why we're closing rather than just dropping the
254
- // socket — silent drops were one of the recovery footguns documented
255
- // in pgserve#45. 57P03 = cannot_connect_now (Postgres standard).
256
- try {
257
- const errFrame = buildErrorResponse({
258
- severity: 'FATAL',
259
- sqlstate: '57P03',
260
- message: 'backend unavailable, retry shortly',
261
- });
262
- // socket.end(data) writes-then-closes atomically; same idempotent
263
- // pattern used for the 28P01 deny branch above.
264
- socket.end(errFrame);
265
- } catch { /* swallow */ }
266
- this.emit('connection-error', { error: err, dbName: state.dbName });
267
- } finally {
268
- state.startupInProgress = false;
269
- }
270
- }
271
-
272
- const BACKEND_CONNECT_RETRY_DELAY_MS = 200;
273
-
274
- /**
275
- * Connect to the postgres backend with one retry on failure. Honours the
276
- * existing `useUnix vs TCP fallback` policy (PR #24 safety net): every
277
- * attempt re-checks whether the Unix socket path still exists, because the
278
- * PG manager may have nulled it between attempts.
279
- *
280
- * Throws the final connect error after the retry; callers translate that
281
- * into a 57P03 ErrorResponse for the client.
282
- */
283
- async function connectBackendWithRetry({ pgSocketPath, pgPort, pgHandler, logger, dbName }) {
284
- const tryOnce = async () => {
285
- const useUnix = pgSocketPath && fs.existsSync(pgSocketPath);
286
- if (useUnix) {
287
- return await Bun.connect({ unix: pgSocketPath, socket: pgHandler });
288
- }
289
- if (pgSocketPath && !useUnix) {
290
- logger?.warn?.({ pgSocketPath, dbName }, 'PG Unix socket path stale — falling back to TCP');
291
- }
292
- return await Bun.connect({
293
- hostname: '127.0.0.1',
294
- port: pgPort,
295
- socket: pgHandler,
296
- });
297
- };
298
-
299
- try {
300
- return await tryOnce();
301
- } catch (firstErr) {
302
- logger?.warn?.(
303
- { dbName, err: firstErr?.message || String(firstErr), retryAfterMs: BACKEND_CONNECT_RETRY_DELAY_MS },
304
- 'Backend connect failed — retrying once',
305
- );
306
- await new Promise((r) => setTimeout(r, BACKEND_CONNECT_RETRY_DELAY_MS));
307
- return await tryOnce();
308
- }
309
- }
310
-
311
- /**
312
- * Group 4 — wire identity to tenancy.
313
- *
314
- * On first connect: provision a per-fingerprint database, audit the create.
315
- * On reconnect: bump last_connection_at + liveness_pid.
316
- * On cross-tenant attempt: deny with SQLSTATE 28P01, OR (when the kill
317
- * switch is on) bypass and audit `enforcement_kill_switch_used`.
318
- */
319
- async function resolveTenantDatabase(state, requestedDb) {
320
- const fp = state.fingerprint;
321
- // No fingerprint (FFI unavailable, accept hook failed) or no admin
322
- // client (init failed) → behave as v1: route the requested name through
323
- // unchanged. Tenancy enforcement is best-effort, never load-bearing for
324
- // basic connectivity.
325
- if (!fp || !this._adminClient) {
326
- return { databaseName: requestedDb };
327
- }
328
-
329
- const { fingerprint, name, uid, pid, packageRealpath } = fp;
330
- const lookupOpts = { timeoutMs: this.adminLookupTimeoutMs };
331
-
332
- let row = null;
333
- try {
334
- row = await findRowByFingerprint(this._adminClient, fingerprint, lookupOpts);
335
- } catch (err) {
336
- this.logger.warn?.(
337
- { err: err?.message || String(err), fingerprint },
338
- 'pgserve_meta lookup failed — falling back to requested DB',
339
- );
340
- return { databaseName: requestedDb };
341
- }
342
-
343
- // Group 5: read pgserve.persist from the resolved package.json so the row
344
- // we (re)write reflects the peer's lifecycle preference. Script-mode peers
345
- // never get persist=true (no package.json to opt in from).
346
- const persistRequested = packageRealpath ? readPersistFlag(packageRealpath) : false;
347
-
348
- if (!row) {
349
- const newName = resolveTenantDatabaseName({ name, fingerprint });
350
- try {
351
- await this.pgManager.createDatabase(newName);
352
- await recordDbCreated(this._adminClient, {
353
- databaseName: newName,
354
- fingerprint,
355
- peerUid: typeof uid === 'number' ? uid : -1,
356
- packageRealpath: packageRealpath || null,
357
- livenessPid: typeof pid === 'number' && pid > 0 ? pid : null,
358
- persist: persistRequested,
359
- }, lookupOpts);
360
- audit(AUDIT_EVENTS.DB_CREATED, {
361
- database: newName,
362
- fingerprint,
363
- peer_uid: uid,
364
- peer_pid: pid,
365
- package_realpath: packageRealpath || null,
366
- name,
367
- persist: persistRequested,
368
- });
369
- } catch (err) {
370
- this.logger.error?.(
371
- { err: err?.message || String(err), fingerprint, dbName: newName },
372
- 'Failed to provision per-fingerprint database',
373
- );
374
- throw err;
375
- }
376
- row = { databaseName: newName, fingerprint, peerUid: uid, allowedTokens: [] };
377
- } else {
378
- try {
379
- await touchLastConnection(this._adminClient, {
380
- databaseName: row.databaseName,
381
- livenessPid: typeof pid === 'number' && pid > 0 ? pid : null,
382
- }, lookupOpts);
383
- } catch (err) {
384
- this.logger.warn?.(
385
- { err: err?.message || String(err), database: row.databaseName },
386
- 'touchLastConnection failed (non-fatal)',
387
- );
388
- }
389
- // Group 5: keep persist in sync when the peer's package.json toggles the
390
- // flag between connections — the previous run might have started without
391
- // persist:true and the operator just added it (or vice versa).
392
- try {
393
- await markPersist(this._adminClient, row.databaseName, persistRequested, lookupOpts);
394
- } catch (err) {
395
- this.logger.warn?.(
396
- { err: err?.message || String(err), database: row.databaseName },
397
- 'markPersist failed (non-fatal)',
398
- );
399
- }
400
- }
401
-
402
- // Enforcement: peer asked for an explicit database that isn't theirs.
403
- // libpq's default `database = user` is treated the same as `postgres` —
404
- // both are "I don't care, give me whatever you have for me", so we
405
- // silently route them into the fingerprint's DB.
406
- const requested = (typeof requestedDb === 'string' && requestedDb.length > 0)
407
- ? requestedDb
408
- : 'postgres';
409
- const isImplicit = requested === 'postgres' || requested === row.databaseName;
410
- if (!isImplicit) {
411
- if (this.enforcementDisabled) {
412
- audit(AUDIT_EVENTS.ENFORCEMENT_KILL_SWITCH_USED, {
413
- fingerprint,
414
- peer_uid: uid,
415
- peer_pid: pid,
416
- requested_database: requested,
417
- owned_database: row.databaseName,
418
- });
419
- try { await this.pgManager.createDatabase(requested); } catch { /* swallow */ }
420
- return { databaseName: requested };
421
- }
422
- audit(AUDIT_EVENTS.CONNECTION_DENIED_FINGERPRINT_MISMATCH, {
423
- fingerprint,
424
- peer_uid: uid,
425
- peer_pid: pid,
426
- requested_database: requested,
427
- owned_database: row.databaseName,
428
- });
429
- return {
430
- deny: true,
431
- ownedDatabaseName: row.databaseName,
432
- message:
433
- `database fingerprint mismatch: peer ${fingerprint} owns ` +
434
- `${row.databaseName}, requested ${requested}`,
435
- };
436
- }
437
-
438
- return { databaseName: row.databaseName };
439
- }
440
-
441
- function handleSocketClose(socket) {
442
- const state = this.socketState.get(socket);
443
- if (state) {
444
- state.pendingToPg = null;
445
- state.pendingToClient = null;
446
- if (state.pgSocket) {
447
- try { state.pgSocket.end(); } catch { /* swallow */ }
448
- }
449
- }
450
- this.connections.delete(socket);
451
- this.socketState.delete(socket);
452
- }
453
-
454
- function handleSocketError(socket, error) {
455
- const state = this.socketState.get(socket);
456
- if (error?.code !== 'ECONNRESET') {
457
- this.logger.error?.({ err: error?.message || String(error), dbName: state?.dbName }, 'Control socket error');
458
- }
459
- if (state) {
460
- state.pendingToPg = null;
461
- state.pendingToClient = null;
462
- if (state.pgSocket) {
463
- try { state.pgSocket.end(); } catch { /* swallow */ }
464
- }
465
- }
466
- this.connections.delete(socket);
467
- this.socketState.delete(socket);
468
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * Shared helpers for the Unix-socket and TCP accept paths.
3
- *
4
- * Kept as a tiny module so daemon.js stays under 1000 lines (AGENTS.md §8)
5
- * without forcing a circular dep between daemon-control.js and daemon-tcp.js.
6
- */
7
-
8
- /**
9
- * Drain the buffered tail to a Bun socket. Returns the still-pending tail
10
- * (or null when fully flushed). Same shape as the original inline helper
11
- * in daemon.js.
12
- */
13
- export function flushPending(target, pending) {
14
- const written = target.write(pending);
15
- if (written === pending.byteLength) return null;
16
- if (written === 0) return pending;
17
- return pending.subarray(written);
18
- }