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/postgres.js
CHANGED
|
@@ -548,6 +548,15 @@ export class PostgresManager extends EventEmitter {
|
|
|
548
548
|
this.binaries = null;
|
|
549
549
|
this.creatingDatabases = new Map(); // Track in-progress creations
|
|
550
550
|
this.socketDir = null; // Unix socket directory for faster local connections
|
|
551
|
+
// pgserve singleton (v2.4): callers that own the socket directory (e.g.
|
|
552
|
+
// `pgserve postmaster` invoked under pm2 or a systemd-user unit) pass an
|
|
553
|
+
// explicit, externally-managed path so libpq peers connecting via
|
|
554
|
+
// `psql -h $XDG_RUNTIME_DIR/pgserve` reach a stable, well-known socket.
|
|
555
|
+
// When unset we fall back to the legacy per-pid `os.tmpdir()` path so
|
|
556
|
+
// foreground / daemon / cluster modes keep working unchanged.
|
|
557
|
+
this.explicitSocketDir = typeof options.socketDir === 'string' && options.socketDir.length > 0
|
|
558
|
+
? options.socketDir
|
|
559
|
+
: null;
|
|
551
560
|
this.adminPool = null; // Connection pool for database admin operations
|
|
552
561
|
this.useRam = options.useRam || false; // Use /dev/shm for true RAM storage (Linux only)
|
|
553
562
|
this.isTrueRam = false; // Tracks if we're actually using RAM storage
|
|
@@ -634,9 +643,24 @@ export class PostgresManager extends EventEmitter {
|
|
|
634
643
|
|
|
635
644
|
// Create Unix socket directory (Linux/macOS only, Windows uses TCP)
|
|
636
645
|
if (os.platform() !== 'win32') {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
646
|
+
if (this.explicitSocketDir) {
|
|
647
|
+
// pgserve singleton (v2.4): operator-supplied socket dir is created
|
|
648
|
+
// and chmoded by the install path (`pgserve install`); we only
|
|
649
|
+
// refuse to start when it doesn't exist so the operator's intent
|
|
650
|
+
// surfaces cleanly instead of postgres bailing on bind() with a
|
|
651
|
+
// cryptic libpq error.
|
|
652
|
+
if (!fs.existsSync(this.explicitSocketDir)) {
|
|
653
|
+
throw new Error(
|
|
654
|
+
`pgserve: socketDir does not exist: ${this.explicitSocketDir}. `
|
|
655
|
+
+ `Run \`pgserve install\` to create it (or pass --socket-dir <existing-dir>).`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
this.socketDir = this.explicitSocketDir;
|
|
659
|
+
} else {
|
|
660
|
+
this.socketDir = path.join(os.tmpdir(), `pgserve-sock-${process.pid}-${Date.now()}`);
|
|
661
|
+
if (!fs.existsSync(this.socketDir)) {
|
|
662
|
+
fs.mkdirSync(this.socketDir, { recursive: true, mode: 0o700 });
|
|
663
|
+
}
|
|
640
664
|
}
|
|
641
665
|
}
|
|
642
666
|
|
|
@@ -894,6 +918,33 @@ export class PostgresManager extends EventEmitter {
|
|
|
894
918
|
...gucArgs,
|
|
895
919
|
];
|
|
896
920
|
|
|
921
|
+
// pgserve singleton (v2.4): the bun-side audit-log writer is gone.
|
|
922
|
+
// Audit moves to postgres-native logging — `pgaudit` if the .so is
|
|
923
|
+
// bundled with the embedded postgres distribution, `log_statement`
|
|
924
|
+
// as a portable fallback otherwise. The wish ships the contract;
|
|
925
|
+
// shipping the pgaudit binary is a separate cohort task. Either way
|
|
926
|
+
// pm2 captures stderr to `<configDir>/logs/autopg-server-error.log`
|
|
927
|
+
// and `logrotate.d/pgserve` rotates it.
|
|
928
|
+
const pgauditPath = path.join(this.binaries.libDir, '..', 'lib', 'postgresql', 'pgaudit.so');
|
|
929
|
+
if (fs.existsSync(pgauditPath)) {
|
|
930
|
+
pgArgs.push('-c', 'shared_preload_libraries=pgaudit');
|
|
931
|
+
pgArgs.push('-c', 'pgaudit.log=all');
|
|
932
|
+
pgArgs.push('-c', 'pgaudit.log_catalog=off');
|
|
933
|
+
appliedGucs.shared_preload_libraries = 'pgaudit';
|
|
934
|
+
appliedGucs['pgaudit.log'] = 'all';
|
|
935
|
+
} else {
|
|
936
|
+
// Fallback: postgres-native log_statement='all' captures every
|
|
937
|
+
// query without the pgaudit-specific class taxonomy. Operators
|
|
938
|
+
// get an audit trail today; the cohort can swap to pgaudit later
|
|
939
|
+
// by dropping the .so into the embedded-postgres bundle.
|
|
940
|
+
pgArgs.push('-c', 'log_statement=all');
|
|
941
|
+
appliedGucs.log_statement = 'all';
|
|
942
|
+
this.logger?.warn(
|
|
943
|
+
{ pgauditPath },
|
|
944
|
+
'pgaudit.so not found in embedded postgres lib dir; falling back to log_statement=all for audit',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
|
|
897
948
|
// Enable Unix socket for faster local connections (Linux/macOS)
|
|
898
949
|
// Windows falls back to TCP only
|
|
899
950
|
if (this.socketDir) {
|
|
@@ -1574,8 +1625,16 @@ export class PostgresManager extends EventEmitter {
|
|
|
1574
1625
|
}
|
|
1575
1626
|
}
|
|
1576
1627
|
|
|
1577
|
-
// Clean up socket directory
|
|
1578
|
-
|
|
1628
|
+
// Clean up socket directory.
|
|
1629
|
+
// pgserve singleton (v2.4): when the caller supplied an explicit
|
|
1630
|
+
// socket dir (operator-owned canonical path under
|
|
1631
|
+
// `$XDG_RUNTIME_DIR/pgserve` or `/tmp/pgserve`), the install path
|
|
1632
|
+
// owns the directory's lifecycle — postgres unlinks its own
|
|
1633
|
+
// `.s.PGSQL.<port>` + `.lock` files on graceful shutdown, and
|
|
1634
|
+
// tearing the directory down here would race with operator tooling
|
|
1635
|
+
// (pm2 restarts, doctor --fix, etc.). Only sweep the legacy
|
|
1636
|
+
// per-pid `os.tmpdir()/pgserve-sock-*` form we generated ourselves.
|
|
1637
|
+
if (this.socketDir && !this.explicitSocketDir) {
|
|
1579
1638
|
try {
|
|
1580
1639
|
fs.rmSync(this.socketDir, { recursive: true, force: true });
|
|
1581
1640
|
this.logger.debug({ socketDir: this.socketDir }, 'Cleaned up socket directory');
|
package/src/admin-client.js
DELETED
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Admin DB client — small Bun.SQL wrapper exposing the `{query, end}`
|
|
3
|
-
* surface that `src/control-db.js` expects.
|
|
4
|
-
*
|
|
5
|
-
* The daemon and the `pgserve daemon issue-token / revoke-token` CLI
|
|
6
|
-
* subcommands both need a privileged connection to the underlying
|
|
7
|
-
* Postgres instance owned by `PostgresManager`. The pg npm module is
|
|
8
|
-
* a devDependency only (it backs the test harness); rather than promote
|
|
9
|
-
* it to runtime we wrap Bun.SQL — which is shipped with the runtime —
|
|
10
|
-
* in the parameterised-query interface control-db.js documents.
|
|
11
|
-
*
|
|
12
|
-
* Connection target:
|
|
13
|
-
* - Local Unix socket when `socketDir` is provided (the daemon's
|
|
14
|
-
* hot path) — drops the bytes onto the kernel-local socket.
|
|
15
|
-
* - TCP fallback when `socketDir` is null (e.g. CI hosts without
|
|
16
|
-
* the embedded socket directory present).
|
|
17
|
-
*
|
|
18
|
-
* The CLI side reads the daemon's discovery file at
|
|
19
|
-
* `${controlSocketDir}/admin.json` to learn `{socketDir, port}`.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { SQL } from 'bun';
|
|
23
|
-
import fs from 'fs';
|
|
24
|
-
import path from 'path';
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {object} args
|
|
28
|
-
* @param {string|null} [args.socketDir] — accepted for parity with the
|
|
29
|
-
* embedded-postgres callers but unused; Bun.SQL's startup auth path
|
|
30
|
-
* does not currently traverse `pg_hba.conf` Unix-socket trust rules
|
|
31
|
-
* against `embedded-postgres`, so we always go TCP for admin work.
|
|
32
|
-
* Keeping the parameter avoids a churning call-site signature.
|
|
33
|
-
* @param {string} [args.host='127.0.0.1']
|
|
34
|
-
* @param {number} args.port
|
|
35
|
-
* @param {string} [args.database='postgres']
|
|
36
|
-
* @param {string} [args.user='postgres']
|
|
37
|
-
* @param {string} [args.password='postgres']
|
|
38
|
-
* @param {number} [args.max=2]
|
|
39
|
-
* @param {number} [args.idleTimeout=300]
|
|
40
|
-
* @param {number} [args.queryTimeoutMs=0]
|
|
41
|
-
* @returns {Promise<{supportsQueryOptions: boolean, query: (text: string, params?: any[], opts?: {timeoutMs?: number}) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
|
|
42
|
-
*/
|
|
43
|
-
export async function createAdminClient({
|
|
44
|
-
socketDir: _socketDir = null,
|
|
45
|
-
host = '127.0.0.1',
|
|
46
|
-
port,
|
|
47
|
-
database = 'postgres',
|
|
48
|
-
user = 'postgres',
|
|
49
|
-
password = 'postgres',
|
|
50
|
-
max = 2,
|
|
51
|
-
idleTimeout = 300,
|
|
52
|
-
queryTimeoutMs = 0,
|
|
53
|
-
} = {}) {
|
|
54
|
-
if (typeof port !== 'number') throw new Error('createAdminClient: port required');
|
|
55
|
-
const options = {
|
|
56
|
-
hostname: host,
|
|
57
|
-
port,
|
|
58
|
-
database,
|
|
59
|
-
username: user,
|
|
60
|
-
password,
|
|
61
|
-
max,
|
|
62
|
-
idleTimeout,
|
|
63
|
-
};
|
|
64
|
-
let sql = new SQL(options);
|
|
65
|
-
// Light probe so a misconfigured daemon fails loudly here rather than at
|
|
66
|
-
// first query.
|
|
67
|
-
await sql`SELECT 1`;
|
|
68
|
-
|
|
69
|
-
async function reopen() {
|
|
70
|
-
const closing = sql;
|
|
71
|
-
sql = new SQL(options);
|
|
72
|
-
void closing.close().catch(() => { /* swallow */ });
|
|
73
|
-
await sql`SELECT 1`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return {
|
|
77
|
-
supportsQueryOptions: true,
|
|
78
|
-
get sql() {
|
|
79
|
-
return sql;
|
|
80
|
-
},
|
|
81
|
-
async query(text, params = [], opts = {}) {
|
|
82
|
-
// control-db.js is written for the pg npm module's contract, which
|
|
83
|
-
// requires JSON-stringified payloads bound to JSONB parameters.
|
|
84
|
-
// Bun.SQL goes the other way: it stringifies JS objects when they
|
|
85
|
-
// hit JSONB columns, but a JS string headed for `::jsonb` is sent
|
|
86
|
-
// as a JSON string literal (i.e. `"\"..."\"` rather than the array
|
|
87
|
-
// it represents). Bridge the impedance mismatch here so the same
|
|
88
|
-
// call sites work against either driver.
|
|
89
|
-
const adapted = params.map(coerceJsonbParam);
|
|
90
|
-
const timeoutMs = opts.timeoutMs ?? queryTimeoutMs;
|
|
91
|
-
try {
|
|
92
|
-
return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
|
|
93
|
-
} catch (err) {
|
|
94
|
-
if (!isRetriableAdminQueryError(err)) throw err;
|
|
95
|
-
await reopen();
|
|
96
|
-
return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
async end() {
|
|
100
|
-
try { await sql.close(); } catch { /* swallow */ }
|
|
101
|
-
},
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function runQueryWithTimeout(sql, text, params, queryTimeoutMs) {
|
|
106
|
-
const query = runQuery(sql, text, params);
|
|
107
|
-
return withTimeout(query, queryTimeoutMs);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
async function runQuery(sql, text, params) {
|
|
111
|
-
const rows = await sql.unsafe(text, params);
|
|
112
|
-
// Bun returns an Array of plain objects with `count` set on it; turn
|
|
113
|
-
// JSONB columns back into JS values so control-db.js's parseTokens
|
|
114
|
-
// sees the array-of-objects shape it would receive from pg.
|
|
115
|
-
const out = Array.from(rows).map(decodeJsonColumns);
|
|
116
|
-
return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function withTimeout(promise, timeoutMs) {
|
|
120
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
|
|
121
|
-
let timer;
|
|
122
|
-
const timeout = new Promise((_, reject) => {
|
|
123
|
-
timer = setTimeout(() => {
|
|
124
|
-
const err = new Error(`admin query timed out after ${timeoutMs}ms`);
|
|
125
|
-
err.code = 'EADMINQUERYTIMEOUT';
|
|
126
|
-
reject(err);
|
|
127
|
-
}, timeoutMs);
|
|
128
|
-
timer.unref?.();
|
|
129
|
-
});
|
|
130
|
-
promise.catch(() => { /* handled by the race winner */ });
|
|
131
|
-
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function isRetriableAdminQueryError(err) {
|
|
135
|
-
const code = err?.code;
|
|
136
|
-
if (['EADMINQUERYTIMEOUT', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ConnectionClosed'].includes(code)) return true;
|
|
137
|
-
const message = err?.message || String(err);
|
|
138
|
-
return /connection (?:closed|terminated|reset)|socket closed|timeout|CONNECTION_ENDED|CONNECTION_DESTROYED/i.test(message);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Strings shaped like a JSON array or object are unwrapped so Bun.SQL's
|
|
143
|
-
* automatic JSONB serialiser sees the JS value (not a quoted JSON string).
|
|
144
|
-
* Anything else is passed through untouched. This mirrors what node-pg
|
|
145
|
-
* does implicitly when the column type is JSONB.
|
|
146
|
-
*/
|
|
147
|
-
function coerceJsonbParam(p) {
|
|
148
|
-
if (typeof p !== 'string') return p;
|
|
149
|
-
const trimmed = p.trim();
|
|
150
|
-
if (trimmed.length === 0) return p;
|
|
151
|
-
const first = trimmed[0];
|
|
152
|
-
if (first !== '[' && first !== '{') return p;
|
|
153
|
-
try {
|
|
154
|
-
return JSON.parse(p);
|
|
155
|
-
} catch {
|
|
156
|
-
return p;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Bun.SQL returns JSONB values as the JSON text rather than parsed JS.
|
|
162
|
-
* Re-parse the obvious cases so callers expecting node-pg's auto-decoded
|
|
163
|
-
* shape get arrays/objects.
|
|
164
|
-
*/
|
|
165
|
-
function decodeJsonColumns(row) {
|
|
166
|
-
const out = {};
|
|
167
|
-
for (const key of Object.keys(row)) {
|
|
168
|
-
const v = row[key];
|
|
169
|
-
if (typeof v === 'string' && (v.startsWith('[') || v.startsWith('{'))) {
|
|
170
|
-
try { out[key] = JSON.parse(v); } catch { out[key] = v; }
|
|
171
|
-
} else {
|
|
172
|
-
out[key] = v;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
return out;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Daemon-side: write a small JSON file that issue-token / revoke-token
|
|
180
|
-
* subcommands read to find the admin socket.
|
|
181
|
-
*
|
|
182
|
-
* @param {object} args
|
|
183
|
-
* @param {string} args.controlSocketDir
|
|
184
|
-
* @param {string|null} args.socketDir — PG socket directory (nullable on Windows)
|
|
185
|
-
* @param {number} args.port
|
|
186
|
-
* @returns {string} the absolute path to the discovery file
|
|
187
|
-
*/
|
|
188
|
-
export function writeAdminDiscovery({ controlSocketDir, socketDir, port }) {
|
|
189
|
-
const file = path.join(controlSocketDir, 'admin.json');
|
|
190
|
-
const payload = {
|
|
191
|
-
socketDir,
|
|
192
|
-
port,
|
|
193
|
-
host: socketDir ? null : '127.0.0.1',
|
|
194
|
-
pid: process.pid,
|
|
195
|
-
written_at: new Date().toISOString(),
|
|
196
|
-
};
|
|
197
|
-
fs.writeFileSync(file, JSON.stringify(payload), { mode: 0o600 });
|
|
198
|
-
return file;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* CLI-side: read the daemon's discovery file.
|
|
203
|
-
*
|
|
204
|
-
* @param {string} controlSocketDir
|
|
205
|
-
* @returns {{socketDir: string|null, port: number, host: string|null}}
|
|
206
|
-
*/
|
|
207
|
-
export function readAdminDiscovery(controlSocketDir) {
|
|
208
|
-
const file = path.join(controlSocketDir, 'admin.json');
|
|
209
|
-
const raw = fs.readFileSync(file, 'utf8');
|
|
210
|
-
return JSON.parse(raw);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* CLI-side: best-effort cleanup at daemon shutdown.
|
|
215
|
-
*
|
|
216
|
-
* @param {string} controlSocketDir
|
|
217
|
-
*/
|
|
218
|
-
export function removeAdminDiscovery(controlSocketDir) {
|
|
219
|
-
const file = path.join(controlSocketDir, 'admin.json');
|
|
220
|
-
try { fs.unlinkSync(file); } catch (e) {
|
|
221
|
-
if (e.code !== 'ENOENT') throw e;
|
|
222
|
-
}
|
|
223
|
-
}
|
package/src/audit.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve audit log — JSONL writer with in-process rotation + syslog tier.
|
|
3
|
-
*
|
|
4
|
-
* Tier 1 (default): `~/.pgserve/audit.log`, rotated 50 MB × 5 files.
|
|
5
|
-
* Tier 2 (opt-in): local syslog via `logger -t pgserve-audit`, one spawn per event.
|
|
6
|
-
* Tier 3 (HTTP webhook): deferred to v2.1.
|
|
7
|
-
*
|
|
8
|
-
* Configuration source-of-truth is the active package.json's
|
|
9
|
-
* `pgserve.audit.target` field; the daemon (Group 3) resolves it per peer
|
|
10
|
-
* and threads the value through `audit(event, fields, { target })`.
|
|
11
|
-
*
|
|
12
|
-
* The event names defined for v2.0 (one row per audit() call):
|
|
13
|
-
* db_created
|
|
14
|
-
* db_reaped_ttl
|
|
15
|
-
* db_reaped_liveness
|
|
16
|
-
* db_persist_honored
|
|
17
|
-
* connection_routed
|
|
18
|
-
* connection_denied_fingerprint_mismatch
|
|
19
|
-
* enforcement_kill_switch_used
|
|
20
|
-
* tcp_token_issued
|
|
21
|
-
* tcp_token_used
|
|
22
|
-
* tcp_token_denied
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import fs from 'fs';
|
|
26
|
-
import os from 'os';
|
|
27
|
-
import path from 'path';
|
|
28
|
-
import { spawn } from 'child_process';
|
|
29
|
-
|
|
30
|
-
export const AUDIT_EVENTS = Object.freeze({
|
|
31
|
-
DB_CREATED: 'db_created',
|
|
32
|
-
DB_REAPED_TTL: 'db_reaped_ttl',
|
|
33
|
-
DB_REAPED_LIVENESS: 'db_reaped_liveness',
|
|
34
|
-
DB_PERSIST_HONORED: 'db_persist_honored',
|
|
35
|
-
CONNECTION_ROUTED: 'connection_routed',
|
|
36
|
-
CONNECTION_DENIED_FINGERPRINT_MISMATCH: 'connection_denied_fingerprint_mismatch',
|
|
37
|
-
ENFORCEMENT_KILL_SWITCH_USED: 'enforcement_kill_switch_used',
|
|
38
|
-
TCP_TOKEN_ISSUED: 'tcp_token_issued',
|
|
39
|
-
TCP_TOKEN_USED: 'tcp_token_used',
|
|
40
|
-
TCP_TOKEN_DENIED: 'tcp_token_denied',
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const VALID_EVENTS = new Set(Object.values(AUDIT_EVENTS));
|
|
44
|
-
|
|
45
|
-
const ROTATE_THRESHOLD_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
46
|
-
const ROTATE_KEEP = 5;
|
|
47
|
-
|
|
48
|
-
let DEFAULT_LOG_DIR = path.join(os.homedir(), '.pgserve');
|
|
49
|
-
let DEFAULT_LOG_PATH = path.join(DEFAULT_LOG_DIR, 'audit.log');
|
|
50
|
-
let DEFAULT_TARGET = process.env.PGSERVE_AUDIT_TARGET || 'file';
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Override the default log path. Used by tests and by the daemon if it
|
|
54
|
-
* needs to redirect audit output (e.g. when XDG_DATA_HOME is set).
|
|
55
|
-
*
|
|
56
|
-
* @param {{logFile?: string, target?: 'file'|'syslog'}} cfg
|
|
57
|
-
*/
|
|
58
|
-
export function configureAudit(cfg = {}) {
|
|
59
|
-
if (cfg.logFile) {
|
|
60
|
-
DEFAULT_LOG_PATH = cfg.logFile;
|
|
61
|
-
DEFAULT_LOG_DIR = path.dirname(cfg.logFile);
|
|
62
|
-
}
|
|
63
|
-
if (cfg.target) {
|
|
64
|
-
DEFAULT_TARGET = cfg.target;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Read pgserve.audit.target from a package.json (returns 'file' if absent).
|
|
70
|
-
* Group 3 calls this per-peer once it has resolved the peer's package.json.
|
|
71
|
-
*
|
|
72
|
-
* @param {string} packageJsonPath
|
|
73
|
-
* @returns {'file'|'syslog'}
|
|
74
|
-
*/
|
|
75
|
-
export function readAuditTarget(packageJsonPath) {
|
|
76
|
-
try {
|
|
77
|
-
const raw = fs.readFileSync(packageJsonPath, 'utf8');
|
|
78
|
-
const pkg = JSON.parse(raw);
|
|
79
|
-
const target = pkg?.pgserve?.audit?.target;
|
|
80
|
-
if (target === 'syslog') return 'syslog';
|
|
81
|
-
return 'file';
|
|
82
|
-
} catch {
|
|
83
|
-
return 'file';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Write one audit event.
|
|
89
|
-
*
|
|
90
|
-
* @param {string} event — one of AUDIT_EVENTS values
|
|
91
|
-
* @param {Record<string, unknown>} [fields] — event-specific payload
|
|
92
|
-
* @param {object} [opts]
|
|
93
|
-
* @param {'file'|'syslog'} [opts.target]
|
|
94
|
-
* @param {string} [opts.logFile]
|
|
95
|
-
*/
|
|
96
|
-
export function audit(event, fields = {}, opts = {}) {
|
|
97
|
-
if (!VALID_EVENTS.has(event)) {
|
|
98
|
-
throw new Error(`audit: unknown event "${event}". Allowed: ${[...VALID_EVENTS].join(', ')}`);
|
|
99
|
-
}
|
|
100
|
-
const record = {
|
|
101
|
-
ts: new Date().toISOString(),
|
|
102
|
-
event,
|
|
103
|
-
...fields,
|
|
104
|
-
};
|
|
105
|
-
const line = JSON.stringify(record);
|
|
106
|
-
const target = opts.target || DEFAULT_TARGET;
|
|
107
|
-
|
|
108
|
-
if (target === 'syslog') {
|
|
109
|
-
writeSyslog(line);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
writeFile(line, opts.logFile || DEFAULT_LOG_PATH);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function writeFile(line, logFile) {
|
|
116
|
-
const dir = path.dirname(logFile);
|
|
117
|
-
if (!fs.existsSync(dir)) {
|
|
118
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
119
|
-
}
|
|
120
|
-
rotateIfNeeded(logFile, Buffer.byteLength(line, 'utf8') + 1 /* newline */);
|
|
121
|
-
fs.appendFileSync(logFile, line + '\n', { mode: 0o600 });
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function rotateIfNeeded(logFile, incomingBytes) {
|
|
125
|
-
let size = 0;
|
|
126
|
-
try {
|
|
127
|
-
size = fs.statSync(logFile).size;
|
|
128
|
-
} catch {
|
|
129
|
-
return; // file does not exist yet
|
|
130
|
-
}
|
|
131
|
-
if (size + incomingBytes <= ROTATE_THRESHOLD_BYTES) return;
|
|
132
|
-
|
|
133
|
-
// Cascade .N → .(N+1), drop the eldest.
|
|
134
|
-
const oldest = `${logFile}.${ROTATE_KEEP}`;
|
|
135
|
-
if (fs.existsSync(oldest)) {
|
|
136
|
-
fs.unlinkSync(oldest);
|
|
137
|
-
}
|
|
138
|
-
for (let i = ROTATE_KEEP - 1; i >= 1; i--) {
|
|
139
|
-
const src = `${logFile}.${i}`;
|
|
140
|
-
const dst = `${logFile}.${i + 1}`;
|
|
141
|
-
if (fs.existsSync(src)) fs.renameSync(src, dst);
|
|
142
|
-
}
|
|
143
|
-
fs.renameSync(logFile, `${logFile}.1`);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function writeSyslog(line) {
|
|
147
|
-
// logger -t <tag> is POSIX-standard; spawn detached, do not block.
|
|
148
|
-
// Stderr/stdout discarded — audit must never throw at call sites.
|
|
149
|
-
try {
|
|
150
|
-
const child = spawn('logger', ['-t', 'pgserve-audit', line], {
|
|
151
|
-
stdio: 'ignore',
|
|
152
|
-
detached: false,
|
|
153
|
-
});
|
|
154
|
-
child.on('error', () => { /* logger missing — swallow */ });
|
|
155
|
-
} catch {
|
|
156
|
-
// ENOENT / EACCES — swallow; audit must never break the daemon.
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Internal: expose rotation constants so tests can drive coverage cleanly
|
|
162
|
-
* without depending on actual 50 MB writes.
|
|
163
|
-
*/
|
|
164
|
-
export const _internals = Object.freeze({
|
|
165
|
-
ROTATE_THRESHOLD_BYTES,
|
|
166
|
-
ROTATE_KEEP,
|
|
167
|
-
rotateIfNeeded,
|
|
168
|
-
});
|