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/daemon.js
DELETED
|
@@ -1,709 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve daemon — singleton control-socket server (orchestrator).
|
|
3
|
-
*
|
|
4
|
-
* One process per host. Listens on a well-known Unix socket
|
|
5
|
-
* (`$XDG_RUNTIME_DIR/pgserve/control.sock`, fallback `/tmp/pgserve/control.sock`),
|
|
6
|
-
* supervises a single PostgresManager instance, and proxies every accepted
|
|
7
|
-
* client through to the underlying PG Unix socket.
|
|
8
|
-
*
|
|
9
|
-
* Singleton enforcement uses a PID lock file (`pgserve.pid`) co-located with
|
|
10
|
-
* the control socket. A second daemon invocation refuses with the live PID;
|
|
11
|
-
* a stale lock (process gone) is cleaned up automatically on next boot.
|
|
12
|
-
*
|
|
13
|
-
* Module layout (split for AGENTS.md §8 1000-line discipline):
|
|
14
|
-
* - daemon.js (this file) — class shell, lifecycle, lock, signal handlers,
|
|
15
|
-
* listener wiring, public exports.
|
|
16
|
-
* - daemon-control.js — Unix accept hooks: handleSocketOpen/Data/Close/
|
|
17
|
-
* Error, processStartupMessage, resolveTenantDatabase (Group 2 + Group 4).
|
|
18
|
-
* - daemon-tcp.js — Optional TCP accept hooks + token verify
|
|
19
|
-
* (Group 6).
|
|
20
|
-
* - daemon-shared.js — flushPending helper shared by both paths.
|
|
21
|
-
*
|
|
22
|
-
* PR #24 invariants preserved:
|
|
23
|
-
* - `PostgresManager.start()` re-entry guard untouched.
|
|
24
|
-
* - `PostgresManager.stop()` nulls socketDir/databaseDir.
|
|
25
|
-
* - On abnormal daemon exit, the next boot's stale-pid cleanup unlinks
|
|
26
|
-
* the orphaned control socket *and* PID lock so we never leak either.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/* global Bun */
|
|
30
|
-
import fs from 'fs';
|
|
31
|
-
import path from 'path';
|
|
32
|
-
import { EventEmitter } from 'events';
|
|
33
|
-
import { PostgresManager } from './postgres.js';
|
|
34
|
-
import { createLogger } from './logger.js';
|
|
35
|
-
import { initFingerprintFfi } from './fingerprint.js';
|
|
36
|
-
import { configureAudit } from './audit.js';
|
|
37
|
-
import { ensureMetaSchema } from './control-db.js';
|
|
38
|
-
import { createAdminClient, writeAdminDiscovery, removeAdminDiscovery } from './admin-client.js';
|
|
39
|
-
import {
|
|
40
|
-
isFingerprintEnforcementDisabled,
|
|
41
|
-
KILL_SWITCH_ENV,
|
|
42
|
-
} from './tenancy.js';
|
|
43
|
-
import { flushPending } from './daemon-shared.js';
|
|
44
|
-
import { attachControlHandlers } from './daemon-control.js';
|
|
45
|
-
import { attachTcpHandlers } from './daemon-tcp.js';
|
|
46
|
-
import { installSweepTriggers } from './gc.js';
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Resolve the directory that holds the daemon's control socket and pid lock.
|
|
50
|
-
* `$XDG_RUNTIME_DIR/pgserve` when XDG is set (the systemd / freedesktop
|
|
51
|
-
* convention), otherwise `/tmp/pgserve` as the documented fallback.
|
|
52
|
-
*/
|
|
53
|
-
export function resolveControlSocketDir() {
|
|
54
|
-
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
55
|
-
const base = xdg && xdg.length > 0 ? xdg : '/tmp';
|
|
56
|
-
return path.join(base, 'pgserve');
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function resolveControlSocketPath(dir = resolveControlSocketDir()) {
|
|
60
|
-
return path.join(dir, 'control.sock');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function resolvePidLockPath(dir = resolveControlSocketDir()) {
|
|
64
|
-
return path.join(dir, 'pgserve.pid');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* libpq compat path. When users say `psql -h $XDG_RUNTIME_DIR/pgserve`,
|
|
69
|
-
* libpq looks for `<host>/.s.PGSQL.<port>` with port defaulting to 5432.
|
|
70
|
-
* The daemon binds `control.sock` (per wish §Group 2) and ALSO publishes
|
|
71
|
-
* a `.s.PGSQL.<port>` symlink to it so off-the-shelf clients connect.
|
|
72
|
-
*/
|
|
73
|
-
export function resolveLibpqCompatPath(dir = resolveControlSocketDir(), port = 5432) {
|
|
74
|
-
return path.join(dir, `.s.PGSQL.${port}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Return true if a process with the given pid is alive (signal 0 trick).
|
|
79
|
-
*/
|
|
80
|
-
export function isProcessAlive(pid) {
|
|
81
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
82
|
-
try {
|
|
83
|
-
process.kill(pid, 0);
|
|
84
|
-
return true;
|
|
85
|
-
} catch (err) {
|
|
86
|
-
// EPERM means the process exists but we don't own it — still alive.
|
|
87
|
-
return err.code === 'EPERM';
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Acquire the singleton PID lock, taking care of stale lock cleanup.
|
|
93
|
-
*
|
|
94
|
-
* Returns `{ acquired: true }` on success. On an already-running peer,
|
|
95
|
-
* returns `{ acquired: false, pid }` so the caller can render a clean
|
|
96
|
-
* "already running, pid N" error and exit non-zero.
|
|
97
|
-
*
|
|
98
|
-
* Cleanup contract on failed acquisition is the caller's responsibility:
|
|
99
|
-
* we never unlink the socket of a *live* peer.
|
|
100
|
-
*/
|
|
101
|
-
export function acquirePidLock({ pidLockPath, socketPath, libpqCompatPath, logger }) {
|
|
102
|
-
ensureDir(path.dirname(pidLockPath));
|
|
103
|
-
|
|
104
|
-
const orphanPaths = [socketPath, libpqCompatPath].filter(Boolean);
|
|
105
|
-
|
|
106
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
107
|
-
try {
|
|
108
|
-
const fd = fs.openSync(pidLockPath, 'wx', 0o600);
|
|
109
|
-
try {
|
|
110
|
-
fs.writeSync(fd, String(process.pid));
|
|
111
|
-
} finally {
|
|
112
|
-
fs.closeSync(fd);
|
|
113
|
-
}
|
|
114
|
-
return { acquired: true };
|
|
115
|
-
} catch (err) {
|
|
116
|
-
if (err.code !== 'EEXIST') throw err;
|
|
117
|
-
|
|
118
|
-
// PID file exists. Read it and decide whether the owner is alive.
|
|
119
|
-
let stalePid = null;
|
|
120
|
-
try {
|
|
121
|
-
const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
|
|
122
|
-
stalePid = parseInt(raw, 10);
|
|
123
|
-
} catch {
|
|
124
|
-
// Unreadable file is treated as stale.
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (Number.isInteger(stalePid) && isProcessAlive(stalePid)) {
|
|
128
|
-
return { acquired: false, pid: stalePid };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Stale lock — clean it up alongside any orphaned socket / symlink,
|
|
132
|
-
// then retry. The next attempt either succeeds or surfaces a real
|
|
133
|
-
// error.
|
|
134
|
-
logger?.warn?.(
|
|
135
|
-
{ pidLockPath, stalePid },
|
|
136
|
-
'Found stale daemon PID lock, cleaning up before retry',
|
|
137
|
-
);
|
|
138
|
-
try {
|
|
139
|
-
fs.unlinkSync(pidLockPath);
|
|
140
|
-
} catch (e) {
|
|
141
|
-
if (e.code !== 'ENOENT') throw e;
|
|
142
|
-
}
|
|
143
|
-
for (const p of orphanPaths) {
|
|
144
|
-
try {
|
|
145
|
-
fs.unlinkSync(p);
|
|
146
|
-
} catch (e) {
|
|
147
|
-
if (e.code !== 'ENOENT') throw e;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Loop and retry the open-exclusive.
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
// If we got here both attempts failed without throwing — should not happen.
|
|
154
|
-
throw new Error('acquirePidLock: failed after stale-lock cleanup');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function ensureDir(dir) {
|
|
158
|
-
if (!fs.existsSync(dir)) {
|
|
159
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Send a SIGTERM to the daemon owning the lock. Returns the previous pid
|
|
165
|
-
* if a daemon was found, or `null` if no live daemon exists.
|
|
166
|
-
*
|
|
167
|
-
* Used by `pgserve daemon stop`.
|
|
168
|
-
*/
|
|
169
|
-
export function stopDaemon({ controlSocketDir = resolveControlSocketDir(), timeoutMs = 5000 } = {}) {
|
|
170
|
-
const pidLockPath = resolvePidLockPath(controlSocketDir);
|
|
171
|
-
let pid = null;
|
|
172
|
-
try {
|
|
173
|
-
const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
|
|
174
|
-
pid = parseInt(raw, 10);
|
|
175
|
-
} catch {
|
|
176
|
-
return { stopped: false, reason: 'no-pid-file' };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (!Number.isInteger(pid) || pid <= 0) {
|
|
180
|
-
try { fs.unlinkSync(pidLockPath); } catch { /* swallow */ }
|
|
181
|
-
return { stopped: false, reason: 'invalid-pid-file' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (!isProcessAlive(pid)) {
|
|
185
|
-
try { fs.unlinkSync(pidLockPath); } catch { /* swallow */ }
|
|
186
|
-
try { fs.unlinkSync(resolveControlSocketPath(controlSocketDir)); } catch { /* swallow */ }
|
|
187
|
-
try { fs.unlinkSync(resolveLibpqCompatPath(controlSocketDir)); } catch { /* swallow */ }
|
|
188
|
-
return { stopped: false, reason: 'stale-pid', pid };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
process.kill(pid, 'SIGTERM');
|
|
193
|
-
} catch (err) {
|
|
194
|
-
return { stopped: false, reason: 'signal-failed', pid, error: err.message };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Wait for the daemon to remove its pid file.
|
|
198
|
-
const deadline = Date.now() + timeoutMs;
|
|
199
|
-
while (Date.now() < deadline) {
|
|
200
|
-
if (!fs.existsSync(pidLockPath)) {
|
|
201
|
-
return { stopped: true, pid };
|
|
202
|
-
}
|
|
203
|
-
Bun.sleepSync ? Bun.sleepSync(50) : sleepBlocking(50);
|
|
204
|
-
}
|
|
205
|
-
return { stopped: false, reason: 'timeout', pid };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function sleepBlocking(ms) {
|
|
209
|
-
// Tiny blocking sleep used only by the CLI stop path. We avoid pulling in
|
|
210
|
-
// an async dep here; ten 50ms ticks across the 5s timeout is fine.
|
|
211
|
-
const end = Date.now() + ms;
|
|
212
|
-
while (Date.now() < end) { /* spin */ }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* The daemon. Owns one PostgresManager and one Bun.listen({unix}) server.
|
|
217
|
-
* Accept-path methods (handleSocketOpen, handleTcpOpen, …) live in the
|
|
218
|
-
* daemon-control.js / daemon-tcp.js modules and are mixed into the
|
|
219
|
-
* prototype below.
|
|
220
|
-
*/
|
|
221
|
-
export class PgserveDaemon extends EventEmitter {
|
|
222
|
-
constructor(options = {}) {
|
|
223
|
-
super();
|
|
224
|
-
this.controlSocketDir = options.controlSocketDir || resolveControlSocketDir();
|
|
225
|
-
this.controlSocketPath = options.controlSocketPath || resolveControlSocketPath(this.controlSocketDir);
|
|
226
|
-
this.pidLockPath = options.pidLockPath || resolvePidLockPath(this.controlSocketDir);
|
|
227
|
-
this.libpqPort = options.libpqPort || 5432;
|
|
228
|
-
this.libpqCompatPath = options.libpqCompatPath || resolveLibpqCompatPath(this.controlSocketDir, this.libpqPort);
|
|
229
|
-
this.maxConnections = options.maxConnections || 1000;
|
|
230
|
-
this.autoProvision = options.autoProvision !== false;
|
|
231
|
-
this.baseDir = options.baseDir || null;
|
|
232
|
-
this.useRam = options.useRam || false;
|
|
233
|
-
this.auditLogFile = options.auditLogFile || null;
|
|
234
|
-
this.auditTarget = options.auditTarget || null;
|
|
235
|
-
// Group 6: opt-in TCP binds. Each entry is `{host, port}`. Empty array
|
|
236
|
-
// (the default) means "Unix socket only" — no TCP port is bound.
|
|
237
|
-
this.tcpListens = normalizeTcpListens(options.tcpListens);
|
|
238
|
-
// Group 4: fingerprint enforcement is on by default; the kill-switch env
|
|
239
|
-
// var (`PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`) flips it off and is
|
|
240
|
-
// surfaced as a deprecation warning at start(). Tests pass an explicit
|
|
241
|
-
// boolean override.
|
|
242
|
-
this.enforcementDisabled = options.enforcementDisabled !== undefined
|
|
243
|
-
? !!options.enforcementDisabled
|
|
244
|
-
: isFingerprintEnforcementDisabled();
|
|
245
|
-
// Group 4 test seam: per-accept overrides for fingerprint derivation.
|
|
246
|
-
// Production omits this and the daemon walks `/proc/$pid/cwd` for real.
|
|
247
|
-
this._fingerprintAcceptOpts = typeof options._fingerprintAcceptOpts === 'function'
|
|
248
|
-
? options._fingerprintAcceptOpts
|
|
249
|
-
: null;
|
|
250
|
-
this.logger = options.logger || createLogger({ level: options.logLevel || 'info' });
|
|
251
|
-
|
|
252
|
-
this.pgManager = options.pgManager || new PostgresManager({
|
|
253
|
-
dataDir: this.baseDir,
|
|
254
|
-
port: options.pgPort ?? 0,
|
|
255
|
-
logger: this.logger.child ? this.logger.child({ component: 'postgres' }) : this.logger,
|
|
256
|
-
useRam: this.useRam,
|
|
257
|
-
enablePgvector: options.enablePgvector || false,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// Forward unexpected backend deaths to wrapper-level supervisors. A clean
|
|
261
|
-
// stop() sets PostgresManager._stopping=true so the event arrives with
|
|
262
|
-
// expected=true and we leave the daemon alone; an external SIGKILL / OOM
|
|
263
|
-
// / segfault arrives with expected=false and we re-emit so the wrapper
|
|
264
|
-
// can exit non-zero and let a process supervisor (genie serve, pm2,
|
|
265
|
-
// systemd) restart us cleanly. See pgserve#45.
|
|
266
|
-
this.pgManager.on('backendExited', (info) => {
|
|
267
|
-
if (!info.expected) {
|
|
268
|
-
this.emit('backendDiedUnexpectedly', info);
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
this.server = null;
|
|
273
|
-
this.tcpServers = [];
|
|
274
|
-
this.connections = new Set();
|
|
275
|
-
this.socketState = new WeakMap();
|
|
276
|
-
this._lockAcquired = false;
|
|
277
|
-
this._signalHandlersInstalled = false;
|
|
278
|
-
this._stopping = false;
|
|
279
|
-
// Lazy-initialised admin DB client (Group 6 token validation).
|
|
280
|
-
this._adminClient = null;
|
|
281
|
-
this.adminIdleTimeout = options.adminIdleTimeout ?? 300;
|
|
282
|
-
this.adminQueryTimeoutMs = options.adminQueryTimeoutMs ?? 0;
|
|
283
|
-
this.adminLookupTimeoutMs = options.adminLookupTimeoutMs ?? 5000;
|
|
284
|
-
// Group 5: GC sweep handle ({stop, sweep}). Installed once the admin
|
|
285
|
-
// client is up and torn down on stop().
|
|
286
|
-
this._gcHandle = null;
|
|
287
|
-
// Group 5 test seam — opt out of the boot sweep / hourly timer when
|
|
288
|
-
// tests want to drive sweeps manually. Default: enabled.
|
|
289
|
-
this.gcEnabled = options.gcEnabled !== false;
|
|
290
|
-
this.gcOptions = options.gcOptions || {};
|
|
291
|
-
|
|
292
|
-
this.setMaxListeners(this.maxConnections + 10);
|
|
293
|
-
|
|
294
|
-
// Watchdog: forcibly close any control-socket peer that has been accepted
|
|
295
|
-
// but hasn't completed the postgres handshake within this deadline. The
|
|
296
|
-
// env override is for tests (or for operators who want a tighter bound).
|
|
297
|
-
// See pgserve#45: peers that connected and never sent a StartupMessage
|
|
298
|
-
// would pile up indefinitely in `state.handshakeComplete=false`,
|
|
299
|
-
// exhausting connection slots.
|
|
300
|
-
const envDeadline = Number.parseInt(process.env.PGSERVE_HANDSHAKE_DEADLINE_MS ?? '', 10);
|
|
301
|
-
this.handshakeDeadlineMs =
|
|
302
|
-
Number.isFinite(envDeadline) && envDeadline > 0
|
|
303
|
-
? envDeadline
|
|
304
|
-
: (options.handshakeDeadlineMs ?? 30_000);
|
|
305
|
-
// Sweep cadence: small enough to bound the worst-case slop on top of the
|
|
306
|
-
// deadline (5s default → 30s deadline becomes "killed within 30-35s").
|
|
307
|
-
this.handshakeSweepIntervalMs = Math.max(
|
|
308
|
-
1000,
|
|
309
|
-
Math.min(this.handshakeDeadlineMs, options.handshakeSweepIntervalMs ?? 5_000),
|
|
310
|
-
);
|
|
311
|
-
this._handshakeWatchdogTimer = null;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Iterate accepted sockets and force-close any that have been waiting on
|
|
316
|
-
* the postgres handshake for longer than `handshakeDeadlineMs`. Exposed on
|
|
317
|
-
* the prototype so tests can drive it deterministically without waiting for
|
|
318
|
-
* the timer.
|
|
319
|
-
*/
|
|
320
|
-
_sweepStuckHandshakes() {
|
|
321
|
-
const now = Date.now();
|
|
322
|
-
let closed = 0;
|
|
323
|
-
for (const socket of this.connections) {
|
|
324
|
-
const state = this.socketState.get(socket);
|
|
325
|
-
if (!state) continue;
|
|
326
|
-
if (state.handshakeComplete) continue;
|
|
327
|
-
const acceptedAt = state.acceptedAt ?? now;
|
|
328
|
-
if (now - acceptedAt < this.handshakeDeadlineMs) continue;
|
|
329
|
-
this.logger.warn?.(
|
|
330
|
-
{ acceptedAt, ageMs: now - acceptedAt, deadlineMs: this.handshakeDeadlineMs, fingerprint: state.fingerprint },
|
|
331
|
-
'Closing peer stuck in pre-handshake state past deadline',
|
|
332
|
-
);
|
|
333
|
-
try { socket.end(); } catch { /* swallow */ }
|
|
334
|
-
this.connections.delete(socket);
|
|
335
|
-
this.socketState.delete(socket);
|
|
336
|
-
closed++;
|
|
337
|
-
}
|
|
338
|
-
return closed;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Start the daemon: acquire singleton lock, boot PG, bind control socket.
|
|
343
|
-
*
|
|
344
|
-
* Throws `DaemonAlreadyRunningError` (a tagged Error) when another live
|
|
345
|
-
* pgserve daemon already owns the lock, so the CLI can render the
|
|
346
|
-
* "already running, pid N" message and `exit(1)` cleanly.
|
|
347
|
-
*/
|
|
348
|
-
async start() {
|
|
349
|
-
if (this.server) {
|
|
350
|
-
this.logger.warn?.({ pid: process.pid }, 'PgserveDaemon.start called while already running');
|
|
351
|
-
return this;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
ensureDir(this.controlSocketDir);
|
|
355
|
-
|
|
356
|
-
// Group 4: surface the kill switch loudly at boot. The audit log records
|
|
357
|
-
// every bypassed connection later, but operators should see this in
|
|
358
|
-
// the daemon's own stderr the moment the process starts.
|
|
359
|
-
if (this.enforcementDisabled) {
|
|
360
|
-
const msg =
|
|
361
|
-
`[pgserve] WARNING: ${KILL_SWITCH_ENV}=1 is set — fingerprint ` +
|
|
362
|
-
`enforcement is DISABLED. Cross-tenant connections will be ` +
|
|
363
|
-
`permitted. This kill switch is deprecated and will be removed ` +
|
|
364
|
-
`in pgserve v3.`;
|
|
365
|
-
try { process.stderr.write(`${msg}\n`); } catch { /* swallow */ }
|
|
366
|
-
this.logger.warn?.({ env: KILL_SWITCH_ENV }, 'Fingerprint enforcement disabled — deprecated kill switch in use');
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const lock = acquirePidLock({
|
|
370
|
-
pidLockPath: this.pidLockPath,
|
|
371
|
-
socketPath: this.controlSocketPath,
|
|
372
|
-
libpqCompatPath: this.libpqCompatPath,
|
|
373
|
-
logger: this.logger,
|
|
374
|
-
});
|
|
375
|
-
if (!lock.acquired) {
|
|
376
|
-
const err = new Error(`pgserve daemon already running, pid ${lock.pid}`);
|
|
377
|
-
err.code = 'EALREADYRUNNING';
|
|
378
|
-
err.pid = lock.pid;
|
|
379
|
-
throw err;
|
|
380
|
-
}
|
|
381
|
-
this._lockAcquired = true;
|
|
382
|
-
|
|
383
|
-
// Best-effort: tighten directory perms in case the dir pre-existed
|
|
384
|
-
// from a previous user (e.g. /tmp/pgserve world-writable parent).
|
|
385
|
-
try { fs.chmodSync(this.controlSocketDir, 0o700); } catch { /* swallow */ }
|
|
386
|
-
|
|
387
|
-
// Wire up audit-log destination + fingerprint FFI before any accept
|
|
388
|
-
// can fire, so handleSocketOpen always sees a primed environment.
|
|
389
|
-
if (this.auditLogFile || this.auditTarget) {
|
|
390
|
-
configureAudit({
|
|
391
|
-
...(this.auditLogFile ? { logFile: this.auditLogFile } : {}),
|
|
392
|
-
...(this.auditTarget ? { target: this.auditTarget } : {}),
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
try {
|
|
396
|
-
await initFingerprintFfi();
|
|
397
|
-
} catch (err) {
|
|
398
|
-
this.releaseLock();
|
|
399
|
-
throw err;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
this.installSignalHandlers();
|
|
403
|
-
|
|
404
|
-
try {
|
|
405
|
-
await this.pgManager.start();
|
|
406
|
-
} catch (err) {
|
|
407
|
-
// Release the lock before propagating — otherwise the operator has to
|
|
408
|
-
// manually unlink a pid file that points at a dead process.
|
|
409
|
-
this.releaseLock();
|
|
410
|
-
throw err;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Bind the control socket. Bun's listener writes to the path; we already
|
|
414
|
-
// unlinked any stale socket in acquirePidLock (or no socket existed).
|
|
415
|
-
const daemon = this;
|
|
416
|
-
try {
|
|
417
|
-
this.server = Bun.listen({
|
|
418
|
-
unix: this.controlSocketPath,
|
|
419
|
-
socket: {
|
|
420
|
-
data(socket, data) {
|
|
421
|
-
daemon.handleSocketData(socket, data);
|
|
422
|
-
},
|
|
423
|
-
open(socket) {
|
|
424
|
-
daemon.handleSocketOpen(socket);
|
|
425
|
-
},
|
|
426
|
-
close(socket) {
|
|
427
|
-
daemon.handleSocketClose(socket);
|
|
428
|
-
},
|
|
429
|
-
error(socket, error) {
|
|
430
|
-
daemon.handleSocketError(socket, error);
|
|
431
|
-
},
|
|
432
|
-
drain(socket) {
|
|
433
|
-
const state = daemon.socketState.get(socket);
|
|
434
|
-
if (!state) return;
|
|
435
|
-
if (state.pendingToClient) {
|
|
436
|
-
state.pendingToClient = flushPending(socket, state.pendingToClient);
|
|
437
|
-
}
|
|
438
|
-
if (!state.pendingToClient && state.pgSocket) {
|
|
439
|
-
state.pgSocket.resume();
|
|
440
|
-
}
|
|
441
|
-
},
|
|
442
|
-
},
|
|
443
|
-
});
|
|
444
|
-
} catch (err) {
|
|
445
|
-
try { await this.pgManager.stop(); } catch { /* swallow */ }
|
|
446
|
-
this.releaseLock();
|
|
447
|
-
throw err;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Restrict the socket to the owning user (some kernels honour mode
|
|
451
|
-
// bits on AF_UNIX sockets, which makes our daemon refuse to even
|
|
452
|
-
// accept from other UIDs without further auth).
|
|
453
|
-
try { fs.chmodSync(this.controlSocketPath, 0o600); } catch { /* swallow */ }
|
|
454
|
-
|
|
455
|
-
// Publish a libpq-compatible symlink so off-the-shelf clients can use
|
|
456
|
-
// `psql -h <dir>` without knowing the `control.sock` name. Replace any
|
|
457
|
-
// stale symlink left by a previous abnormal exit.
|
|
458
|
-
try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
|
|
459
|
-
if (e.code !== 'ENOENT') {
|
|
460
|
-
this.logger.warn?.({ err: e.message }, 'Failed to unlink stale libpq compat symlink');
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
try {
|
|
464
|
-
fs.symlinkSync(path.basename(this.controlSocketPath), this.libpqCompatPath);
|
|
465
|
-
} catch (e) {
|
|
466
|
-
this.logger.warn?.({ err: e.message }, 'Failed to publish libpq compat symlink');
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Group 6: open the admin DB client + provision the meta schema before
|
|
470
|
-
// we accept any connection that might rely on it (TCP token verify).
|
|
471
|
-
try {
|
|
472
|
-
this._adminClient = await createAdminClient({
|
|
473
|
-
socketDir: this.pgManager.socketDir,
|
|
474
|
-
port: this.pgManager.port,
|
|
475
|
-
idleTimeout: this.adminIdleTimeout,
|
|
476
|
-
queryTimeoutMs: this.adminQueryTimeoutMs,
|
|
477
|
-
});
|
|
478
|
-
await ensureMetaSchema(this._adminClient);
|
|
479
|
-
writeAdminDiscovery({
|
|
480
|
-
controlSocketDir: this.controlSocketDir,
|
|
481
|
-
socketDir: this.pgManager.socketDir,
|
|
482
|
-
port: this.pgManager.port,
|
|
483
|
-
});
|
|
484
|
-
} catch (err) {
|
|
485
|
-
this.logger.warn?.(
|
|
486
|
-
{ err: err?.message || String(err) },
|
|
487
|
-
'admin DB init failed — TCP listen will refuse connections',
|
|
488
|
-
);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Group 6: bind any opt-in TCP listeners. Errors here are fatal — if the
|
|
492
|
-
// operator asked for TCP they want to know it failed (port collision,
|
|
493
|
-
// EACCES) rather than silently fall back to Unix-only.
|
|
494
|
-
for (const listen of this.tcpListens) {
|
|
495
|
-
const tcp = await this.bindTcpListener(listen);
|
|
496
|
-
this.tcpServers.push(tcp);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Group 5: install GC sweep triggers (boot + hourly + on-connect sample)
|
|
500
|
-
// once the admin client is provisioned. Disabled when gcEnabled=false
|
|
501
|
-
// (tests that drive sweeps manually) or when no admin client exists.
|
|
502
|
-
if (this.gcEnabled && this._adminClient) {
|
|
503
|
-
try {
|
|
504
|
-
this._gcHandle = installSweepTriggers(this, {
|
|
505
|
-
adminClient: this._adminClient,
|
|
506
|
-
...this.gcOptions,
|
|
507
|
-
});
|
|
508
|
-
} catch (err) {
|
|
509
|
-
this.logger.warn?.(
|
|
510
|
-
{ err: err?.message || String(err) },
|
|
511
|
-
'GC sweep install failed — orphan reaping disabled',
|
|
512
|
-
);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
this.logger.info?.({
|
|
517
|
-
pid: process.pid,
|
|
518
|
-
controlSocketPath: this.controlSocketPath,
|
|
519
|
-
pidLockPath: this.pidLockPath,
|
|
520
|
-
pgPort: this.pgManager.port,
|
|
521
|
-
tcpListens: this.tcpListens,
|
|
522
|
-
handshakeDeadlineMs: this.handshakeDeadlineMs,
|
|
523
|
-
}, 'pgserve daemon listening');
|
|
524
|
-
|
|
525
|
-
// Arm the handshake watchdog. unref() so the timer doesn't keep the
|
|
526
|
-
// process alive on its own — the daemon already awaits the wrapper's
|
|
527
|
-
// forever-promise.
|
|
528
|
-
this._handshakeWatchdogTimer = setInterval(
|
|
529
|
-
() => this._sweepStuckHandshakes(),
|
|
530
|
-
this.handshakeSweepIntervalMs,
|
|
531
|
-
);
|
|
532
|
-
if (typeof this._handshakeWatchdogTimer.unref === 'function') {
|
|
533
|
-
this._handshakeWatchdogTimer.unref();
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
this.emit('listening');
|
|
537
|
-
return this;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Graceful shutdown: drain connections, stop PG, release lock + socket.
|
|
542
|
-
*/
|
|
543
|
-
async stop() {
|
|
544
|
-
if (this._stopping) return;
|
|
545
|
-
this._stopping = true;
|
|
546
|
-
|
|
547
|
-
this.logger.info?.('Stopping pgserve daemon');
|
|
548
|
-
|
|
549
|
-
if (this._handshakeWatchdogTimer) {
|
|
550
|
-
clearInterval(this._handshakeWatchdogTimer);
|
|
551
|
-
this._handshakeWatchdogTimer = null;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
for (const socket of this.connections) {
|
|
555
|
-
try { socket.end(); } catch { /* swallow */ }
|
|
556
|
-
}
|
|
557
|
-
this.connections.clear();
|
|
558
|
-
|
|
559
|
-
if (this.server) {
|
|
560
|
-
try { this.server.stop(); } catch { /* swallow */ }
|
|
561
|
-
this.server = null;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// Group 6: tear down opt-in TCP listeners.
|
|
565
|
-
for (const tcp of this.tcpServers) {
|
|
566
|
-
try { tcp.stop(); } catch { /* swallow */ }
|
|
567
|
-
}
|
|
568
|
-
this.tcpServers = [];
|
|
569
|
-
|
|
570
|
-
// Group 5: detach GC triggers before the admin client closes so an
|
|
571
|
-
// in-flight sweep doesn't try to query a closed connection.
|
|
572
|
-
if (this._gcHandle) {
|
|
573
|
-
try { await this._gcHandle.stop(); } catch { /* swallow */ }
|
|
574
|
-
this._gcHandle = null;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
if (this._adminClient) {
|
|
578
|
-
try { await this._adminClient.end(); } catch { /* swallow */ }
|
|
579
|
-
this._adminClient = null;
|
|
580
|
-
}
|
|
581
|
-
try {
|
|
582
|
-
removeAdminDiscovery(this.controlSocketDir);
|
|
583
|
-
} catch (e) {
|
|
584
|
-
if (e.code !== 'ENOENT') {
|
|
585
|
-
this.logger.warn?.({ err: e.message }, 'Failed to remove admin discovery file');
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
try {
|
|
590
|
-
await this.pgManager.stop();
|
|
591
|
-
} catch (err) {
|
|
592
|
-
this.logger.warn?.({ err: err.message }, 'PostgresManager.stop failed during daemon shutdown');
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
|
|
596
|
-
if (e.code !== 'ENOENT') {
|
|
597
|
-
this.logger.warn?.({ err: e.message }, 'Failed to unlink libpq compat symlink');
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
try { fs.unlinkSync(this.controlSocketPath); } catch (e) {
|
|
602
|
-
if (e.code !== 'ENOENT') {
|
|
603
|
-
this.logger.warn?.({ err: e.message }, 'Failed to unlink control socket');
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
this.releaseLock();
|
|
608
|
-
this._stopping = false;
|
|
609
|
-
this.emit('stopped');
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
releaseLock() {
|
|
613
|
-
if (!this._lockAcquired) return;
|
|
614
|
-
try {
|
|
615
|
-
// Only remove the lock if it still belongs to us. Defends against
|
|
616
|
-
// a fast restart loop where another daemon raced in.
|
|
617
|
-
const raw = fs.readFileSync(this.pidLockPath, 'utf8').trim();
|
|
618
|
-
const owner = parseInt(raw, 10);
|
|
619
|
-
if (Number.isInteger(owner) && owner === process.pid) {
|
|
620
|
-
fs.unlinkSync(this.pidLockPath);
|
|
621
|
-
}
|
|
622
|
-
} catch (e) {
|
|
623
|
-
if (e.code !== 'ENOENT') {
|
|
624
|
-
this.logger.warn?.({ err: e.message }, 'Failed to release daemon pid lock');
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
this._lockAcquired = false;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
installSignalHandlers() {
|
|
631
|
-
if (this._signalHandlersInstalled) return;
|
|
632
|
-
this._signalHandlersInstalled = true;
|
|
633
|
-
const onSignal = async (sig) => {
|
|
634
|
-
this.logger.info?.({ sig }, 'Received signal, draining daemon');
|
|
635
|
-
try { await this.stop(); } catch { /* swallow */ }
|
|
636
|
-
// Re-raise so the OS reports the right exit status. Use the default
|
|
637
|
-
// disposition rather than process.exit(0): operators expect a
|
|
638
|
-
// SIGTERM-killed daemon to exit with the corresponding code.
|
|
639
|
-
process.exit(0);
|
|
640
|
-
};
|
|
641
|
-
process.on('SIGTERM', onSignal);
|
|
642
|
-
process.on('SIGINT', onSignal);
|
|
643
|
-
process.on('SIGHUP', onSignal);
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
getStats() {
|
|
647
|
-
return {
|
|
648
|
-
controlSocketPath: this.controlSocketPath,
|
|
649
|
-
pidLockPath: this.pidLockPath,
|
|
650
|
-
activeConnections: this.connections.size,
|
|
651
|
-
pgPort: this.pgManager.port,
|
|
652
|
-
postgres: this.pgManager.getStats(),
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Mix the accept-path handlers (Unix + TCP) into the prototype. Done at
|
|
658
|
-
// module load so `new PgserveDaemon()` always has them — same observable
|
|
659
|
-
// surface as the pre-split file.
|
|
660
|
-
attachControlHandlers(PgserveDaemon);
|
|
661
|
-
attachTcpHandlers(PgserveDaemon);
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Normalise the `--listen` form. Accepts:
|
|
665
|
-
* - omitted / null / [] → no TCP listeners
|
|
666
|
-
* - "5432" → bind 0.0.0.0:5432
|
|
667
|
-
* - ":5432" → bind 0.0.0.0:5432
|
|
668
|
-
* - "127.0.0.1:5432" → bind localhost only
|
|
669
|
-
* - array of any of the above
|
|
670
|
-
*
|
|
671
|
-
* Returns an array of `{host, port}` objects. Throws on garbage input.
|
|
672
|
-
*/
|
|
673
|
-
export function normalizeTcpListens(listens) {
|
|
674
|
-
if (listens === undefined || listens === null) return [];
|
|
675
|
-
const arr = Array.isArray(listens) ? listens : [listens];
|
|
676
|
-
return arr.filter(Boolean).map(parseSingleListen);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function parseSingleListen(spec) {
|
|
680
|
-
if (typeof spec === 'object' && typeof spec.port === 'number') {
|
|
681
|
-
return { host: spec.host || '0.0.0.0', port: spec.port };
|
|
682
|
-
}
|
|
683
|
-
if (typeof spec !== 'string') {
|
|
684
|
-
throw new Error(`pgserve daemon --listen: bad spec ${JSON.stringify(spec)}`);
|
|
685
|
-
}
|
|
686
|
-
let s = spec.trim();
|
|
687
|
-
if (s.startsWith(':')) s = s.slice(1);
|
|
688
|
-
let host = '0.0.0.0';
|
|
689
|
-
let portText = s;
|
|
690
|
-
const lastColon = s.lastIndexOf(':');
|
|
691
|
-
if (lastColon !== -1) {
|
|
692
|
-
host = s.slice(0, lastColon);
|
|
693
|
-
portText = s.slice(lastColon + 1);
|
|
694
|
-
}
|
|
695
|
-
const port = parseInt(portText, 10);
|
|
696
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
697
|
-
throw new Error(`pgserve daemon --listen: invalid port "${spec}"`);
|
|
698
|
-
}
|
|
699
|
-
return { host: host || '0.0.0.0', port };
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Convenience entry — used by the CLI subcommand.
|
|
704
|
-
*/
|
|
705
|
-
export async function startDaemon(options = {}) {
|
|
706
|
-
const daemon = new PgserveDaemon(options);
|
|
707
|
-
await daemon.start();
|
|
708
|
-
return daemon;
|
|
709
|
-
}
|