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/control-db.js
DELETED
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve control DB — `pgserve_meta` schema + accessors.
|
|
3
|
-
*
|
|
4
|
-
* The pgserve daemon owns a control database (the "admin DB"). This module
|
|
5
|
-
* defines the `pgserve_meta` table that records every user database the
|
|
6
|
-
* daemon provisions per peer fingerprint, plus the small set of accessors
|
|
7
|
-
* the daemon (Wave 2+) and GC sweep (Group 5) call against it.
|
|
8
|
-
*
|
|
9
|
-
* Schema (see DESIGN.md §9 + Group 6 token migration):
|
|
10
|
-
* database_name TEXT PRIMARY KEY
|
|
11
|
-
* fingerprint TEXT NOT NULL -- 12 hex chars from sha256
|
|
12
|
-
* peer_uid INTEGER NOT NULL
|
|
13
|
-
* package_realpath TEXT -- NULL for script fallback
|
|
14
|
-
* created_at TIMESTAMPTZ DEFAULT now()
|
|
15
|
-
* last_connection_at TIMESTAMPTZ DEFAULT now()
|
|
16
|
-
* liveness_pid INTEGER
|
|
17
|
-
* persist BOOLEAN DEFAULT false
|
|
18
|
-
* allowed_tokens JSONB DEFAULT '[]' -- Group 6: bearer tokens for TCP path
|
|
19
|
-
*
|
|
20
|
-
* Each `allowed_tokens` entry is `{id, hash, issued_at}` where `hash` is the
|
|
21
|
-
* sha256 of the bearer token (the cleartext is shown to the operator once
|
|
22
|
-
* during `pgserve daemon issue-token` and never persisted).
|
|
23
|
-
*
|
|
24
|
-
* Client contract: any object exposing
|
|
25
|
-
* `query(text: string, params?: unknown[]) => Promise<{ rows: object[] }>`
|
|
26
|
-
* (matches `pg.Client` / `pg.Pool` directly; trivial to wrap Bun.SQL).
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
import { timingSafeEqual } from './tokens.js';
|
|
30
|
-
|
|
31
|
-
const REAPABLE_QUERY = `
|
|
32
|
-
SELECT database_name, fingerprint, last_connection_at, liveness_pid, persist
|
|
33
|
-
FROM pgserve_meta
|
|
34
|
-
WHERE persist = false
|
|
35
|
-
ORDER BY last_connection_at ASC
|
|
36
|
-
`;
|
|
37
|
-
|
|
38
|
-
function query(client, text, params = [], opts = {}) {
|
|
39
|
-
if (client.supportsQueryOptions && opts && Object.keys(opts).length > 0) {
|
|
40
|
-
return client.query(text, params, opts);
|
|
41
|
-
}
|
|
42
|
-
return client.query(text, params);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Create the `pgserve_meta` table if it does not already exist.
|
|
47
|
-
* Safe to call repeatedly — used at daemon boot and in tests.
|
|
48
|
-
*
|
|
49
|
-
* @param {{query: Function}} client
|
|
50
|
-
* @returns {Promise<void>}
|
|
51
|
-
*/
|
|
52
|
-
export async function ensureMetaSchema(client) {
|
|
53
|
-
await client.query(`
|
|
54
|
-
CREATE TABLE IF NOT EXISTS pgserve_meta (
|
|
55
|
-
database_name TEXT PRIMARY KEY,
|
|
56
|
-
fingerprint TEXT NOT NULL,
|
|
57
|
-
peer_uid INTEGER NOT NULL,
|
|
58
|
-
package_realpath TEXT,
|
|
59
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
60
|
-
last_connection_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
61
|
-
liveness_pid INTEGER,
|
|
62
|
-
persist BOOLEAN NOT NULL DEFAULT false,
|
|
63
|
-
allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
64
|
-
)
|
|
65
|
-
`);
|
|
66
|
-
// Group 6 migration: existing v2-pre-tcp installs predate allowed_tokens.
|
|
67
|
-
// ADD COLUMN IF NOT EXISTS lets the first daemon boot after upgrade fold
|
|
68
|
-
// the new column into a populated table without operator intervention.
|
|
69
|
-
await client.query(`
|
|
70
|
-
ALTER TABLE pgserve_meta
|
|
71
|
-
ADD COLUMN IF NOT EXISTS allowed_tokens JSONB NOT NULL DEFAULT '[]'::jsonb
|
|
72
|
-
`);
|
|
73
|
-
await client.query(`
|
|
74
|
-
CREATE INDEX IF NOT EXISTS pgserve_meta_fingerprint_idx
|
|
75
|
-
ON pgserve_meta (fingerprint)
|
|
76
|
-
`);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Insert (or upsert) a row marking a freshly-created user DB.
|
|
81
|
-
*
|
|
82
|
-
* @param {{query: Function}} client
|
|
83
|
-
* @param {object} row
|
|
84
|
-
* @param {string} row.databaseName
|
|
85
|
-
* @param {string} row.fingerprint
|
|
86
|
-
* @param {number} row.peerUid
|
|
87
|
-
* @param {string|null} [row.packageRealpath]
|
|
88
|
-
* @param {number|null} [row.livenessPid]
|
|
89
|
-
* @param {boolean} [row.persist]
|
|
90
|
-
*/
|
|
91
|
-
export async function recordDbCreated(client, {
|
|
92
|
-
databaseName,
|
|
93
|
-
fingerprint,
|
|
94
|
-
peerUid,
|
|
95
|
-
packageRealpath = null,
|
|
96
|
-
livenessPid = null,
|
|
97
|
-
persist = false,
|
|
98
|
-
}, opts = {}) {
|
|
99
|
-
if (!databaseName) throw new Error('recordDbCreated: databaseName required');
|
|
100
|
-
if (!fingerprint) throw new Error('recordDbCreated: fingerprint required');
|
|
101
|
-
if (typeof peerUid !== 'number') throw new Error('recordDbCreated: peerUid must be number');
|
|
102
|
-
|
|
103
|
-
await query(
|
|
104
|
-
client,
|
|
105
|
-
`
|
|
106
|
-
INSERT INTO pgserve_meta
|
|
107
|
-
(database_name, fingerprint, peer_uid, package_realpath, liveness_pid, persist)
|
|
108
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
109
|
-
ON CONFLICT (database_name) DO UPDATE SET
|
|
110
|
-
fingerprint = EXCLUDED.fingerprint,
|
|
111
|
-
peer_uid = EXCLUDED.peer_uid,
|
|
112
|
-
package_realpath = EXCLUDED.package_realpath,
|
|
113
|
-
liveness_pid = EXCLUDED.liveness_pid,
|
|
114
|
-
persist = EXCLUDED.persist,
|
|
115
|
-
last_connection_at = now()
|
|
116
|
-
`,
|
|
117
|
-
[databaseName, fingerprint, peerUid, packageRealpath, livenessPid, persist],
|
|
118
|
-
opts,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Slide the connection window: bump last_connection_at and refresh
|
|
124
|
-
* liveness_pid on every accept for an existing fingerprint.
|
|
125
|
-
*
|
|
126
|
-
* @param {{query: Function}} client
|
|
127
|
-
* @param {{databaseName: string, livenessPid?: number|null}} args
|
|
128
|
-
*/
|
|
129
|
-
export async function touchLastConnection(client, { databaseName, livenessPid = null }, opts = {}) {
|
|
130
|
-
if (!databaseName) throw new Error('touchLastConnection: databaseName required');
|
|
131
|
-
await query(
|
|
132
|
-
client,
|
|
133
|
-
`
|
|
134
|
-
UPDATE pgserve_meta
|
|
135
|
-
SET last_connection_at = now(),
|
|
136
|
-
liveness_pid = $2
|
|
137
|
-
WHERE database_name = $1
|
|
138
|
-
`,
|
|
139
|
-
[databaseName, livenessPid],
|
|
140
|
-
opts,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Set the persist flag for a database (true = exempt from GC).
|
|
146
|
-
*
|
|
147
|
-
* @param {{query: Function}} client
|
|
148
|
-
* @param {string} databaseName
|
|
149
|
-
* @param {boolean} value
|
|
150
|
-
*/
|
|
151
|
-
export async function markPersist(client, databaseName, value, opts = {}) {
|
|
152
|
-
if (!databaseName) throw new Error('markPersist: databaseName required');
|
|
153
|
-
await query(
|
|
154
|
-
client,
|
|
155
|
-
`UPDATE pgserve_meta SET persist = $2 WHERE database_name = $1`,
|
|
156
|
-
[databaseName, !!value],
|
|
157
|
-
opts,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Async iterator over candidate DBs for the GC sweep.
|
|
163
|
-
* Skips persist=true rows entirely (they are never reaped).
|
|
164
|
-
*
|
|
165
|
-
* Group 5 consumes this and applies its liveness/TTL policy.
|
|
166
|
-
*
|
|
167
|
-
* @param {{query: Function}} client
|
|
168
|
-
* @param {{now?: Date}} [opts] — `now` accepted for caller symmetry; the
|
|
169
|
-
* policy decision (TTL elapsed?) lives in Group 5, not here.
|
|
170
|
-
* @returns {AsyncIterable<{
|
|
171
|
-
* databaseName: string,
|
|
172
|
-
* fingerprint: string,
|
|
173
|
-
* lastConnectionAt: Date,
|
|
174
|
-
* livenessPid: number|null,
|
|
175
|
-
* persist: boolean,
|
|
176
|
-
* }>}
|
|
177
|
-
*/
|
|
178
|
-
export async function* forEachReapable(client, _opts = {}) {
|
|
179
|
-
const result = await client.query(REAPABLE_QUERY);
|
|
180
|
-
for (const row of result.rows) {
|
|
181
|
-
yield {
|
|
182
|
-
databaseName: row.database_name,
|
|
183
|
-
fingerprint: row.fingerprint,
|
|
184
|
-
lastConnectionAt: row.last_connection_at,
|
|
185
|
-
livenessPid: row.liveness_pid,
|
|
186
|
-
persist: row.persist,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Delete a row after the user DB has been DROPped. Group 5 helper.
|
|
193
|
-
*
|
|
194
|
-
* @param {{query: Function}} client
|
|
195
|
-
* @param {string} databaseName
|
|
196
|
-
*/
|
|
197
|
-
export async function deleteMetaRow(client, databaseName) {
|
|
198
|
-
await client.query(`DELETE FROM pgserve_meta WHERE database_name = $1`, [databaseName]);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
// Group 6: TCP bearer-token CRUD
|
|
203
|
-
//
|
|
204
|
-
// `allowed_tokens` is a JSONB array on pgserve_meta. Each entry is shaped
|
|
205
|
-
// `{id, hash, issued_at}` where `hash` is sha256 of the cleartext bearer
|
|
206
|
-
// token. Tokens are scoped to the `database_name` row's `fingerprint`; a
|
|
207
|
-
// fingerprint without a row cannot have tokens issued (the peer must have
|
|
208
|
-
// connected over the Unix socket at least once so its DB exists).
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Look up the metadata row for a fingerprint. Returns null if the fingerprint
|
|
213
|
-
* has not yet been provisioned (the peer never connected via Unix socket).
|
|
214
|
-
*
|
|
215
|
-
* @param {{query: Function}} client
|
|
216
|
-
* @param {string} fingerprint — 12 hex chars
|
|
217
|
-
* @returns {Promise<{databaseName: string, fingerprint: string, peerUid: number, allowedTokens: Array<{id: string, hash: string, issued_at: string}>} | null>}
|
|
218
|
-
*/
|
|
219
|
-
export async function findRowByFingerprint(client, fingerprint, opts = {}) {
|
|
220
|
-
if (!fingerprint) throw new Error('findRowByFingerprint: fingerprint required');
|
|
221
|
-
const r = await query(
|
|
222
|
-
client,
|
|
223
|
-
`SELECT database_name, fingerprint, peer_uid, allowed_tokens
|
|
224
|
-
FROM pgserve_meta WHERE fingerprint = $1 LIMIT 1`,
|
|
225
|
-
[fingerprint],
|
|
226
|
-
opts,
|
|
227
|
-
);
|
|
228
|
-
if (r.rows.length === 0) return null;
|
|
229
|
-
const row = r.rows[0];
|
|
230
|
-
return {
|
|
231
|
-
databaseName: row.database_name,
|
|
232
|
-
fingerprint: row.fingerprint,
|
|
233
|
-
peerUid: row.peer_uid,
|
|
234
|
-
allowedTokens: parseTokens(row.allowed_tokens),
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function parseTokens(raw) {
|
|
239
|
-
if (!raw) return [];
|
|
240
|
-
if (Array.isArray(raw)) return raw;
|
|
241
|
-
try {
|
|
242
|
-
const parsed = JSON.parse(raw);
|
|
243
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
244
|
-
} catch {
|
|
245
|
-
return [];
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Append a hashed bearer token to a fingerprint's allowed list.
|
|
251
|
-
*
|
|
252
|
-
* @param {{query: Function}} client
|
|
253
|
-
* @param {{fingerprint: string, tokenId: string, tokenHash: string}} args
|
|
254
|
-
* @returns {Promise<{databaseName: string}>}
|
|
255
|
-
* @throws if the fingerprint has no pgserve_meta row
|
|
256
|
-
*/
|
|
257
|
-
export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash }, opts = {}) {
|
|
258
|
-
if (!fingerprint) throw new Error('addAllowedToken: fingerprint required');
|
|
259
|
-
if (!tokenId) throw new Error('addAllowedToken: tokenId required');
|
|
260
|
-
if (!tokenHash) throw new Error('addAllowedToken: tokenHash required');
|
|
261
|
-
|
|
262
|
-
const row = await findRowByFingerprint(client, fingerprint, opts);
|
|
263
|
-
if (!row) {
|
|
264
|
-
const err = new Error(
|
|
265
|
-
`addAllowedToken: no pgserve_meta row for fingerprint ${fingerprint}; ` +
|
|
266
|
-
`peer must connect once via Unix socket before tokens can be issued`,
|
|
267
|
-
);
|
|
268
|
-
err.code = 'EUNKNOWNFINGERPRINT';
|
|
269
|
-
throw err;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const entry = {
|
|
273
|
-
id: tokenId,
|
|
274
|
-
hash: tokenHash,
|
|
275
|
-
issued_at: new Date().toISOString(),
|
|
276
|
-
};
|
|
277
|
-
await query(
|
|
278
|
-
client,
|
|
279
|
-
`UPDATE pgserve_meta
|
|
280
|
-
SET allowed_tokens = allowed_tokens || $2::jsonb
|
|
281
|
-
WHERE database_name = $1`,
|
|
282
|
-
[row.databaseName, JSON.stringify([entry])],
|
|
283
|
-
opts,
|
|
284
|
-
);
|
|
285
|
-
return { databaseName: row.databaseName };
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Remove a token by its id from any fingerprint's allowed list. Returns the
|
|
290
|
-
* number of rows affected.
|
|
291
|
-
*
|
|
292
|
-
* @param {{query: Function}} client
|
|
293
|
-
* @param {string} tokenId
|
|
294
|
-
* @returns {Promise<number>}
|
|
295
|
-
*/
|
|
296
|
-
export async function revokeAllowedToken(client, tokenId) {
|
|
297
|
-
if (!tokenId) throw new Error('revokeAllowedToken: tokenId required');
|
|
298
|
-
// jsonb_path_query_array would be cleaner but isn't on every PG; the array
|
|
299
|
-
// filter via SELECT/UPDATE works on any version >= 12.
|
|
300
|
-
const r = await client.query(
|
|
301
|
-
`UPDATE pgserve_meta
|
|
302
|
-
SET allowed_tokens = COALESCE((
|
|
303
|
-
SELECT jsonb_agg(elem)
|
|
304
|
-
FROM jsonb_array_elements(allowed_tokens) elem
|
|
305
|
-
WHERE elem->>'id' <> $1
|
|
306
|
-
), '[]'::jsonb)
|
|
307
|
-
WHERE allowed_tokens @> jsonb_build_array(jsonb_build_object('id', $1::text))`,
|
|
308
|
-
[tokenId],
|
|
309
|
-
);
|
|
310
|
-
return r.rowCount ?? r.rows?.length ?? 0;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Verify a presented bearer-token hash against a fingerprint's allowed list.
|
|
315
|
-
* Returns the matched token id (so audit events can attribute the connection)
|
|
316
|
-
* plus the resolved database name on success, or null if the token is unknown.
|
|
317
|
-
*
|
|
318
|
-
* @param {{query: Function}} client
|
|
319
|
-
* @param {{fingerprint: string, tokenHash: string}} args
|
|
320
|
-
* @returns {Promise<{tokenId: string, databaseName: string} | null>}
|
|
321
|
-
*/
|
|
322
|
-
export async function verifyToken(client, { fingerprint, tokenHash }, opts = {}) {
|
|
323
|
-
if (!fingerprint) throw new Error('verifyToken: fingerprint required');
|
|
324
|
-
if (!tokenHash) throw new Error('verifyToken: tokenHash required');
|
|
325
|
-
const row = await findRowByFingerprint(client, fingerprint, opts);
|
|
326
|
-
if (!row) return null;
|
|
327
|
-
const match = row.allowedTokens.find((t) => timingSafeEqual(t.hash, tokenHash));
|
|
328
|
-
if (!match) return null;
|
|
329
|
-
return { tokenId: match.id, databaseName: row.databaseName };
|
|
330
|
-
}
|