pgserve 1.1.10 → 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.
Files changed (43) hide show
  1. package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
  2. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
  3. package/.genie/wishes/pgserve-v2/WISH.md +442 -0
  4. package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
  5. package/.genie/wishes/release-system-genie-pattern/validation.md +205 -0
  6. package/.github/workflows/ci.yml +8 -4
  7. package/.github/workflows/release.yml +233 -111
  8. package/.github/workflows/{build-all-platforms.yml → version.yml} +32 -8
  9. package/AGENTS.md +10 -8
  10. package/CHANGELOG.md +150 -0
  11. package/Makefile +18 -41
  12. package/README.md +186 -1
  13. package/SECURITY.md +109 -0
  14. package/bin/pglite-server.js +253 -1
  15. package/eslint.config.js +2 -0
  16. package/package.json +1 -1
  17. package/src/admin-client.js +171 -0
  18. package/src/audit.js +168 -0
  19. package/src/control-db.js +313 -0
  20. package/src/daemon-control.js +408 -0
  21. package/src/daemon-shared.js +18 -0
  22. package/src/daemon-tcp.js +296 -0
  23. package/src/daemon.js +629 -0
  24. package/src/fingerprint.js +453 -0
  25. package/src/gc.js +351 -0
  26. package/src/index.js +11 -0
  27. package/src/postgres.js +54 -0
  28. package/src/protocol.js +131 -0
  29. package/src/router.js +78 -5
  30. package/src/tenancy.js +75 -0
  31. package/src/tokens.js +102 -0
  32. package/tests/audit.test.js +189 -0
  33. package/tests/control-db.test.js +285 -0
  34. package/tests/daemon-fingerprint-integration.test.js +109 -0
  35. package/tests/daemon-pr24-regression.test.js +201 -0
  36. package/tests/fingerprint.test.js +249 -0
  37. package/tests/fixtures/240-orphan-seed.sql +30 -0
  38. package/tests/multi-tenant.test.js +164 -0
  39. package/tests/orphan-cleanup.test.js +390 -0
  40. package/tests/tcp-listen.test.js +368 -0
  41. package/tests/tenancy.test.js +403 -0
  42. package/.github/release.yml +0 -30
  43. package/scripts/release.cjs +0 -198
