pgserve 2.3.0 → 2.5.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 +9 -4
- package/bin/postgres-server.js +170 -631
- package/config/logrotate.d/pgserve +47 -0
- package/config/pgaudit.conf +31 -0
- package/package.json +3 -2
- package/scripts/audit-redaction-lint.js +349 -0
- package/scripts/test-npx.sh +32 -10
- package/src/audit/audit.js +134 -0
- package/src/cli-install.cjs +340 -100
- package/src/commands/uninstall.js +241 -0
- package/src/commands/verify.js +360 -0
- package/src/cosign/cache-token.js +328 -0
- package/src/cosign/schema.js +97 -0
- package/src/cosign/trust-list.js +81 -0
- package/src/cosign/verify-binary.js +277 -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/runtime-json.js +181 -0
- package/src/lib/socket-dir.js +69 -0
- package/src/postgres.js +64 -5
- package/src/upgrade/index.js +5 -0
- package/src/upgrade/steps/cosign-meta-migration.js +123 -0
- 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/sync.js
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SyncManager - Async replication from pgserve to real PostgreSQL
|
|
3
|
-
*
|
|
4
|
-
* Uses PostgreSQL's native logical replication for ZERO hot-path impact.
|
|
5
|
-
* All replication is handled by PostgreSQL's WAL writer process, not Node.js.
|
|
6
|
-
*
|
|
7
|
-
* 100% Bun-native: Uses Bun.sql for all database operations.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { SQL } from 'bun';
|
|
11
|
-
import { createLogger } from './logger.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Match database name against patterns (supports wildcards)
|
|
15
|
-
* @param {string} dbName - Database name to check
|
|
16
|
-
* @param {string[]} patterns - Array of patterns (supports * wildcard)
|
|
17
|
-
* @returns {boolean}
|
|
18
|
-
*/
|
|
19
|
-
function matchesPattern(dbName, patterns) {
|
|
20
|
-
if (!patterns || patterns.length === 0) return true; // No filter = sync all
|
|
21
|
-
|
|
22
|
-
return patterns.some(pattern => {
|
|
23
|
-
if (pattern.includes('*')) {
|
|
24
|
-
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
|
|
25
|
-
return regex.test(dbName);
|
|
26
|
-
}
|
|
27
|
-
return dbName === pattern;
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* SyncManager - Handles async replication to target PostgreSQL
|
|
33
|
-
*/
|
|
34
|
-
export class SyncManager {
|
|
35
|
-
constructor(options = {}) {
|
|
36
|
-
this.targetUrl = options.targetUrl; // Real PostgreSQL connection string
|
|
37
|
-
this.databases = options.databases || []; // Patterns: ["myapp", "tenant_*"]
|
|
38
|
-
this.sourcePort = options.sourcePort; // pgserve PostgreSQL port
|
|
39
|
-
this.sourceSocketPath = options.sourceSocketPath; // pgserve socket path (optional)
|
|
40
|
-
|
|
41
|
-
this.logger = createLogger({ level: options.logLevel || 'info', component: 'sync' });
|
|
42
|
-
|
|
43
|
-
this.sourceSql = null; // Bun.sql connection to pgserve's PostgreSQL
|
|
44
|
-
this.targetSql = null; // Bun.sql connection to real PostgreSQL
|
|
45
|
-
this.syncedDatabases = new Set();
|
|
46
|
-
this.initialized = false;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Initialize the SyncManager after PostgreSQL is ready
|
|
51
|
-
* @param {Object} _pgManager - PostgresManager instance (unused, reserved for future)
|
|
52
|
-
*/
|
|
53
|
-
async initialize(_pgManager) {
|
|
54
|
-
if (!this.targetUrl) {
|
|
55
|
-
throw new Error('SyncManager requires targetUrl');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
this.logger.info({ target: this.targetUrl.replace(/:[^:@]+@/, ':***@') }, 'Initializing sync manager');
|
|
59
|
-
|
|
60
|
-
// Create Bun.sql connection to source (pgserve's embedded PostgreSQL)
|
|
61
|
-
this.sourceSql = new SQL({
|
|
62
|
-
hostname: this.sourceSocketPath
|
|
63
|
-
? this.sourceSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '')
|
|
64
|
-
: '127.0.0.1',
|
|
65
|
-
port: this.sourcePort,
|
|
66
|
-
database: 'postgres',
|
|
67
|
-
username: 'postgres',
|
|
68
|
-
password: 'postgres',
|
|
69
|
-
max: 3, // Low pool size - replication is async, not latency-sensitive
|
|
70
|
-
idleTimeout: 30,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// Create Bun.sql connection to target (real PostgreSQL)
|
|
74
|
-
const targetUrl = new URL(this.targetUrl);
|
|
75
|
-
this.targetSql = new SQL({
|
|
76
|
-
hostname: targetUrl.hostname,
|
|
77
|
-
port: parseInt(targetUrl.port) || 5432,
|
|
78
|
-
database: targetUrl.pathname.slice(1) || 'postgres',
|
|
79
|
-
username: targetUrl.username || 'postgres',
|
|
80
|
-
password: targetUrl.password || 'postgres',
|
|
81
|
-
max: 3,
|
|
82
|
-
idleTimeout: 30,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// Test connections
|
|
86
|
-
try {
|
|
87
|
-
await this.sourceSql`SELECT 1`;
|
|
88
|
-
this.logger.debug('Source connection ready');
|
|
89
|
-
} catch (err) {
|
|
90
|
-
this.logger.error({ err }, 'Failed to connect to source PostgreSQL');
|
|
91
|
-
throw err;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
await this.targetSql`SELECT 1`;
|
|
96
|
-
this.logger.debug('Target connection ready');
|
|
97
|
-
} catch (err) {
|
|
98
|
-
this.logger.error({ err }, 'Failed to connect to target PostgreSQL');
|
|
99
|
-
throw err;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
this.initialized = true;
|
|
103
|
-
this.logger.info('Sync manager initialized');
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Check if a database should be synced based on patterns
|
|
108
|
-
* @param {string} dbName
|
|
109
|
-
* @returns {boolean}
|
|
110
|
-
*/
|
|
111
|
-
shouldSync(dbName) {
|
|
112
|
-
// Skip system databases
|
|
113
|
-
if (['postgres', 'template0', 'template1'].includes(dbName)) {
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
return matchesPattern(dbName, this.databases);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Setup replication for a specific database
|
|
121
|
-
* Called when a new database is created in pgserve
|
|
122
|
-
*
|
|
123
|
-
* This is NON-BLOCKING - runs in background, doesn't affect hot path
|
|
124
|
-
*
|
|
125
|
-
* @param {string} dbName - Name of the database to sync
|
|
126
|
-
*/
|
|
127
|
-
async setupDatabaseSync(dbName) {
|
|
128
|
-
if (!this.initialized) {
|
|
129
|
-
this.logger.warn({ dbName }, 'Sync manager not initialized, skipping sync setup');
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!this.shouldSync(dbName)) {
|
|
134
|
-
this.logger.debug({ dbName }, 'Database does not match sync patterns, skipping');
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (this.syncedDatabases.has(dbName)) {
|
|
139
|
-
this.logger.debug({ dbName }, 'Database already synced');
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
this.logger.info({ dbName }, 'Setting up database sync');
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
// Step 1: Create database on target if it doesn't exist
|
|
147
|
-
await this.ensureTargetDatabase(dbName);
|
|
148
|
-
|
|
149
|
-
// Step 2: Setup publication on source (pgserve)
|
|
150
|
-
await this.setupPublication(dbName);
|
|
151
|
-
|
|
152
|
-
// Step 3: Setup subscription on target
|
|
153
|
-
await this.setupSubscription(dbName);
|
|
154
|
-
|
|
155
|
-
this.syncedDatabases.add(dbName);
|
|
156
|
-
this.logger.info({ dbName }, 'Database sync established');
|
|
157
|
-
|
|
158
|
-
} catch (err) {
|
|
159
|
-
// Non-fatal - sync failure doesn't affect main server operation
|
|
160
|
-
this.logger.error({ dbName, err }, 'Failed to setup database sync');
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Ensure the database exists on target PostgreSQL
|
|
166
|
-
* @param {string} dbName
|
|
167
|
-
*/
|
|
168
|
-
async ensureTargetDatabase(dbName) {
|
|
169
|
-
const result = await this.targetSql`
|
|
170
|
-
SELECT 1 FROM pg_database WHERE datname = ${dbName}
|
|
171
|
-
`;
|
|
172
|
-
|
|
173
|
-
if (result.length === 0) {
|
|
174
|
-
// CREATE DATABASE cannot run in transaction - use unsafe for DDL
|
|
175
|
-
const safeName = dbName.replace(/"/g, '""');
|
|
176
|
-
await this.targetSql.unsafe(`CREATE DATABASE "${safeName}"`);
|
|
177
|
-
this.logger.debug({ dbName }, 'Created database on target');
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Setup publication on source (pgserve's PostgreSQL)
|
|
183
|
-
* @param {string} dbName
|
|
184
|
-
*/
|
|
185
|
-
async setupPublication(dbName) {
|
|
186
|
-
// Connect to the specific database on source
|
|
187
|
-
const sourceDbSql = new SQL({
|
|
188
|
-
hostname: this.sourceSocketPath
|
|
189
|
-
? this.sourceSocketPath.replace(/\/\.s\.PGSQL\.\d+$/, '')
|
|
190
|
-
: '127.0.0.1',
|
|
191
|
-
port: this.sourcePort,
|
|
192
|
-
database: dbName,
|
|
193
|
-
username: 'postgres',
|
|
194
|
-
password: 'postgres',
|
|
195
|
-
max: 1,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
const pubName = `pgserve_pub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
200
|
-
|
|
201
|
-
// Check if publication exists
|
|
202
|
-
const result = await sourceDbSql`
|
|
203
|
-
SELECT 1 FROM pg_publication WHERE pubname = ${pubName}
|
|
204
|
-
`;
|
|
205
|
-
|
|
206
|
-
if (result.length === 0) {
|
|
207
|
-
// Create publication for all tables
|
|
208
|
-
await sourceDbSql.unsafe(`CREATE PUBLICATION "${pubName}" FOR ALL TABLES`);
|
|
209
|
-
this.logger.debug({ dbName, pubName }, 'Created publication on source');
|
|
210
|
-
}
|
|
211
|
-
} finally {
|
|
212
|
-
await sourceDbSql.close();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Setup subscription on target PostgreSQL
|
|
218
|
-
* @param {string} dbName
|
|
219
|
-
*/
|
|
220
|
-
async setupSubscription(dbName) {
|
|
221
|
-
// Connect to the specific database on target
|
|
222
|
-
const targetUrl = new URL(this.targetUrl);
|
|
223
|
-
|
|
224
|
-
const targetDbSql = new SQL({
|
|
225
|
-
hostname: targetUrl.hostname,
|
|
226
|
-
port: parseInt(targetUrl.port) || 5432,
|
|
227
|
-
database: dbName,
|
|
228
|
-
username: targetUrl.username || 'postgres',
|
|
229
|
-
password: targetUrl.password || 'postgres',
|
|
230
|
-
max: 1,
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
const subName = `pgserve_sub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
235
|
-
const pubName = `pgserve_pub_${dbName.replace(/[^a-z0-9_]/gi, '_')}`;
|
|
236
|
-
|
|
237
|
-
// Check if subscription exists
|
|
238
|
-
const result = await targetDbSql`
|
|
239
|
-
SELECT 1 FROM pg_subscription WHERE subname = ${subName}
|
|
240
|
-
`;
|
|
241
|
-
|
|
242
|
-
if (result.length === 0) {
|
|
243
|
-
// Build connection string to source - ALWAYS use TCP for cross-container compatibility
|
|
244
|
-
// (Unix sockets won't work when target is in Docker or remote)
|
|
245
|
-
const sourceConnStr = `host=127.0.0.1 port=${this.sourcePort} dbname=${dbName} user=postgres password=postgres`;
|
|
246
|
-
|
|
247
|
-
// Create subscription
|
|
248
|
-
await targetDbSql.unsafe(`
|
|
249
|
-
CREATE SUBSCRIPTION "${subName}"
|
|
250
|
-
CONNECTION '${sourceConnStr}'
|
|
251
|
-
PUBLICATION "${pubName}"
|
|
252
|
-
WITH (copy_data = true, create_slot = true)
|
|
253
|
-
`);
|
|
254
|
-
this.logger.debug({ dbName, subName }, 'Created subscription on target');
|
|
255
|
-
}
|
|
256
|
-
} finally {
|
|
257
|
-
await targetDbSql.close();
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Get replication status for all synced databases
|
|
263
|
-
* @returns {Promise<Object>}
|
|
264
|
-
*/
|
|
265
|
-
async getReplicationStatus() {
|
|
266
|
-
if (!this.initialized) {
|
|
267
|
-
return { initialized: false, databases: [] };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const status = {
|
|
271
|
-
initialized: true,
|
|
272
|
-
targetUrl: this.targetUrl.replace(/:[^:@]+@/, ':***@'),
|
|
273
|
-
databases: [],
|
|
274
|
-
replicationSlots: []
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
// Query replication slots from source
|
|
279
|
-
const slotsResult = await this.sourceSql`
|
|
280
|
-
SELECT slot_name, active, restart_lsn, confirmed_flush_lsn
|
|
281
|
-
FROM pg_replication_slots
|
|
282
|
-
WHERE slot_type = 'logical'
|
|
283
|
-
`;
|
|
284
|
-
|
|
285
|
-
status.replicationSlots = slotsResult.map(row => ({
|
|
286
|
-
name: row.slot_name,
|
|
287
|
-
active: row.active,
|
|
288
|
-
restartLsn: row.restart_lsn,
|
|
289
|
-
confirmedFlushLsn: row.confirmed_flush_lsn
|
|
290
|
-
}));
|
|
291
|
-
|
|
292
|
-
// Query replication lag
|
|
293
|
-
const lagResult = await this.sourceSql`
|
|
294
|
-
SELECT
|
|
295
|
-
slot_name,
|
|
296
|
-
pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) as lag_bytes
|
|
297
|
-
FROM pg_replication_slots
|
|
298
|
-
WHERE slot_type = 'logical'
|
|
299
|
-
`;
|
|
300
|
-
|
|
301
|
-
for (const row of lagResult) {
|
|
302
|
-
const slot = status.replicationSlots.find(s => s.name === row.slot_name);
|
|
303
|
-
if (slot) {
|
|
304
|
-
slot.lagBytes = parseInt(row.lag_bytes) || 0;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
status.databases = Array.from(this.syncedDatabases);
|
|
309
|
-
|
|
310
|
-
} catch (err) {
|
|
311
|
-
this.logger.error({ err }, 'Failed to get replication status');
|
|
312
|
-
status.error = err.message;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return status;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Graceful shutdown
|
|
320
|
-
*/
|
|
321
|
-
async stop() {
|
|
322
|
-
this.logger.info('Stopping sync manager');
|
|
323
|
-
|
|
324
|
-
if (this.sourceSql) {
|
|
325
|
-
await this.sourceSql.close();
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (this.targetSql) {
|
|
329
|
-
await this.targetSql.close();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
this.initialized = false;
|
|
333
|
-
this.logger.info('Sync manager stopped');
|
|
334
|
-
}
|
|
335
|
-
}
|
package/src/tenancy.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve tenancy — fingerprint-to-database name resolution + kill-switch.
|
|
3
|
-
*
|
|
4
|
-
* Group 4 wires the kernel-rooted fingerprint (Group 3) to the per-tenant
|
|
5
|
-
* Postgres database. Each `(fingerprint, name)` pair maps deterministically
|
|
6
|
-
* to a database called `app_<sanitized-name>_<12hex>` (≤63 chars, the PG
|
|
7
|
-
* identifier limit).
|
|
8
|
-
*
|
|
9
|
-
* Sanitization rules (per WISH §Group 4):
|
|
10
|
-
* - non-[a-z0-9] runs collapse to a single `_`
|
|
11
|
-
* - lowercased
|
|
12
|
-
* - truncated to 30 chars (so `app_<30>_<12>` ≤ 47 chars, well under 63)
|
|
13
|
-
*
|
|
14
|
-
* The kill switch (`PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`) is read
|
|
15
|
-
* once per process via `isFingerprintEnforcementDisabled()`. The daemon
|
|
16
|
-
* logs a deprecation warning at boot when the env var is observed; the
|
|
17
|
-
* audit event `enforcement_kill_switch_used` fires on every bypassed
|
|
18
|
-
* cross-fingerprint connection.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
export const KILL_SWITCH_ENV = 'PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT';
|
|
22
|
-
|
|
23
|
-
const NAME_TRUNCATE = 30;
|
|
24
|
-
const MAX_DB_IDENT = 63;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Collapse non-alphanumeric runs to a single `_`, lowercase, truncate.
|
|
28
|
-
*
|
|
29
|
-
* Empty or null names fall back to `'anon'` so we always emit a usable
|
|
30
|
-
* database identifier — a peer with no resolvable package name still
|
|
31
|
-
* deserves a tenant DB, just one that visibly says "anonymous".
|
|
32
|
-
*
|
|
33
|
-
* @param {string|null|undefined} name
|
|
34
|
-
* @returns {string}
|
|
35
|
-
*/
|
|
36
|
-
export function sanitizeName(name) {
|
|
37
|
-
const raw = (typeof name === 'string' ? name : '').toLowerCase();
|
|
38
|
-
const collapsed = raw.replace(/[^a-z0-9]+/g, '_');
|
|
39
|
-
if (!collapsed || collapsed === '_') return 'anon';
|
|
40
|
-
return collapsed.slice(0, NAME_TRUNCATE);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Build the canonical per-tenant database name `app_<sanitized>_<fingerprint>`.
|
|
45
|
-
*
|
|
46
|
-
* Throws if fingerprint is not the documented 12 lowercase-hex blob —
|
|
47
|
-
* any caller that managed to slip a malformed fingerprint through deserves
|
|
48
|
-
* a loud failure rather than a silent identifier mismatch later.
|
|
49
|
-
*
|
|
50
|
-
* @param {{name: string|null|undefined, fingerprint: string}} args
|
|
51
|
-
* @returns {string}
|
|
52
|
-
*/
|
|
53
|
-
export function resolveTenantDatabaseName({ name, fingerprint }) {
|
|
54
|
-
if (!/^[0-9a-f]{12}$/.test(fingerprint || '')) {
|
|
55
|
-
throw new Error(`resolveTenantDatabaseName: fingerprint must be 12 hex chars, got "${fingerprint}"`);
|
|
56
|
-
}
|
|
57
|
-
const sanitized = sanitizeName(name);
|
|
58
|
-
const ident = `app_${sanitized}_${fingerprint}`;
|
|
59
|
-
if (ident.length > MAX_DB_IDENT) {
|
|
60
|
-
// Truncation already bounds sanitized to 30; the fingerprint adds 12;
|
|
61
|
-
// the prefix `app_` adds 4 + two underscores = 48. We are safe by
|
|
62
|
-
// construction, but assert anyway: a future change to NAME_TRUNCATE
|
|
63
|
-
// must not silently produce >63-char identifiers.
|
|
64
|
-
throw new Error(`resolveTenantDatabaseName: identifier "${ident}" exceeds ${MAX_DB_IDENT} chars`);
|
|
65
|
-
}
|
|
66
|
-
return ident;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
71
|
-
* @returns {boolean}
|
|
72
|
-
*/
|
|
73
|
-
export function isFingerprintEnforcementDisabled(env = process.env) {
|
|
74
|
-
return env[KILL_SWITCH_ENV] === '1';
|
|
75
|
-
}
|
package/src/tokens.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pgserve TCP bearer-token helpers (Group 6).
|
|
3
|
-
*
|
|
4
|
-
* Tokens are random 256-bit secrets shown to the operator exactly once
|
|
5
|
-
* (the output of `pgserve daemon issue-token`). Only their sha256 hash
|
|
6
|
-
* is persisted in `pgserve_meta.allowed_tokens`. Verification therefore
|
|
7
|
-
* compares hashes, never cleartext.
|
|
8
|
-
*
|
|
9
|
-
* Token id: short hex prefix used for revocation by humans
|
|
10
|
-
* (`pgserve daemon revoke-token <id>`). It is also persisted alongside
|
|
11
|
-
* the hash so `tcp_token_used` audit events can name which credential
|
|
12
|
-
* authorised the connection without leaking the secret.
|
|
13
|
-
*
|
|
14
|
-
* Wire format on the TCP path: peers pass an `application_name` shaped
|
|
15
|
-
* `?fingerprint=<12hex>&token=<bearer>` (a leading `?` is tolerated so
|
|
16
|
-
* libpq URL-style strings round-trip cleanly). Both keys are required;
|
|
17
|
-
* any missing or extra-long value is treated as auth-fail by the
|
|
18
|
-
* daemon's accept hook, never bubbling further.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import crypto from 'crypto';
|
|
22
|
-
|
|
23
|
-
const TOKEN_BYTES = 32; // 256 bits — plenty of entropy
|
|
24
|
-
const TOKEN_ID_BYTES = 6; // 12 hex chars — collision-bound at ~10^14
|
|
25
|
-
const MAX_TOKEN_LEN = 256; // sanity guard for parse path
|
|
26
|
-
const FP_RE = /^[0-9a-f]{12}$/;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Mint a fresh `(id, cleartext, hash)` triple. The cleartext is meant to
|
|
30
|
-
* leave this process exactly once (printed to stdout by `issue-token`);
|
|
31
|
-
* only the hash gets stored.
|
|
32
|
-
*
|
|
33
|
-
* @returns {{id: string, cleartext: string, hash: string}}
|
|
34
|
-
*/
|
|
35
|
-
export function mintToken() {
|
|
36
|
-
const id = crypto.randomBytes(TOKEN_ID_BYTES).toString('hex');
|
|
37
|
-
const cleartext = crypto.randomBytes(TOKEN_BYTES).toString('hex');
|
|
38
|
-
const hash = hashToken(cleartext);
|
|
39
|
-
return { id, cleartext, hash };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Sha256 of the bearer token in lowercase hex. Centralised so daemon
|
|
44
|
-
* accept code, issue-token CLI, and tests cannot drift.
|
|
45
|
-
*
|
|
46
|
-
* @param {string} cleartext
|
|
47
|
-
* @returns {string}
|
|
48
|
-
*/
|
|
49
|
-
export function hashToken(cleartext) {
|
|
50
|
-
if (typeof cleartext !== 'string' || cleartext.length === 0) {
|
|
51
|
-
throw new Error('hashToken: non-empty string required');
|
|
52
|
-
}
|
|
53
|
-
return crypto.createHash('sha256').update(cleartext).digest('hex');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Parse `?fingerprint=<12hex>&token=<bearer>` — or its prefix-less form —
|
|
58
|
-
* out of an `application_name` startup parameter.
|
|
59
|
-
*
|
|
60
|
-
* Returns `null` for any malformed input. Caller never inspects details
|
|
61
|
-
* beyond presence: the daemon emits a single `tcp_token_denied` audit
|
|
62
|
-
* event regardless of which validation step failed, to deny the peer
|
|
63
|
-
* any oracle that distinguishes "unknown fingerprint" from "wrong token".
|
|
64
|
-
*
|
|
65
|
-
* @param {string|undefined|null} applicationName
|
|
66
|
-
* @returns {{fingerprint: string, token: string} | null}
|
|
67
|
-
*/
|
|
68
|
-
export function parseTcpAuth(applicationName) {
|
|
69
|
-
if (typeof applicationName !== 'string' || applicationName.length === 0) return null;
|
|
70
|
-
if (applicationName.length > MAX_TOKEN_LEN + 64) return null;
|
|
71
|
-
const stripped = applicationName.startsWith('?') ? applicationName.slice(1) : applicationName;
|
|
72
|
-
const params = new Map();
|
|
73
|
-
for (const segment of stripped.split('&')) {
|
|
74
|
-
const eq = segment.indexOf('=');
|
|
75
|
-
if (eq <= 0) continue;
|
|
76
|
-
const key = segment.slice(0, eq);
|
|
77
|
-
const val = segment.slice(eq + 1);
|
|
78
|
-
if (key && val) params.set(key, val);
|
|
79
|
-
}
|
|
80
|
-
const fingerprint = params.get('fingerprint');
|
|
81
|
-
const token = params.get('token');
|
|
82
|
-
if (!fingerprint || !token) return null;
|
|
83
|
-
if (!FP_RE.test(fingerprint)) return null;
|
|
84
|
-
if (token.length === 0 || token.length > MAX_TOKEN_LEN) return null;
|
|
85
|
-
return { fingerprint, token };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Constant-time string compare. Bearer-token verification path uses this
|
|
90
|
-
* after sha256 to avoid leaking length-mismatch via timing.
|
|
91
|
-
*
|
|
92
|
-
* @param {string} a
|
|
93
|
-
* @param {string} b
|
|
94
|
-
* @returns {boolean}
|
|
95
|
-
*/
|
|
96
|
-
export function timingSafeEqual(a, b) {
|
|
97
|
-
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
98
|
-
if (a.length !== b.length) return false;
|
|
99
|
-
const bufA = Buffer.from(a);
|
|
100
|
-
const bufB = Buffer.from(b);
|
|
101
|
-
return crypto.timingSafeEqual(bufA, bufB);
|
|
102
|
-
}
|