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.
- package/bin/pgserve-wrapper.cjs +5 -4
- package/bin/postgres-server.js +142 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +2 -2
- package/scripts/test-npx.sh +32 -10
- package/src/cli-install.cjs +147 -77
- package/src/commands/uninstall.js +241 -0
- package/src/index.js +11 -44
- package/src/lib/admin-json.js +202 -0
- package/src/lib/pm2-args.js +119 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/admin-client.js +0 -223
- package/src/audit.js +0 -168
- package/src/cluster.js +0 -654
- package/src/control-db.js +0 -330
- package/src/daemon-control.js +0 -468
- package/src/daemon-shared.js +0 -18
- package/src/daemon-tcp.js +0 -297
- package/src/daemon.js +0 -709
- package/src/dashboard.js +0 -217
- package/src/fingerprint.js +0 -479
- package/src/gc.js +0 -351
- package/src/pg-wire.js +0 -869
- package/src/protocol.js +0 -389
- package/src/restore.js +0 -574
- package/src/router.js +0 -546
- package/src/sdk.js +0 -137
- package/src/stats-collector.js +0 -453
- package/src/stats-dashboard.js +0 -401
- package/src/sync.js +0 -335
- package/src/tenancy.js +0 -75
- package/src/tokens.js +0 -102
package/src/protocol.js
DELETED
|
@@ -1,389 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PostgreSQL Wire Protocol Parser (Performance Optimized)
|
|
3
|
-
*
|
|
4
|
-
* Extracts database name from PostgreSQL startup message
|
|
5
|
-
* https://www.postgresql.org/docs/current/protocol-message-formats.html
|
|
6
|
-
*
|
|
7
|
-
* Optimizations:
|
|
8
|
-
* - Fast path for database extraction (skip full parsing if possible)
|
|
9
|
-
* - Minimize string allocations
|
|
10
|
-
* - Use Buffer.indexOf for faster null-byte search
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const PROTOCOL_VERSION_3 = 196608;
|
|
14
|
-
const SSL_REQUEST_CODE = 80877103; // PostgreSQL SSL negotiation request
|
|
15
|
-
const GSSAPI_REQUEST_CODE = 80877104; // PostgreSQL GSSAPI encryption request
|
|
16
|
-
const CANCEL_REQUEST_CODE = 80877102; // PostgreSQL cancel request
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Parse PostgreSQL startup message to extract connection parameters
|
|
20
|
-
* OPTIMIZED: Fast path for database extraction
|
|
21
|
-
*
|
|
22
|
-
* @param {Buffer} data - Raw startup message data
|
|
23
|
-
* @param {boolean} [fastPath=true] - Use fast path (only extract database)
|
|
24
|
-
* @returns {Object} Parsed parameters (user, database, application_name, etc.)
|
|
25
|
-
*/
|
|
26
|
-
export function parseStartupMessage(data, fastPath = true) {
|
|
27
|
-
const length = data.readInt32BE(0);
|
|
28
|
-
const version = data.readInt32BE(4);
|
|
29
|
-
|
|
30
|
-
// Verify protocol version (3.0 = 196608)
|
|
31
|
-
if (version !== PROTOCOL_VERSION_3) {
|
|
32
|
-
throw new Error(`Unsupported protocol version: ${version}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Fast path: only extract database name (most common case)
|
|
36
|
-
if (fastPath) {
|
|
37
|
-
const dbName = extractDatabaseFast(data, 8, length);
|
|
38
|
-
if (dbName) {
|
|
39
|
-
return { database: dbName };
|
|
40
|
-
}
|
|
41
|
-
// Fallback to full parse if fast path failed
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Full parse (slower but complete)
|
|
45
|
-
const params = {};
|
|
46
|
-
let offset = 8;
|
|
47
|
-
|
|
48
|
-
while (offset < length - 1) {
|
|
49
|
-
// Find next null byte (key end)
|
|
50
|
-
const keyEnd = data.indexOf(0, offset);
|
|
51
|
-
if (keyEnd === -1 || keyEnd >= length) break;
|
|
52
|
-
|
|
53
|
-
// Extract key (avoid toString for common keys)
|
|
54
|
-
const key = data.toString('utf8', offset, keyEnd);
|
|
55
|
-
offset = keyEnd + 1;
|
|
56
|
-
|
|
57
|
-
// Find next null byte (value end)
|
|
58
|
-
const valueEnd = data.indexOf(0, offset);
|
|
59
|
-
if (valueEnd === -1 || valueEnd >= length) break;
|
|
60
|
-
|
|
61
|
-
// Extract value
|
|
62
|
-
const value = data.toString('utf8', offset, valueEnd);
|
|
63
|
-
offset = valueEnd + 1;
|
|
64
|
-
|
|
65
|
-
params[key] = value;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return params;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Fast path: Extract database name without parsing all parameters
|
|
73
|
-
* PERFORMANCE: ~3x faster than full parse
|
|
74
|
-
*
|
|
75
|
-
* @param {Buffer} data - Startup message buffer
|
|
76
|
-
* @param {number} offset - Start offset (after header)
|
|
77
|
-
* @param {number} length - Total message length
|
|
78
|
-
* @returns {string|null} Database name or null
|
|
79
|
-
*/
|
|
80
|
-
function extractDatabaseFast(data, offset, length) {
|
|
81
|
-
// Search for "database\0" key
|
|
82
|
-
while (offset < length - 1) {
|
|
83
|
-
// Find next null byte
|
|
84
|
-
const nullPos = data.indexOf(0, offset);
|
|
85
|
-
if (nullPos === -1 || nullPos >= length) break;
|
|
86
|
-
|
|
87
|
-
const keyLength = nullPos - offset;
|
|
88
|
-
|
|
89
|
-
// Check if this is the "database" key (compare bytes directly)
|
|
90
|
-
if (keyLength === 8 && data[offset] === 0x64 /* 'd' */) {
|
|
91
|
-
// Quick byte comparison for "database"
|
|
92
|
-
if (
|
|
93
|
-
data[offset + 1] === 0x61 && // 'a'
|
|
94
|
-
data[offset + 2] === 0x74 && // 't'
|
|
95
|
-
data[offset + 3] === 0x61 && // 'a'
|
|
96
|
-
data[offset + 4] === 0x62 && // 'b'
|
|
97
|
-
data[offset + 5] === 0x61 && // 'a'
|
|
98
|
-
data[offset + 6] === 0x73 && // 's'
|
|
99
|
-
data[offset + 7] === 0x65 // 'e'
|
|
100
|
-
) {
|
|
101
|
-
// Found "database" key, extract value
|
|
102
|
-
offset = nullPos + 1;
|
|
103
|
-
const valueEnd = data.indexOf(0, offset);
|
|
104
|
-
if (valueEnd === -1 || valueEnd >= length) return null;
|
|
105
|
-
|
|
106
|
-
return data.toString('utf8', offset, valueEnd);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Skip to next key-value pair
|
|
111
|
-
offset = nullPos + 1;
|
|
112
|
-
const valueEnd = data.indexOf(0, offset);
|
|
113
|
-
if (valueEnd === -1) break;
|
|
114
|
-
offset = valueEnd + 1;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Extract database name from startup message
|
|
122
|
-
*
|
|
123
|
-
* @param {Buffer} data - Raw startup message data
|
|
124
|
-
* @returns {string} Database name (defaults to 'postgres')
|
|
125
|
-
*/
|
|
126
|
-
export function extractDatabaseName(data) {
|
|
127
|
-
try {
|
|
128
|
-
const params = parseStartupMessage(data);
|
|
129
|
-
return params.database || 'postgres';
|
|
130
|
-
} catch (error) {
|
|
131
|
-
console.warn('Failed to parse startup message:', error.message);
|
|
132
|
-
return 'postgres'; // Fallback to default
|
|
133
|
-
}
|
|
134
|
-
}
|
|
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
|
-
|
|
267
|
-
// Pre-allocated buffer pool for startup message parsing (avoids allocation per connection)
|
|
268
|
-
const STARTUP_BUFFER_SIZE = 8192; // Max startup message is typically < 1KB
|
|
269
|
-
const bufferPool = [];
|
|
270
|
-
const MAX_POOL_SIZE = 100;
|
|
271
|
-
|
|
272
|
-
function acquireBuffer() {
|
|
273
|
-
return bufferPool.pop() || Buffer.allocUnsafe(STARTUP_BUFFER_SIZE);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function releaseBuffer(buf) {
|
|
277
|
-
if (bufferPool.length < MAX_POOL_SIZE) {
|
|
278
|
-
bufferPool.push(buf);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Read startup message from socket and buffer it
|
|
284
|
-
* OPTIMIZED: Uses pre-allocated buffer pool to avoid allocation per connection
|
|
285
|
-
*
|
|
286
|
-
* @param {net.Socket} socket - TCP socket
|
|
287
|
-
* @returns {Promise<{message: Buffer, allData: Buffer}>} Startup message and all buffered data
|
|
288
|
-
*/
|
|
289
|
-
export async function readStartupMessage(socket) {
|
|
290
|
-
return new Promise((resolve, reject) => {
|
|
291
|
-
const buffer = acquireBuffer();
|
|
292
|
-
let offset = 0;
|
|
293
|
-
let expectedLength = null;
|
|
294
|
-
let resolved = false;
|
|
295
|
-
|
|
296
|
-
const onData = (chunk) => {
|
|
297
|
-
if (resolved) return;
|
|
298
|
-
|
|
299
|
-
// Copy chunk into pre-allocated buffer (avoids Buffer.concat allocation)
|
|
300
|
-
const copyLen = Math.min(chunk.length, STARTUP_BUFFER_SIZE - offset);
|
|
301
|
-
chunk.copy(buffer, offset, 0, copyLen);
|
|
302
|
-
offset += copyLen;
|
|
303
|
-
|
|
304
|
-
// Read expected length from first 4 bytes
|
|
305
|
-
if (expectedLength === null && offset >= 4) {
|
|
306
|
-
expectedLength = buffer.readInt32BE(0);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Check if we have full message
|
|
310
|
-
if (expectedLength !== null && offset >= expectedLength) {
|
|
311
|
-
resolved = true;
|
|
312
|
-
socket.removeListener('data', onData);
|
|
313
|
-
socket.removeListener('error', onError);
|
|
314
|
-
|
|
315
|
-
// Create result buffers (need to copy since we're reusing pool buffer)
|
|
316
|
-
const message = Buffer.from(buffer.subarray(0, expectedLength));
|
|
317
|
-
const allData = Buffer.from(buffer.subarray(0, offset));
|
|
318
|
-
releaseBuffer(buffer);
|
|
319
|
-
resolve({ message, allData });
|
|
320
|
-
}
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
const onError = (error) => {
|
|
324
|
-
if (resolved) return;
|
|
325
|
-
resolved = true;
|
|
326
|
-
socket.removeListener('data', onData);
|
|
327
|
-
socket.removeListener('error', onError);
|
|
328
|
-
releaseBuffer(buffer);
|
|
329
|
-
reject(error);
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
socket.on('data', onData);
|
|
333
|
-
socket.on('error', onError);
|
|
334
|
-
|
|
335
|
-
// Resume socket AFTER listeners are set up (prevents race condition)
|
|
336
|
-
socket.resume();
|
|
337
|
-
|
|
338
|
-
// Timeout after 2 seconds (reduced from 5s for faster probe connection handling)
|
|
339
|
-
setTimeout(() => {
|
|
340
|
-
if (resolved) return;
|
|
341
|
-
resolved = true;
|
|
342
|
-
socket.removeListener('data', onData);
|
|
343
|
-
socket.removeListener('error', onError);
|
|
344
|
-
releaseBuffer(buffer);
|
|
345
|
-
reject(new Error('Timeout reading startup message'));
|
|
346
|
-
}, 2000);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* Extract database name from socket connection (with SSL negotiation support)
|
|
352
|
-
*
|
|
353
|
-
* @param {net.Socket} socket - TCP socket
|
|
354
|
-
* @returns {Promise<{dbName: string, buffered: Buffer}>} Database name and buffered data
|
|
355
|
-
*/
|
|
356
|
-
// Unused but kept for potential future use
|
|
357
|
-
async function _extractDatabaseNameFromSocket(socket) {
|
|
358
|
-
let { message, allData } = await readStartupMessage(socket);
|
|
359
|
-
|
|
360
|
-
// Check if this is a protocol negotiation request (SSL, GSSAPI, Cancel)
|
|
361
|
-
if (message.length >= 8) {
|
|
362
|
-
const version = message.readInt32BE(4);
|
|
363
|
-
|
|
364
|
-
if (version === SSL_REQUEST_CODE) {
|
|
365
|
-
// Respond with 'N' (no SSL support)
|
|
366
|
-
socket.write(Buffer.from('N'));
|
|
367
|
-
|
|
368
|
-
// Read the actual startup message
|
|
369
|
-
const result = await readStartupMessage(socket);
|
|
370
|
-
message = result.message;
|
|
371
|
-
allData = result.allData;
|
|
372
|
-
} else if (version === GSSAPI_REQUEST_CODE) {
|
|
373
|
-
// Respond with 'N' (no GSSAPI support)
|
|
374
|
-
socket.write(Buffer.from('N'));
|
|
375
|
-
|
|
376
|
-
// Read the actual startup message
|
|
377
|
-
const result = await readStartupMessage(socket);
|
|
378
|
-
message = result.message;
|
|
379
|
-
allData = result.allData;
|
|
380
|
-
} else if (version === CANCEL_REQUEST_CODE) {
|
|
381
|
-
// Cancel request - query cancellation not implemented
|
|
382
|
-
// Just close gracefully (cancel requests don't expect a response)
|
|
383
|
-
throw new Error('Cancel request received (not supported)');
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
const dbName = extractDatabaseName(message);
|
|
388
|
-
return { dbName, buffered: allData };
|
|
389
|
-
}
|