@@ -0,0 +1,296 @@
1
+ /**
2
+ * pgserve daemon — opt-in TCP accept path (Group 6).
3
+ *
4
+ * Bound only when `pgserve daemon --listen <host:port>` is set. TCP peers
5
+ * cannot use SO_PEERCRED, so identity is established via a bearer token
6
+ * presented in `application_name` shaped `?fingerprint=<12hex>&token=<bearer>`.
7
+ *
8
+ * Methods are attached to `PgserveDaemon.prototype` from `daemon.js` so
9
+ * the surface stays one cohesive class — the split is purely to honour
10
+ * the 1000-line discipline (AGENTS.md §8).
11
+ */
12
+
13
+ /* global Bun */
14
+ import fs from 'fs';
15
+ import { extractApplicationName, rewriteDatabaseName } from './protocol.js';
16
+ import { audit, AUDIT_EVENTS } from './audit.js';
17
+ import { verifyToken } from './control-db.js';
18
+ import { parseTcpAuth, hashToken } from './tokens.js';
19
+ import { flushPending } from './daemon-shared.js';
20
+
21
+ const PROTOCOL_VERSION_3 = 196608;
22
+ const SSL_REQUEST_CODE = 80877103;
23
+ const GSSAPI_REQUEST_CODE = 80877104;
24
+ const CANCEL_REQUEST_CODE = 80877102;
25
+
26
+ const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB — same bound as router.js
27
+
28
+ /**
29
+ * Install the TCP accept handlers on PgserveDaemon.prototype.
30
+ * Called once from daemon.js at module load.
31
+ */
32
+ export function attachTcpHandlers(PgserveDaemon) {
33
+ PgserveDaemon.prototype.bindTcpListener = bindTcpListener;
34
+ PgserveDaemon.prototype.handleTcpOpen = handleTcpOpen;
35
+ PgserveDaemon.prototype.handleTcpData = handleTcpData;
36
+ PgserveDaemon.prototype.processTcpStartupMessage = processTcpStartupMessage;
37
+ PgserveDaemon.prototype.handleTcpClose = handleTcpClose;
38
+ PgserveDaemon.prototype.handleTcpError = handleTcpError;
39
+ }
40
+
41
+ async function bindTcpListener({ host, port }) {
42
+ const daemon = this;
43
+ return Bun.listen({
44
+ hostname: host,
45
+ port,
46
+ socket: {
47
+ data(socket, data) { daemon.handleTcpData(socket, data); },
48
+ open(socket) { daemon.handleTcpOpen(socket); },
49
+ close(socket) { daemon.handleTcpClose(socket); },
50
+ error(socket, error) { daemon.handleTcpError(socket, error); },
51
+ drain(socket) {
52
+ const state = daemon.socketState.get(socket);
53
+ if (!state) return;
54
+ if (state.pendingToClient) {
55
+ state.pendingToClient = flushPending(socket, state.pendingToClient);
56
+ }
57
+ if (!state.pendingToClient && state.pgSocket) {
58
+ state.pgSocket.resume();
59
+ }
60
+ },
61
+ },
62
+ });
63
+ }
64
+
65
+ function handleTcpOpen(socket) {
66
+ // TCP peers cannot use SO_PEERCRED; identity is established via the
67
+ // application_name token in the startup message.
68
+ this.socketState.set(socket, {
69
+ transport: 'tcp',
70
+ buffer: null,
71
+ pgSocket: null,
72
+ dbName: null,
73
+ handshakeComplete: false,
74
+ startupInProgress: false,
75
+ pendingToPg: null,
76
+ pendingToClient: null,
77
+ fingerprint: null,
78
+ tokenId: null,
79
+ });
80
+ this.connections.add(socket);
81
+ }
82
+
83
+ function handleTcpData(socket, data) {
84
+ const state = this.socketState.get(socket);
85
+ if (!state) return;
86
+
87
+ if (state.handshakeComplete && state.pgSocket) {
88
+ if (state.pendingToPg) {
89
+ state.pendingToPg = Buffer.concat([state.pendingToPg, Buffer.from(data)]);
90
+ socket.pause();
91
+ return;
92
+ }
93
+ const written = state.pgSocket.write(data);
94
+ if (written < data.byteLength) {
95
+ state.pendingToPg = written === 0 ? Buffer.from(data) : Buffer.from(data.subarray(written));
96
+ socket.pause();
97
+ }
98
+ return;
99
+ }
100
+
101
+ const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
102
+ if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
103
+ this.logger.warn?.(
104
+ { incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
105
+ 'TCP pre-handshake buffer exceeded limit — closing connection',
106
+ );
107
+ socket.end();
108
+ return;
109
+ }
110
+ state.buffer = state.buffer ? Buffer.concat([state.buffer, Buffer.from(data)]) : Buffer.from(data);
111
+
112
+ this.processTcpStartupMessage(socket, state).catch((err) => {
113
+ this.logger.error?.({ err: err.message }, 'TCP processStartupMessage failed');
114
+ try { socket.end(); } catch { /* swallow */ }
115
+ });
116
+ }
117
+
118
+ async function processTcpStartupMessage(socket, state) {
119
+ if (state.startupInProgress) return;
120
+ const buffer = state.buffer;
121
+ if (!buffer || buffer.length < 8) return;
122
+ const messageLength = buffer.readUInt32BE(0);
123
+ if (buffer.length < messageLength) return;
124
+ const code = buffer.readUInt32BE(4);
125
+
126
+ if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
127
+ socket.write(Buffer.from('N'));
128
+ state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
129
+ return;
130
+ }
131
+ if (code === CANCEL_REQUEST_CODE) {
132
+ socket.end();
133
+ return;
134
+ }
135
+ if (code !== PROTOCOL_VERSION_3) {
136
+ this.logger.warn?.({ code }, 'TCP unsupported protocol version');
137
+ socket.end();
138
+ return;
139
+ }
140
+
141
+ const startupMessage = buffer.subarray(0, messageLength);
142
+ const applicationName = extractApplicationName(startupMessage);
143
+ const auth = parseTcpAuth(applicationName);
144
+ state.startupInProgress = true;
145
+
146
+ // Validate before opening any PG socket. The denied path emits exactly
147
+ // one audit event then closes — the peer gets no oracle distinguishing
148
+ // "unknown fingerprint" from "bad token".
149
+ let validated = null;
150
+ try {
151
+ if (auth && this._adminClient) {
152
+ const tokenHash = hashToken(auth.token);
153
+ validated = await verifyToken(this._adminClient, {
154
+ fingerprint: auth.fingerprint,
155
+ tokenHash,
156
+ });
157
+ }
158
+ } catch (err) {
159
+ this.logger.warn?.({ err: err.message }, 'verifyToken failed');
160
+ validated = null;
161
+ }
162
+
163
+ if (!validated) {
164
+ audit(AUDIT_EVENTS.TCP_TOKEN_DENIED, {
165
+ fingerprint: auth?.fingerprint || null,
166
+ remote_address: socket.remoteAddress || null,
167
+ reason: !auth ? 'missing_or_malformed_application_name' : 'token_unknown',
168
+ });
169
+ try { socket.end(); } catch { /* swallow */ }
170
+ state.startupInProgress = false;
171
+ return;
172
+ }
173
+
174
+ state.fingerprint = auth.fingerprint;
175
+ state.tokenId = validated.tokenId;
176
+ state.dbName = validated.databaseName;
177
+
178
+ audit(AUDIT_EVENTS.TCP_TOKEN_USED, {
179
+ fingerprint: auth.fingerprint,
180
+ token_id: validated.tokenId,
181
+ database: validated.databaseName,
182
+ remote_address: socket.remoteAddress || null,
183
+ });
184
+
185
+ // Force the peer into its fingerprint's database — even if the libpq
186
+ // client asked for something else. Drop application_name on the way
187
+ // through: the auth blob easily exceeds Postgres' 63-char NAMEDATALEN
188
+ // and would otherwise trigger a truncation NOTICE on every connect.
189
+ let outgoingStartup;
190
+ try {
191
+ outgoingStartup = rewriteDatabaseName(startupMessage, validated.databaseName, {
192
+ dropParams: ['application_name'],
193
+ });
194
+ } catch (err) {
195
+ this.logger.error?.({ err: err.message }, 'rewriteDatabaseName failed for TCP peer');
196
+ try { socket.end(); } catch { /* swallow */ }
197
+ state.startupInProgress = false;
198
+ return;
199
+ }
200
+
201
+ try {
202
+ if (this.autoProvision) {
203
+ await this.pgManager.createDatabase(validated.databaseName);
204
+ }
205
+ const pgSocketPath = this.pgManager.getSocketPath();
206
+ const daemon = this;
207
+ const pgHandler = {
208
+ data(_pgSocket, pgData) {
209
+ if (state.pendingToClient) {
210
+ state.pendingToClient = Buffer.concat([state.pendingToClient, Buffer.from(pgData)]);
211
+ _pgSocket.pause();
212
+ return;
213
+ }
214
+ const written = socket.write(pgData);
215
+ if (written < pgData.byteLength) {
216
+ state.pendingToClient = written === 0
217
+ ? Buffer.from(pgData)
218
+ : Buffer.from(pgData.subarray(written));
219
+ _pgSocket.pause();
220
+ }
221
+ },
222
+ open(pgSocket) {
223
+ pgSocket.write(outgoingStartup);
224
+ state.handshakeComplete = true;
225
+ },
226
+ close() {
227
+ try { socket.end(); } catch { /* swallow */ }
228
+ },
229
+ error(_pgSocket, error) {
230
+ daemon.logger.error?.(
231
+ { dbName: validated.databaseName, err: error?.message || String(error) },
232
+ 'TCP-side PG proxy socket error',
233
+ );
234
+ try { socket.end(); } catch { /* swallow */ }
235
+ },
236
+ drain(_pgSocket) {
237
+ if (state.pendingToPg) {
238
+ state.pendingToPg = flushPending(_pgSocket, state.pendingToPg);
239
+ }
240
+ if (!state.pendingToPg) {
241
+ socket.resume();
242
+ }
243
+ },
244
+ };
245
+
246
+ const useUnix = pgSocketPath && fs.existsSync(pgSocketPath);
247
+ if (useUnix) {
248
+ state.pgSocket = await Bun.connect({ unix: pgSocketPath, socket: pgHandler });
249
+ } else {
250
+ state.pgSocket = await Bun.connect({
251
+ hostname: '127.0.0.1',
252
+ port: this.pgManager.port,
253
+ socket: pgHandler,
254
+ });
255
+ }
256
+ this.emit('tcp-connection', { dbName: validated.databaseName, fingerprint: auth.fingerprint });
257
+ } catch (err) {
258
+ this.logger.error?.(
259
+ { dbName: validated.databaseName, err: err?.message || String(err) },
260
+ 'TCP daemon connection error',
261
+ );
262
+ try { socket.end(); } catch { /* swallow */ }
263
+ this.emit('connection-error', { error: err, dbName: validated.databaseName });
264
+ } finally {
265
+ state.startupInProgress = false;
266
+ }
267
+ }
268
+
269
+ function handleTcpClose(socket) {
270
+ const state = this.socketState.get(socket);
271
+ if (state) {
272
+ state.pendingToPg = null;
273
+ state.pendingToClient = null;
274
+ if (state.pgSocket) {
275
+ try { state.pgSocket.end(); } catch { /* swallow */ }
276
+ }
277
+ }
278
+ this.connections.delete(socket);
279
+ this.socketState.delete(socket);
280
+ }
281
+
282
+ function handleTcpError(socket, error) {
283
+ if (error?.code !== 'ECONNRESET') {
284
+ this.logger.error?.({ err: error?.message || String(error) }, 'TCP socket error');
285
+ }
286
+ const state = this.socketState.get(socket);
287
+ if (state) {
288
+ state.pendingToPg = null;
289
+ state.pendingToClient = null;
290
+ if (state.pgSocket) {
291
+ try { state.pgSocket.end(); } catch { /* swallow */ }
292
+ }
293
+ }
294
+ this.connections.delete(socket);
295
+ this.socketState.delete(socket);
296
+ }