pgserve 2.3.0 → 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/dashboard.js
DELETED
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dashboard - Informative CLI startup display
|
|
3
|
-
*
|
|
4
|
-
* Hybrid approach:
|
|
5
|
-
* - Scrolling stages (preserved history)
|
|
6
|
-
* - In-place progress updates (only during restore)
|
|
7
|
-
* - Non-TTY fallback (works in pipes/CI)
|
|
8
|
-
*
|
|
9
|
-
* Zero external dependencies - pure Node.js ANSI codes
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { readFileSync } from 'fs';
|
|
13
|
-
import { join, dirname } from 'path';
|
|
14
|
-
import { fileURLToPath } from 'url';
|
|
15
|
-
|
|
16
|
-
// Get package version - BUILD_VERSION is injected at compile time via --define
|
|
17
|
-
// Falls back to reading package.json for development mode
|
|
18
|
-
let version = '1.0.0';
|
|
19
|
-
try {
|
|
20
|
-
// Check for build-time injected version first
|
|
21
|
-
if (typeof BUILD_VERSION !== 'undefined') {
|
|
22
|
-
version = BUILD_VERSION;
|
|
23
|
-
} else {
|
|
24
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
26
|
-
version = pkg.version;
|
|
27
|
-
}
|
|
28
|
-
} catch {
|
|
29
|
-
// Fallback version
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ANSI escape codes
|
|
33
|
-
const ANSI = {
|
|
34
|
-
CLEAR_LINE: '\x1B[2K',
|
|
35
|
-
MOVE_UP: (n) => `\x1B[${n}A`,
|
|
36
|
-
MOVE_DOWN: (n) => `\x1B[${n}B`,
|
|
37
|
-
HIDE_CURSOR: '\x1B[?25l',
|
|
38
|
-
SHOW_CURSOR: '\x1B[?25h',
|
|
39
|
-
GREEN: '\x1B[32m',
|
|
40
|
-
YELLOW: '\x1B[33m',
|
|
41
|
-
CYAN: '\x1B[36m',
|
|
42
|
-
DIM: '\x1B[2m',
|
|
43
|
-
RESET: '\x1B[0m',
|
|
44
|
-
BOLD: '\x1B[1m'
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Dashboard class for CLI output
|
|
49
|
-
*/
|
|
50
|
-
export class Dashboard {
|
|
51
|
-
constructor(options = {}) {
|
|
52
|
-
this.enabled = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
53
|
-
this.updateInterval = options.updateInterval || 200; // Throttle updates
|
|
54
|
-
this.lastUpdate = 0;
|
|
55
|
-
this.progressLines = 0; // Track lines to overwrite
|
|
56
|
-
this.restoreStartTime = 0;
|
|
57
|
-
this.config = options.config || {};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Show the startup header
|
|
62
|
-
*/
|
|
63
|
-
showHeader(config = {}) {
|
|
64
|
-
const mode = config.memoryMode ? 'In-memory' : 'Persistent';
|
|
65
|
-
const port = config.port || 8432;
|
|
66
|
-
const host = config.host || '127.0.0.1';
|
|
67
|
-
const syncTo = config.syncTo ? ` → ${this._maskUrl(config.syncTo)}` : '';
|
|
68
|
-
|
|
69
|
-
console.log('');
|
|
70
|
-
console.log(`${ANSI.BOLD}pgserve v${version}${ANSI.RESET} - Embedded PostgreSQL Server`);
|
|
71
|
-
console.log(`${ANSI.DIM}MODE: ${mode} | PORT: ${port} | HOST: ${host}${syncTo}${ANSI.RESET}`);
|
|
72
|
-
console.log('');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Log a stage completion
|
|
77
|
-
*/
|
|
78
|
-
stage(name, status = 'done') {
|
|
79
|
-
const icon = status === 'done' ? `${ANSI.GREEN}[✓]${ANSI.RESET}` :
|
|
80
|
-
status === 'error' ? `${ANSI.YELLOW}[✗]${ANSI.RESET}` :
|
|
81
|
-
`${ANSI.DIM}[○]${ANSI.RESET}`;
|
|
82
|
-
console.log(`${icon} ${name}`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Start restore progress section
|
|
87
|
-
*/
|
|
88
|
-
startRestore(totalDatabases, totalTables = 0, totalBytes = 0) {
|
|
89
|
-
this.restoreStartTime = Date.now();
|
|
90
|
-
this.totalDatabases = totalDatabases;
|
|
91
|
-
this.totalTables = totalTables || totalDatabases * 10; // Estimate
|
|
92
|
-
this.totalBytes = totalBytes;
|
|
93
|
-
|
|
94
|
-
console.log('');
|
|
95
|
-
console.log(`${ANSI.CYAN}Restoring from external PostgreSQL...${ANSI.RESET}`);
|
|
96
|
-
|
|
97
|
-
if (this.enabled) {
|
|
98
|
-
// Reserve lines for progress
|
|
99
|
-
console.log(' Databases: 0/0 [░░░░░░░░░░░░░░░░] 0%');
|
|
100
|
-
console.log(' Tables: 0/0 [░░░░░░░░░░░░░░░░] 0%');
|
|
101
|
-
console.log(' Speed: 0.0 MB/s | ETA: calculating...');
|
|
102
|
-
this.progressLines = 3;
|
|
103
|
-
process.stdout.write(ANSI.HIDE_CURSOR);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Update restore progress (in-place)
|
|
109
|
-
*/
|
|
110
|
-
updateRestore(metrics) {
|
|
111
|
-
if (!this.enabled) return;
|
|
112
|
-
|
|
113
|
-
// Throttle updates
|
|
114
|
-
const now = Date.now();
|
|
115
|
-
if (now - this.lastUpdate < this.updateInterval) return;
|
|
116
|
-
this.lastUpdate = now;
|
|
117
|
-
|
|
118
|
-
const {
|
|
119
|
-
databasesRestored = 0,
|
|
120
|
-
totalDatabases = this.totalDatabases || 1,
|
|
121
|
-
tablesRestored = 0,
|
|
122
|
-
totalTables = this.totalTables || 1,
|
|
123
|
-
bytesTransferred = 0
|
|
124
|
-
} = metrics;
|
|
125
|
-
|
|
126
|
-
// Calculate throughput and ETA
|
|
127
|
-
const elapsed = (now - this.restoreStartTime) / 1000;
|
|
128
|
-
const throughputMBps = elapsed > 0 ? (bytesTransferred / 1024 / 1024) / elapsed : 0;
|
|
129
|
-
const bytesRemaining = this.totalBytes - bytesTransferred;
|
|
130
|
-
const eta = throughputMBps > 0 ? Math.ceil(bytesRemaining / (throughputMBps * 1024 * 1024)) : 0;
|
|
131
|
-
|
|
132
|
-
// Format progress
|
|
133
|
-
const dbPct = Math.round((databasesRestored / totalDatabases) * 100);
|
|
134
|
-
const tablePct = Math.round((tablesRestored / totalTables) * 100);
|
|
135
|
-
const dbBar = this._progressBar(databasesRestored, totalDatabases);
|
|
136
|
-
const tableBar = this._progressBar(tablesRestored, totalTables);
|
|
137
|
-
const etaStr = eta > 0 ? `~${eta}s` : 'finishing...';
|
|
138
|
-
|
|
139
|
-
// Move up and overwrite
|
|
140
|
-
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
141
|
-
|
|
142
|
-
process.stdout.write(ANSI.CLEAR_LINE);
|
|
143
|
-
console.log(` Databases: ${String(databasesRestored).padStart(2)}/${totalDatabases} ${dbBar} ${String(dbPct).padStart(3)}%`);
|
|
144
|
-
|
|
145
|
-
process.stdout.write(ANSI.CLEAR_LINE);
|
|
146
|
-
console.log(` Tables: ${String(tablesRestored).padStart(3)}/${totalTables} ${tableBar} ${String(tablePct).padStart(3)}%`);
|
|
147
|
-
|
|
148
|
-
process.stdout.write(ANSI.CLEAR_LINE);
|
|
149
|
-
console.log(` Speed: ${throughputMBps.toFixed(1)} MB/s | ETA: ${etaStr}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Complete restore progress (replace with summary)
|
|
154
|
-
*/
|
|
155
|
-
completeRestore(metrics) {
|
|
156
|
-
if (this.enabled && this.progressLines > 0) {
|
|
157
|
-
// Move up and clear progress lines
|
|
158
|
-
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
159
|
-
for (let i = 0; i < this.progressLines; i++) {
|
|
160
|
-
process.stdout.write(ANSI.CLEAR_LINE + '\n');
|
|
161
|
-
}
|
|
162
|
-
process.stdout.write(ANSI.MOVE_UP(this.progressLines));
|
|
163
|
-
process.stdout.write(ANSI.SHOW_CURSOR);
|
|
164
|
-
this.progressLines = 0;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const duration = ((metrics.endTime || Date.now()) - this.restoreStartTime) / 1000;
|
|
168
|
-
const mb = (metrics.bytesTransferred / 1024 / 1024).toFixed(1);
|
|
169
|
-
|
|
170
|
-
console.log(`${ANSI.GREEN}[✓]${ANSI.RESET} Restored ${metrics.databasesRestored} database${metrics.databasesRestored !== 1 ? 's' : ''} (${mb} MB in ${duration.toFixed(1)}s)`);
|
|
171
|
-
console.log('');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Show final ready message
|
|
176
|
-
*/
|
|
177
|
-
showReady(config = {}) {
|
|
178
|
-
const port = config.port || 8432;
|
|
179
|
-
const host = config.host || '127.0.0.1';
|
|
180
|
-
|
|
181
|
-
console.log('');
|
|
182
|
-
console.log(`${ANSI.GREEN}${ANSI.BOLD}✨ READY${ANSI.RESET}: postgresql://${host}:${port}/<database>`);
|
|
183
|
-
console.log(`${ANSI.DIM}Press Ctrl+C to stop${ANSI.RESET}`);
|
|
184
|
-
console.log('');
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Generate progress bar
|
|
189
|
-
*/
|
|
190
|
-
_progressBar(current, total, width = 16) {
|
|
191
|
-
const pct = total > 0 ? current / total : 0;
|
|
192
|
-
const filled = Math.round(pct * width);
|
|
193
|
-
const empty = width - filled;
|
|
194
|
-
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Mask sensitive URL parts
|
|
199
|
-
*/
|
|
200
|
-
_maskUrl(url) {
|
|
201
|
-
try {
|
|
202
|
-
const u = new URL(url);
|
|
203
|
-
return `${u.protocol}//${u.host}/${u.pathname.split('/')[1] || ''}`;
|
|
204
|
-
} catch {
|
|
205
|
-
return url.replace(/:[^:@]+@/, ':***@');
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Cleanup (show cursor if hidden)
|
|
211
|
-
*/
|
|
212
|
-
cleanup() {
|
|
213
|
-
if (this.enabled) {
|
|
214
|
-
process.stdout.write(ANSI.SHOW_CURSOR);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
package/src/fingerprint.js
DELETED
|
@@ -1,479 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve fingerprint — kernel-rooted peer identity.
|
|
3
|
-
*
|
|
4
|
-
* On every accept on the daemon's control socket, the daemon needs to derive
|
|
5
|
-
* a stable, 12-hex fingerprint that identifies the calling project. The chain:
|
|
6
|
-
*
|
|
7
|
-
* 1. SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
|
|
8
|
-
* → kernel-attested {pid, uid, gid}
|
|
9
|
-
* 2. peer cwd lookup → /proc/$pid/cwd on Linux, lsof on macOS
|
|
10
|
-
* 3. walk upward to the nearest package.json
|
|
11
|
-
* 4. if found: fingerprint = sha256(realpath \0 name \0 uid)[:12] mode='package'
|
|
12
|
-
* else: fingerprint = sha256(uid \0 cwd \0 cmdline[1])[:12] mode='script'
|
|
13
|
-
*
|
|
14
|
-
* Properties:
|
|
15
|
-
* - Identity is kernel-rooted: peer can't lie about uid/pid.
|
|
16
|
-
* - Stable across `cwd` changes inside the same project, across
|
|
17
|
-
* `npm install` (we hash realpath), and across runtime swaps
|
|
18
|
-
* (Bun ↔ Node — neither argv[0] nor exe path enters the input).
|
|
19
|
-
* - Monorepos: nearest-ancestor package.json wins (deepest match), matching
|
|
20
|
-
* the `require.resolve` mental model.
|
|
21
|
-
*
|
|
22
|
-
* Daemon integration (Group 2 wires this in):
|
|
23
|
-
* import { handleControlAccept, initFingerprintFfi } from './fingerprint.js';
|
|
24
|
-
* await initFingerprintFfi(); // once at daemon boot
|
|
25
|
-
* server.on('connection', (socket) => {
|
|
26
|
-
* const info = handleControlAccept(socket); // also emits connection_routed
|
|
27
|
-
* // ... resolve DB by info.fingerprint, etc.
|
|
28
|
-
* });
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import crypto from 'crypto';
|
|
32
|
-
import { execFileSync } from 'child_process';
|
|
33
|
-
import fs from 'fs';
|
|
34
|
-
import path from 'path';
|
|
35
|
-
import { audit, AUDIT_EVENTS } from './audit.js';
|
|
36
|
-
|
|
37
|
-
// ---------------------------------------------------------------------------
|
|
38
|
-
// Peer credentials: SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
let _peerCredImpl = null; // populated by initFingerprintFfi()
|
|
42
|
-
let _peerCredOverride = null; // test seam — see _setPeerCredImpl()
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Read kernel-attested peer credentials from a connected Unix socket.
|
|
46
|
-
*
|
|
47
|
-
* @param {import('net').Socket | number} socket
|
|
48
|
-
* @returns {{pid: number, uid: number, gid: number}}
|
|
49
|
-
*/
|
|
50
|
-
export function getPeerCred(socket) {
|
|
51
|
-
if (_peerCredOverride) return _peerCredOverride(socket);
|
|
52
|
-
if (!_peerCredImpl) {
|
|
53
|
-
throw new Error('getPeerCred: FFI not initialized — call await initFingerprintFfi() first');
|
|
54
|
-
}
|
|
55
|
-
const fd = extractFd(socket);
|
|
56
|
-
if (fd == null || fd < 0) {
|
|
57
|
-
throw new Error('getPeerCred: socket has no accessible file descriptor');
|
|
58
|
-
}
|
|
59
|
-
return _peerCredImpl(fd);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function extractFd(socket) {
|
|
63
|
-
if (typeof socket === 'number') return socket;
|
|
64
|
-
if (socket?._handle && typeof socket._handle.fd === 'number') return socket._handle.fd;
|
|
65
|
-
if (typeof socket?.fd === 'number') return socket.fd;
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Pre-warm the native FFI handle. Call once at daemon startup; tests call it
|
|
71
|
-
* in their before-all hook. Safe to call repeatedly.
|
|
72
|
-
*
|
|
73
|
-
* Bypassed when a test override is installed via `_setPeerCredImpl`.
|
|
74
|
-
*
|
|
75
|
-
* @returns {Promise<void>}
|
|
76
|
-
*/
|
|
77
|
-
export async function initFingerprintFfi() {
|
|
78
|
-
if (_peerCredOverride) return;
|
|
79
|
-
if (_peerCredImpl) return;
|
|
80
|
-
if (process.platform !== 'linux' && process.platform !== 'darwin') {
|
|
81
|
-
throw new Error(`initFingerprintFfi: unsupported platform "${process.platform}"`);
|
|
82
|
-
}
|
|
83
|
-
// bun:ffi loaded via dynamic import so plain-Node consumers can still
|
|
84
|
-
// import the module surface without crashing on module-eval.
|
|
85
|
-
const ffi = await import('bun:ffi');
|
|
86
|
-
// glibc on Linux ships as libc.so.6 (versioned); macOS ships libc.dylib.
|
|
87
|
-
const libcCandidates = process.platform === 'linux'
|
|
88
|
-
? ['libc.so.6', 'libc.so']
|
|
89
|
-
: [`libc.${ffi.suffix}`];
|
|
90
|
-
const lib = openFirst(ffi, libcCandidates, process.platform === 'linux'
|
|
91
|
-
? {
|
|
92
|
-
getsockopt: {
|
|
93
|
-
args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
|
|
94
|
-
returns: ffi.FFIType.i32,
|
|
95
|
-
},
|
|
96
|
-
}
|
|
97
|
-
: {
|
|
98
|
-
getpeereid: {
|
|
99
|
-
args: [ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
|
|
100
|
-
returns: ffi.FFIType.i32,
|
|
101
|
-
},
|
|
102
|
-
getsockopt: {
|
|
103
|
-
args: [ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.i32, ffi.FFIType.ptr, ffi.FFIType.ptr],
|
|
104
|
-
returns: ffi.FFIType.i32,
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
_peerCredImpl = process.platform === 'linux'
|
|
108
|
-
? makeLinuxReader(lib.symbols, ffi.ptr)
|
|
109
|
-
: makeDarwinReader(lib.symbols, ffi.ptr);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function openFirst(ffi, candidates, signatures) {
|
|
113
|
-
let lastError;
|
|
114
|
-
for (const name of candidates) {
|
|
115
|
-
try {
|
|
116
|
-
return ffi.dlopen(name, signatures);
|
|
117
|
-
} catch (err) {
|
|
118
|
-
lastError = err;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
throw new Error(
|
|
122
|
-
`initFingerprintFfi: dlopen failed for [${candidates.join(', ')}]: ${lastError?.message ?? lastError}`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function makeLinuxReader(symbols, ptr) {
|
|
127
|
-
// getsockopt(fd, SOL_SOCKET=1, SO_PEERCRED=17, &ucred, &len)
|
|
128
|
-
// ucred = { pid: i32, uid: u32, gid: u32 } — 12 bytes packed.
|
|
129
|
-
const SOL_SOCKET = 1;
|
|
130
|
-
const SO_PEERCRED = 17;
|
|
131
|
-
return (fd) => {
|
|
132
|
-
const buf = new ArrayBuffer(12);
|
|
133
|
-
const view = new DataView(buf);
|
|
134
|
-
const len = new Uint32Array([12]);
|
|
135
|
-
const rc = symbols.getsockopt(fd, SOL_SOCKET, SO_PEERCRED, ptr(buf), ptr(len));
|
|
136
|
-
if (rc !== 0) {
|
|
137
|
-
throw new Error(`getsockopt SO_PEERCRED failed (rc=${rc}, fd=${fd})`);
|
|
138
|
-
}
|
|
139
|
-
return {
|
|
140
|
-
pid: view.getInt32(0, true),
|
|
141
|
-
uid: view.getUint32(4, true),
|
|
142
|
-
gid: view.getUint32(8, true),
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function makeDarwinReader(symbols, ptr) {
|
|
148
|
-
// macOS: getpeereid(fd, &uid, &gid) for credentials (no PID).
|
|
149
|
-
// LOCAL_PEERPID via getsockopt(SOL_LOCAL=0, optname=2) supplies the pid
|
|
150
|
-
// when the kernel has it; otherwise pid=0 (unknown but tolerated — the
|
|
151
|
-
// fingerprint never depends on pid, only the GC liveness probe does).
|
|
152
|
-
const SOL_LOCAL = 0;
|
|
153
|
-
const LOCAL_PEERPID = 2;
|
|
154
|
-
return (fd) => {
|
|
155
|
-
const idBuf = new ArrayBuffer(8);
|
|
156
|
-
const idView = new DataView(idBuf);
|
|
157
|
-
const uidPtr = ptr(idBuf, 0);
|
|
158
|
-
const gidPtr = ptr(idBuf, 4);
|
|
159
|
-
const rc = symbols.getpeereid(fd, uidPtr, gidPtr);
|
|
160
|
-
if (rc !== 0) {
|
|
161
|
-
throw new Error(`getpeereid failed (rc=${rc}, fd=${fd})`);
|
|
162
|
-
}
|
|
163
|
-
const uid = idView.getUint32(0, true);
|
|
164
|
-
const gid = idView.getUint32(4, true);
|
|
165
|
-
let pid = 0;
|
|
166
|
-
try {
|
|
167
|
-
const pidBuf = new ArrayBuffer(4);
|
|
168
|
-
const pidView = new DataView(pidBuf);
|
|
169
|
-
const len = new Uint32Array([4]);
|
|
170
|
-
if (symbols.getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, ptr(pidBuf), ptr(len)) === 0) {
|
|
171
|
-
pid = pidView.getInt32(0, true);
|
|
172
|
-
}
|
|
173
|
-
} catch { /* LOCAL_PEERPID unsupported on this kernel */ }
|
|
174
|
-
return { pid, uid, gid };
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
// Peer process metadata reads
|
|
180
|
-
// ---------------------------------------------------------------------------
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Resolve the cwd of a peer process. Linux uses /proc/$pid/cwd; macOS has no
|
|
184
|
-
* /proc, so it shells out to the platform lsof binary and parses the cwd row.
|
|
185
|
-
* Returns null if the process disappeared, permissions deny the lookup, or the
|
|
186
|
-
* host does not expose a cwd for the peer.
|
|
187
|
-
*
|
|
188
|
-
* @param {number} pid
|
|
189
|
-
* @returns {string | null}
|
|
190
|
-
*/
|
|
191
|
-
export function readProcCwd(pid) {
|
|
192
|
-
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
193
|
-
if (process.platform === 'darwin') return readDarwinCwd(pid);
|
|
194
|
-
if (process.platform !== 'linux') return null;
|
|
195
|
-
try {
|
|
196
|
-
return fs.readlinkSync(`/proc/${pid}/cwd`);
|
|
197
|
-
} catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function readDarwinCwd(pid) {
|
|
203
|
-
const lsof = process.env.PGSERVE_LSOF_BIN || '/usr/sbin/lsof';
|
|
204
|
-
try {
|
|
205
|
-
const output = execFileSync(lsof, ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
|
|
206
|
-
encoding: 'utf8',
|
|
207
|
-
timeout: 1000,
|
|
208
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
209
|
-
});
|
|
210
|
-
return parseDarwinLsofCwd(output);
|
|
211
|
-
} catch {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function parseDarwinLsofCwd(output) {
|
|
217
|
-
for (const line of String(output || '').split(/\r?\n/)) {
|
|
218
|
-
if (line.startsWith('n') && line.length > 1) return line.slice(1);
|
|
219
|
-
}
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Read the peer's argv via /proc/$pid/cmdline (NUL-separated).
|
|
225
|
-
* argv[0] is the exe; argv[1] is typically the script.
|
|
226
|
-
*
|
|
227
|
-
* @param {number} pid
|
|
228
|
-
* @returns {string[]}
|
|
229
|
-
*/
|
|
230
|
-
export function readProcCmdline(pid) {
|
|
231
|
-
if (process.platform !== 'linux') return [];
|
|
232
|
-
try {
|
|
233
|
-
const raw = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
234
|
-
if (!raw) return [];
|
|
235
|
-
const trimmed = raw.endsWith('\0') ? raw.slice(0, -1) : raw;
|
|
236
|
-
return trimmed.split('\0');
|
|
237
|
-
} catch {
|
|
238
|
-
return [];
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
// package.json discovery
|
|
244
|
-
// ---------------------------------------------------------------------------
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Walk upward from `startCwd` to the filesystem root, returning the realpath
|
|
248
|
-
* of the nearest ancestor `package.json`. Returns null if none found.
|
|
249
|
-
*
|
|
250
|
-
* Matches Node's `require.resolve` mental model: nested package.json wins
|
|
251
|
-
* (deepest match). Monorepos that want the workspace root to win must opt
|
|
252
|
-
* in via `pgserve.fingerprintRoot: "monorepo-root"` (deferred to v2.1).
|
|
253
|
-
*
|
|
254
|
-
* @param {string} startCwd
|
|
255
|
-
* @returns {string | null} — absolute realpath to package.json, or null
|
|
256
|
-
*/
|
|
257
|
-
export function findNearestPackageJson(startCwd) {
|
|
258
|
-
if (!startCwd) return null;
|
|
259
|
-
let dir;
|
|
260
|
-
try {
|
|
261
|
-
dir = fs.realpathSync(startCwd);
|
|
262
|
-
} catch {
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
while (true) {
|
|
266
|
-
const candidate = path.join(dir, 'package.json');
|
|
267
|
-
if (fs.existsSync(candidate)) {
|
|
268
|
-
try {
|
|
269
|
-
return fs.realpathSync(candidate);
|
|
270
|
-
} catch {
|
|
271
|
-
return candidate;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const parent = path.dirname(dir);
|
|
275
|
-
if (parent === dir) return null;
|
|
276
|
-
dir = parent;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Read the `name` field from a package.json file. Returns null if absent,
|
|
282
|
-
* malformed, or non-string.
|
|
283
|
-
*
|
|
284
|
-
* @param {string} packageJsonPath
|
|
285
|
-
* @returns {string | null}
|
|
286
|
-
*/
|
|
287
|
-
export function readPackageName(packageJsonPath) {
|
|
288
|
-
try {
|
|
289
|
-
const raw = fs.readFileSync(packageJsonPath, 'utf8');
|
|
290
|
-
const pkg = JSON.parse(raw);
|
|
291
|
-
return typeof pkg?.name === 'string' && pkg.name.length > 0 ? pkg.name : null;
|
|
292
|
-
} catch {
|
|
293
|
-
return null;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Read the `pgserve.persist` flag from a package.json file. Returns false on
|
|
299
|
-
* any error (missing file, malformed JSON, missing field) — the default
|
|
300
|
-
* lifecycle is ephemeral; persist must be explicitly opted in.
|
|
301
|
-
*
|
|
302
|
-
* @param {string} packageJsonPath
|
|
303
|
-
* @returns {boolean}
|
|
304
|
-
*/
|
|
305
|
-
export function readPersistFlag(packageJsonPath) {
|
|
306
|
-
if (!packageJsonPath) return false;
|
|
307
|
-
try {
|
|
308
|
-
const raw = fs.readFileSync(packageJsonPath, 'utf8');
|
|
309
|
-
const pkg = JSON.parse(raw);
|
|
310
|
-
return pkg?.pgserve?.persist === true;
|
|
311
|
-
} catch {
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// Fingerprint derivations
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* `sha256(packageRealpath \0 name \0 uid)[:12]`
|
|
322
|
-
*
|
|
323
|
-
* NUL separators prevent collision-by-concatenation (e.g. project named
|
|
324
|
-
* "abc 1000" can't impersonate uid=1000 / project=abc).
|
|
325
|
-
*
|
|
326
|
-
* @param {{packageRealpath: string, name: string, uid: number|string}} args
|
|
327
|
-
* @returns {string} 12 lowercase hex chars
|
|
328
|
-
*/
|
|
329
|
-
export function derivePackageFingerprint({ packageRealpath, name, uid }) {
|
|
330
|
-
if (!packageRealpath) throw new Error('derivePackageFingerprint: packageRealpath required');
|
|
331
|
-
if (typeof name !== 'string') throw new Error('derivePackageFingerprint: name must be string');
|
|
332
|
-
if (uid === undefined || uid === null) throw new Error('derivePackageFingerprint: uid required');
|
|
333
|
-
const input = `${packageRealpath}\0${name}\0${String(uid)}`;
|
|
334
|
-
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* Script fallback: hashes uid + cwd + cmdline[1]. Used when no package.json
|
|
339
|
-
* exists above the peer's cwd (e.g. a one-off `bun script.js` outside any
|
|
340
|
-
* project root).
|
|
341
|
-
*
|
|
342
|
-
* @param {{uid: number|string, cwd: string, cmdline1: string}} args
|
|
343
|
-
* @returns {string} 12 lowercase hex chars
|
|
344
|
-
*/
|
|
345
|
-
export function deriveScriptFingerprint({ uid, cwd, cmdline1 }) {
|
|
346
|
-
if (uid === undefined || uid === null) throw new Error('deriveScriptFingerprint: uid required');
|
|
347
|
-
if (!cwd) throw new Error('deriveScriptFingerprint: cwd required');
|
|
348
|
-
const script = cmdline1 || '';
|
|
349
|
-
const input = `${String(uid)}\0${cwd}\0${script}`;
|
|
350
|
-
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ---------------------------------------------------------------------------
|
|
354
|
-
// Top-level resolver
|
|
355
|
-
// ---------------------------------------------------------------------------
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* @typedef {{
|
|
359
|
-
* fingerprint: string,
|
|
360
|
-
* packageRealpath: string | null,
|
|
361
|
-
* name: string | null,
|
|
362
|
-
* uid: number,
|
|
363
|
-
* pid: number,
|
|
364
|
-
* gid: number,
|
|
365
|
-
* mode: 'package' | 'script',
|
|
366
|
-
* cwd: string | null,
|
|
367
|
-
* }} FingerprintInfo
|
|
368
|
-
*/
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* End-to-end: read peer creds, resolve cwd via /proc, find package.json
|
|
372
|
-
* (or fall back to script mode), produce the 12-hex fingerprint.
|
|
373
|
-
*
|
|
374
|
-
* @param {import('net').Socket | number} socket
|
|
375
|
-
* @returns {FingerprintInfo}
|
|
376
|
-
*/
|
|
377
|
-
export function fingerprintForPeer(socket) {
|
|
378
|
-
return fingerprintFromCred(getPeerCred(socket));
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Pure-input variant: bypass the FFI step. Used by daemon paths that
|
|
383
|
-
* already have peer creds and by tests that don't want to spin up a real
|
|
384
|
-
* Unix socket pair.
|
|
385
|
-
*
|
|
386
|
-
* @param {{pid: number, uid: number, gid: number}} cred
|
|
387
|
-
* @param {{cwdOverride?: string, cmdlineOverride?: string[]}} [opts]
|
|
388
|
-
* @returns {FingerprintInfo}
|
|
389
|
-
*/
|
|
390
|
-
export function fingerprintFromCred(cred, opts = {}) {
|
|
391
|
-
if (!cred || typeof cred.uid !== 'number' || typeof cred.pid !== 'number') {
|
|
392
|
-
throw new Error('fingerprintFromCred: cred must have numeric pid+uid');
|
|
393
|
-
}
|
|
394
|
-
const cwd = opts.cwdOverride !== undefined ? opts.cwdOverride : readProcCwd(cred.pid);
|
|
395
|
-
const cmdline = opts.cmdlineOverride !== undefined ? opts.cmdlineOverride : readProcCmdline(cred.pid);
|
|
396
|
-
|
|
397
|
-
const pkgPath = cwd ? findNearestPackageJson(cwd) : null;
|
|
398
|
-
if (pkgPath) {
|
|
399
|
-
const name = readPackageName(pkgPath) ?? '';
|
|
400
|
-
const fingerprint = derivePackageFingerprint({
|
|
401
|
-
packageRealpath: pkgPath,
|
|
402
|
-
name,
|
|
403
|
-
uid: cred.uid,
|
|
404
|
-
});
|
|
405
|
-
return {
|
|
406
|
-
fingerprint,
|
|
407
|
-
packageRealpath: pkgPath,
|
|
408
|
-
name,
|
|
409
|
-
uid: cred.uid,
|
|
410
|
-
pid: cred.pid,
|
|
411
|
-
gid: cred.gid,
|
|
412
|
-
mode: 'package',
|
|
413
|
-
cwd,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const fingerprint = deriveScriptFingerprint({
|
|
418
|
-
uid: cred.uid,
|
|
419
|
-
cwd: cwd || '',
|
|
420
|
-
cmdline1: cmdline[1] || '',
|
|
421
|
-
});
|
|
422
|
-
return {
|
|
423
|
-
fingerprint,
|
|
424
|
-
packageRealpath: null,
|
|
425
|
-
name: null,
|
|
426
|
-
uid: cred.uid,
|
|
427
|
-
pid: cred.pid,
|
|
428
|
-
gid: cred.gid,
|
|
429
|
-
mode: 'script',
|
|
430
|
-
cwd,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ---------------------------------------------------------------------------
|
|
435
|
-
// Daemon accept hook
|
|
436
|
-
// ---------------------------------------------------------------------------
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Wrap `fingerprintForPeer` with a `connection_routed` audit emit.
|
|
440
|
-
* The daemon (Group 2) calls this on every control-socket accept.
|
|
441
|
-
*
|
|
442
|
-
* @param {import('net').Socket | number} socket
|
|
443
|
-
* @param {{cwdOverride?: string, cmdlineOverride?: string[], auditTarget?: 'file'|'syslog'}} [opts]
|
|
444
|
-
* @returns {FingerprintInfo}
|
|
445
|
-
*/
|
|
446
|
-
export function handleControlAccept(socket, opts = {}) {
|
|
447
|
-
const useOverrides = opts.cwdOverride !== undefined || opts.cmdlineOverride !== undefined;
|
|
448
|
-
const info = useOverrides
|
|
449
|
-
? fingerprintFromCred(getPeerCred(socket), opts)
|
|
450
|
-
: fingerprintForPeer(socket);
|
|
451
|
-
audit(
|
|
452
|
-
AUDIT_EVENTS.CONNECTION_ROUTED,
|
|
453
|
-
{
|
|
454
|
-
fingerprint: info.fingerprint,
|
|
455
|
-
mode: info.mode,
|
|
456
|
-
peer_pid: info.pid,
|
|
457
|
-
peer_uid: info.uid,
|
|
458
|
-
package_realpath: info.packageRealpath,
|
|
459
|
-
name: info.name,
|
|
460
|
-
},
|
|
461
|
-
opts.auditTarget ? { target: opts.auditTarget } : {},
|
|
462
|
-
);
|
|
463
|
-
return info;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ---------------------------------------------------------------------------
|
|
467
|
-
// Test seam
|
|
468
|
-
// ---------------------------------------------------------------------------
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Internal: tests can stub the peer-cred reader to avoid spinning up real
|
|
472
|
-
* Unix socket pairs when they only care about the cwd/walk/hash logic.
|
|
473
|
-
* Pass `null` to restore the default native reader.
|
|
474
|
-
*
|
|
475
|
-
* @param {((socket: any) => {pid:number, uid:number, gid:number}) | null} fn
|
|
476
|
-
*/
|
|
477
|
-
export function _setPeerCredImpl(fn) {
|
|
478
|
-
_peerCredOverride = fn;
|
|
479
|
-
}
|