@sysnee/pgs 0.1.6 → 0.1.7-rc.10
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/README.md +155 -188
- package/docker-compose.yml +9 -6
- package/docs/ARCHITECTURE_RECOMMENDATIONS.md +11 -9
- package/docs/CRITICAL_REVIEW.md +10 -8
- package/docs/EXECUTIVE_SUMMARY.md +9 -9
- package/docs/PROJECT.md +130 -77
- package/manager.js +192 -97
- package/package.json +2 -2
- package/traefik.yml +15 -0
- package/haproxy-lua/pg-route.lua +0 -177
- package/haproxy.cfg +0 -35
package/manager.js
CHANGED
|
@@ -19,7 +19,9 @@ const CONFIG_DIR = path.join(os.homedir(), '.sysnee-config');
|
|
|
19
19
|
|
|
20
20
|
const COMPOSE_FILE_PATH = path.join(CONFIG_DIR, 'docker-compose.yml');
|
|
21
21
|
const INIT_DIR_PATH = path.join(CONFIG_DIR, 'init');
|
|
22
|
-
const
|
|
22
|
+
const CERTS_DIR_PATH = path.join(CONFIG_DIR, 'certs');
|
|
23
|
+
const TRAEFIK_STATIC_PATH = path.join(CONFIG_DIR, 'traefik.yml');
|
|
24
|
+
const TRAEFIK_DYNAMIC_PATH = path.join(CONFIG_DIR, 'dynamic.yml');
|
|
23
25
|
const TENANT_ACCESS_FILE_PATH = path.join(CONFIG_DIR, 'tenant-access.json');
|
|
24
26
|
const SETUP_STATUS_PATH = path.join(CONFIG_DIR, 'status.txt');
|
|
25
27
|
|
|
@@ -58,86 +60,116 @@ function removeTenantAccess(tenantId) {
|
|
|
58
60
|
saveTenantAccess(access);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
// ====================
|
|
63
|
+
// ==================== Traefik Configuration ====================
|
|
62
64
|
|
|
63
|
-
function
|
|
64
|
-
console.debug('
|
|
65
|
+
function generateTraefikDynamicConfig(tenantsManifests = {}) {
|
|
66
|
+
console.debug('Generating Traefik dynamic config')
|
|
65
67
|
const doc = loadCompose();
|
|
68
|
+
const access = loadTenantAccess();
|
|
66
69
|
|
|
67
|
-
// Get all tenant services
|
|
68
70
|
const tenants = Object.keys(doc.services || {})
|
|
69
|
-
.filter(name => name.startsWith('pgs_') && name !== '
|
|
70
|
-
|
|
71
|
-
console.debug(`tenants.lenght: ${tenants.length}`)
|
|
71
|
+
.filter(name => name.startsWith('pgs_') && name !== 'traefik');
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
const templateFilePath = path.join(__dirname, 'haproxy.cfg')
|
|
75
|
-
console.debug(`haproxy template file path: ${templateFilePath}`)
|
|
76
|
-
let config = readFileSync(templateFilePath, 'utf8');
|
|
77
|
-
console.debug(`haproxy template file loaded`)
|
|
73
|
+
console.debug(`tenants.length: ${tenants.length}`)
|
|
78
74
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
75
|
+
const dynamicConfig = {
|
|
76
|
+
tcp: {
|
|
77
|
+
routers: {},
|
|
78
|
+
middlewares: {},
|
|
79
|
+
services: {}
|
|
80
|
+
},
|
|
81
|
+
tls: {
|
|
82
|
+
certificates: [
|
|
83
|
+
{
|
|
84
|
+
certFile: '/etc/traefik/certs/fullchain.pem',
|
|
85
|
+
keyFile: '/etc/traefik/certs/privkey.pem'
|
|
86
|
+
}
|
|
87
|
+
]
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
`;
|
|
88
|
-
} else {
|
|
89
|
-
config += ` # No tenants configured yet
|
|
90
|
-
`;
|
|
91
|
-
}
|
|
89
|
+
};
|
|
92
90
|
|
|
93
|
-
// Generate backend for each tenant
|
|
94
91
|
for (const serviceName of tenants) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
const tenantId = serviceName.replace('pgs_', '');
|
|
93
|
+
|
|
94
|
+
if (access[tenantId] !== true) {
|
|
95
|
+
console.debug(`Skipping disabled tenant: ${tenantId}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const routerName = `router_${tenantId}`.replace(/-/g, '_');
|
|
100
|
+
const middlewareName = `ipwhitelist_${tenantId}`.replace(/-/g, '_');
|
|
101
|
+
const svcName = `svc_${tenantId}`.replace(/-/g, '_');
|
|
102
|
+
|
|
103
|
+
const router = {
|
|
104
|
+
entryPoints: ['postgres'],
|
|
105
|
+
rule: `HostSNI(\`${tenantId}.pgs.br-sp-1.sysnee.com\`)`,
|
|
106
|
+
service: svcName,
|
|
107
|
+
tls: {}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const manifest = tenantsManifests[tenantId];
|
|
111
|
+
if (manifest && manifest.firewall && manifest.firewall.rules) {
|
|
112
|
+
const sourceRanges = manifest.firewall.rules
|
|
113
|
+
.filter(rule => rule.type === 'allow')
|
|
114
|
+
.map(rule => rule.source);
|
|
115
|
+
|
|
116
|
+
if (sourceRanges.length > 0) {
|
|
117
|
+
router.middlewares = [middlewareName];
|
|
118
|
+
|
|
119
|
+
if (!dynamicConfig.tcp.middlewares[middlewareName]) {
|
|
120
|
+
dynamicConfig.tcp.middlewares[middlewareName] = {
|
|
121
|
+
ipAllowList: {
|
|
122
|
+
sourceRange: sourceRanges
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
98
128
|
|
|
99
|
-
|
|
129
|
+
dynamicConfig.tcp.routers[routerName] = router;
|
|
130
|
+
|
|
131
|
+
dynamicConfig.tcp.services[svcName] = {
|
|
132
|
+
loadBalancer: {
|
|
133
|
+
servers: [
|
|
134
|
+
{ address: `${serviceName}:5432` }
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
};
|
|
100
138
|
}
|
|
101
139
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
console.log(`
|
|
140
|
+
const yamlContent = yaml.dump(dynamicConfig, { indent: 2, lineWidth: -1, quotingType: '"' });
|
|
141
|
+
|
|
142
|
+
console.log(`Saving Traefik dynamic config to ${TRAEFIK_DYNAMIC_PATH}`)
|
|
143
|
+
writeFileSync(TRAEFIK_DYNAMIC_PATH, yamlContent);
|
|
144
|
+
console.log('Traefik dynamic config saved successfully')
|
|
105
145
|
}
|
|
106
146
|
|
|
107
|
-
function
|
|
147
|
+
function updateTraefikService() {
|
|
108
148
|
const doc = loadCompose();
|
|
109
149
|
|
|
110
|
-
// Get all tenant services
|
|
111
150
|
const tenants = Object.keys(doc.services || {})
|
|
112
|
-
.filter(name => name.startsWith('pgs_') && name !== '
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
restart: 'unless-stopped'
|
|
127
|
-
};
|
|
128
|
-
}
|
|
151
|
+
.filter(name => name.startsWith('pgs_') && name !== 'traefik');
|
|
152
|
+
|
|
153
|
+
doc.services.traefik = {
|
|
154
|
+
image: 'traefik:v3.0',
|
|
155
|
+
container_name: 'postgres_proxy',
|
|
156
|
+
ports: ['5432:5432'],
|
|
157
|
+
volumes: [
|
|
158
|
+
`${TRAEFIK_STATIC_PATH}:/etc/traefik/traefik.yml:ro`,
|
|
159
|
+
`${TRAEFIK_DYNAMIC_PATH}:/etc/traefik/dynamic.yml:ro`,
|
|
160
|
+
`${CERTS_DIR_PATH}:/etc/traefik/certs:ro`
|
|
161
|
+
],
|
|
162
|
+
networks: ['postgres_network'],
|
|
163
|
+
restart: 'unless-stopped'
|
|
164
|
+
};
|
|
129
165
|
|
|
130
|
-
// Update depends_on
|
|
131
166
|
if (tenants.length > 0) {
|
|
132
|
-
doc.services.
|
|
133
|
-
} else {
|
|
134
|
-
delete doc.services.haproxy.depends_on;
|
|
167
|
+
doc.services.traefik.depends_on = tenants;
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
saveCompose(doc);
|
|
138
171
|
|
|
139
|
-
|
|
140
|
-
executeCommand('docker restart postgres_proxy');
|
|
172
|
+
executeCommand('docker compose up -d --force-recreate traefik');
|
|
141
173
|
}
|
|
142
174
|
|
|
143
175
|
function createInitScript({ tenantId, password, databaseName }) {
|
|
@@ -200,11 +232,30 @@ function ensureNetwork(doc) {
|
|
|
200
232
|
}
|
|
201
233
|
|
|
202
234
|
function initialSetup() {
|
|
235
|
+
installDocker()
|
|
203
236
|
createInitialFiles()
|
|
204
237
|
ensureDockerPrivilegies()
|
|
205
238
|
console.log('All ready!')
|
|
206
239
|
}
|
|
207
240
|
|
|
241
|
+
function installDocker() {
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
execSync('docker info', { stdio: 'pipe' })
|
|
245
|
+
console.log('Docker already installed')
|
|
246
|
+
return
|
|
247
|
+
} catch (_) {}
|
|
248
|
+
|
|
249
|
+
console.log('Installing Docker...')
|
|
250
|
+
execSync('curl -fsSL https://get.docker.com -o get-docker.sh && sudo sh get-docker.sh', { stdio: 'inherit' })
|
|
251
|
+
execSync('rm get-docker.sh')
|
|
252
|
+
console.log('Docker installed successfully')
|
|
253
|
+
|
|
254
|
+
console.log('Installing Docker Compose plugin...')
|
|
255
|
+
execSync('sudo apt-get install -y docker-compose-plugin', { stdio: 'inherit' })
|
|
256
|
+
console.log('Docker Compose plugin installed successfully')
|
|
257
|
+
}
|
|
258
|
+
|
|
208
259
|
function ensureDockerPrivilegies() {
|
|
209
260
|
execSync('sudo usermod -aG docker $USER && newgrp docker')
|
|
210
261
|
writeFileSync(SETUP_STATUS_PATH, 'container:ok')
|
|
@@ -217,14 +268,43 @@ function createInitialFiles() {
|
|
|
217
268
|
}
|
|
218
269
|
|
|
219
270
|
// docker-compose.yml
|
|
220
|
-
if (!
|
|
271
|
+
if (!existsSync(COMPOSE_FILE_PATH)) {
|
|
221
272
|
const dockerComposeInitialContent = readFileSync(path.join(__dirname, 'docker-compose.yml'), 'utf8')
|
|
222
273
|
writeFileSync(COMPOSE_FILE_PATH, dockerComposeInitialContent)
|
|
223
274
|
}
|
|
224
275
|
|
|
225
276
|
// tenant-access.json
|
|
226
|
-
if (!
|
|
227
|
-
|
|
277
|
+
if (!existsSync(TENANT_ACCESS_FILE_PATH)) {
|
|
278
|
+
writeFileSync(TENANT_ACCESS_FILE_PATH, '{}');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Traefik static config
|
|
282
|
+
if (!existsSync(TRAEFIK_STATIC_PATH)) {
|
|
283
|
+
const traefikTemplate = readFileSync(path.join(__dirname, 'traefik.yml'), 'utf8');
|
|
284
|
+
writeFileSync(TRAEFIK_STATIC_PATH, traefikTemplate);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Traefik dynamic config (empty initially)
|
|
288
|
+
if (!existsSync(TRAEFIK_DYNAMIC_PATH)) {
|
|
289
|
+
const emptyDynamic = yaml.dump({
|
|
290
|
+
tcp: { routers: {}, services: {} },
|
|
291
|
+
tls: {
|
|
292
|
+
certificates: [{
|
|
293
|
+
certFile: '/etc/traefik/certs/fullchain.pem',
|
|
294
|
+
keyFile: '/etc/traefik/certs/privkey.pem'
|
|
295
|
+
}]
|
|
296
|
+
}
|
|
297
|
+
}, { indent: 2 });
|
|
298
|
+
writeFileSync(TRAEFIK_DYNAMIC_PATH, emptyDynamic);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Certs directory
|
|
302
|
+
if (!existsSync(CERTS_DIR_PATH)) {
|
|
303
|
+
mkdirSync(CERTS_DIR_PATH, { recursive: true });
|
|
304
|
+
console.log(`Created certs directory at ${CERTS_DIR_PATH}`);
|
|
305
|
+
console.log('Place your SSL certificate files there:');
|
|
306
|
+
console.log(' - fullchain.pem (certificate chain)');
|
|
307
|
+
console.log(' - privkey.pem (private key)');
|
|
228
308
|
}
|
|
229
309
|
}
|
|
230
310
|
|
|
@@ -244,7 +324,7 @@ function checkSetupStatus() {
|
|
|
244
324
|
}
|
|
245
325
|
|
|
246
326
|
function createTenant(tenantId, options = {}) {
|
|
247
|
-
const { version = '18', password, limits = DEFAULT_LIMITS } = options;
|
|
327
|
+
const { version = '18', password, limits = DEFAULT_LIMITS, manifest = null } = options;
|
|
248
328
|
const doc = loadCompose();
|
|
249
329
|
|
|
250
330
|
if (doc.services && doc.services[`pgs_${tenantId}`]) {
|
|
@@ -273,21 +353,21 @@ function createTenant(tenantId, options = {}) {
|
|
|
273
353
|
|
|
274
354
|
saveCompose(doc);
|
|
275
355
|
|
|
276
|
-
// Add tenant to access control (disabled by default for security)
|
|
277
356
|
setTenantAccess(tenantId, true);
|
|
278
357
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
358
|
+
const manifests = manifest ? { [tenantId]: manifest } : {};
|
|
359
|
+
generateTraefikDynamicConfig(manifests);
|
|
360
|
+
updateTraefikService();
|
|
282
361
|
|
|
283
|
-
// start the tenant
|
|
284
362
|
executeCommand(`docker compose up -d ${serviceName}`.trim());
|
|
285
363
|
|
|
286
364
|
console.log(`✓ Created tenant: ${tenantId}`);
|
|
287
365
|
console.log(` Service: ${serviceName}`);
|
|
366
|
+
console.log(` Host: ${tenantId}.pgs.br-sp-1.sysnee.com`);
|
|
288
367
|
console.log(` Port: 5432`);
|
|
289
368
|
console.log(` Database: ${tenantId}`);
|
|
290
369
|
console.log(` Password: ${password}`);
|
|
370
|
+
console.log(` SSL: required`);
|
|
291
371
|
console.log(` Access: enabled (use 'disable-access ${tenantId}' to disable)`);
|
|
292
372
|
|
|
293
373
|
return { tenantId, serviceName };
|
|
@@ -303,7 +383,7 @@ function listTenants() {
|
|
|
303
383
|
}
|
|
304
384
|
|
|
305
385
|
const tenants = Object.keys(doc.services)
|
|
306
|
-
.filter(name => name.startsWith('pgs_') && name !== '
|
|
386
|
+
.filter(name => name.startsWith('pgs_') && name !== 'traefik')
|
|
307
387
|
.map(name => {
|
|
308
388
|
const service = doc.services[name];
|
|
309
389
|
const db = service.environment?.POSTGRES_DB || 'N/A';
|
|
@@ -319,17 +399,18 @@ function listTenants() {
|
|
|
319
399
|
}
|
|
320
400
|
|
|
321
401
|
console.log('\nTenants:');
|
|
322
|
-
console.log('─'.repeat(
|
|
323
|
-
console.log(` ${'ID'.padEnd(
|
|
324
|
-
console.log('─'.repeat(
|
|
402
|
+
console.log('─'.repeat(90));
|
|
403
|
+
console.log(` ${'ID'.padEnd(25)} ${'Host'.padEnd(45)} ${'Access'.padEnd(15)}`);
|
|
404
|
+
console.log('─'.repeat(90));
|
|
325
405
|
tenants.forEach(t => {
|
|
326
406
|
const accessStr = t.access ? '✓ enabled' : '✗ disabled';
|
|
327
|
-
|
|
407
|
+
const host = `${t.tenantId}.pgs.br-sp-1.sysnee.com`;
|
|
408
|
+
console.log(` ${t.tenantId.padEnd(25)} ${host.padEnd(45)} ${accessStr}`);
|
|
328
409
|
});
|
|
329
|
-
console.log('─'.repeat(
|
|
410
|
+
console.log('─'.repeat(90));
|
|
330
411
|
}
|
|
331
412
|
|
|
332
|
-
function enableTenantAccess(tenantId) {
|
|
413
|
+
function enableTenantAccess(tenantId, manifests = {}) {
|
|
333
414
|
const doc = loadCompose();
|
|
334
415
|
const serviceName = `pgs_${tenantId}`;
|
|
335
416
|
|
|
@@ -337,16 +418,13 @@ function enableTenantAccess(tenantId) {
|
|
|
337
418
|
throw new Error(`Tenant ${tenantId} not found`);
|
|
338
419
|
}
|
|
339
420
|
|
|
340
|
-
// Update access control
|
|
341
421
|
setTenantAccess(tenantId, true);
|
|
342
|
-
|
|
343
|
-
// Restart HAProxy
|
|
344
|
-
executeCommand('docker restart postgres_proxy');
|
|
422
|
+
generateTraefikDynamicConfig(manifests);
|
|
345
423
|
|
|
346
424
|
console.log(`✓ External access enabled for tenant: ${tenantId}`);
|
|
347
425
|
}
|
|
348
426
|
|
|
349
|
-
function disableTenantAccess(tenantId) {
|
|
427
|
+
function disableTenantAccess(tenantId, manifests = {}) {
|
|
350
428
|
const doc = loadCompose();
|
|
351
429
|
const serviceName = `pgs_${tenantId}`;
|
|
352
430
|
|
|
@@ -354,16 +432,13 @@ function disableTenantAccess(tenantId) {
|
|
|
354
432
|
throw new Error(`Tenant ${tenantId} not found`);
|
|
355
433
|
}
|
|
356
434
|
|
|
357
|
-
// Update access control
|
|
358
435
|
setTenantAccess(tenantId, false);
|
|
359
|
-
|
|
360
|
-
// Restart HAProxy
|
|
361
|
-
executeCommand('docker restart postgres_proxy');
|
|
436
|
+
generateTraefikDynamicConfig(manifests);
|
|
362
437
|
|
|
363
438
|
console.log(`✓ External access disabled for tenant: ${tenantId}`);
|
|
364
439
|
}
|
|
365
440
|
|
|
366
|
-
function removeTenant(tenantId) {
|
|
441
|
+
function removeTenant(tenantId, manifests = {}) {
|
|
367
442
|
const doc = loadCompose();
|
|
368
443
|
const serviceName = `pgs_${tenantId}`;
|
|
369
444
|
|
|
@@ -385,10 +460,9 @@ function removeTenant(tenantId) {
|
|
|
385
460
|
|
|
386
461
|
saveCompose(doc);
|
|
387
462
|
|
|
388
|
-
// Remove from tenant access and regenerate HAProxy config
|
|
389
463
|
removeTenantAccess(tenantId);
|
|
390
|
-
|
|
391
|
-
|
|
464
|
+
generateTraefikDynamicConfig(manifests);
|
|
465
|
+
updateTraefikService();
|
|
392
466
|
|
|
393
467
|
console.log(`✓ Removed tenant: ${tenantId}`);
|
|
394
468
|
console.log(` Run: docker compose down ${serviceName}`);
|
|
@@ -437,14 +511,15 @@ program
|
|
|
437
511
|
|
|
438
512
|
const providedTenantId = args[0];
|
|
439
513
|
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
|
440
|
-
const tenantId = `${providedTenantId}
|
|
514
|
+
const tenantId = `${providedTenantId}-${randomSuffix}`;
|
|
441
515
|
|
|
442
516
|
const opts = args[1];
|
|
443
517
|
|
|
444
518
|
const tenantOptions = {
|
|
445
519
|
password: null,
|
|
446
520
|
version: null,
|
|
447
|
-
limits: null
|
|
521
|
+
limits: null,
|
|
522
|
+
manifest: null
|
|
448
523
|
}
|
|
449
524
|
|
|
450
525
|
if (opts.file) {
|
|
@@ -458,6 +533,7 @@ program
|
|
|
458
533
|
|
|
459
534
|
tenantOptions.limits = optsFromManifest.shared_limits
|
|
460
535
|
tenantOptions.version = optsFromManifest.version
|
|
536
|
+
tenantOptions.manifest = optsFromManifest
|
|
461
537
|
} else {
|
|
462
538
|
tenantOptions.version = opts.version;
|
|
463
539
|
tenantOptions.limits = {
|
|
@@ -492,10 +568,17 @@ program
|
|
|
492
568
|
.command('remove')
|
|
493
569
|
.description('Remove a tenant instance')
|
|
494
570
|
.argument('<tenant-id>', 'Tenant identifier')
|
|
495
|
-
.
|
|
571
|
+
.option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
|
|
572
|
+
.action((tenantId, opts) => {
|
|
496
573
|
checkSetupStatus()
|
|
497
574
|
try {
|
|
498
|
-
|
|
575
|
+
const manifests = {};
|
|
576
|
+
if (opts.file) {
|
|
577
|
+
const manifestFilePath = path.resolve(opts.file)
|
|
578
|
+
const manifest = JSON.parse(readFileSync(manifestFilePath))
|
|
579
|
+
manifests[tenantId] = manifest;
|
|
580
|
+
}
|
|
581
|
+
removeTenant(tenantId, manifests);
|
|
499
582
|
} catch (error) {
|
|
500
583
|
console.error(`Error: ${error.message}`);
|
|
501
584
|
process.exit(1);
|
|
@@ -520,17 +603,23 @@ program
|
|
|
520
603
|
checkSetupStatus()
|
|
521
604
|
const service = tenantId ? `pgs_${tenantId}` : '';
|
|
522
605
|
executeCommand(`docker compose stop ${service}`.trim());
|
|
523
|
-
executeCommand('docker restart postgres_proxy');
|
|
524
606
|
});
|
|
525
607
|
|
|
526
608
|
program
|
|
527
609
|
.command('enable-access')
|
|
528
610
|
.description('Enable external access for a tenant')
|
|
529
611
|
.argument('<tenant-id>', 'Tenant identifier')
|
|
530
|
-
.
|
|
612
|
+
.option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
|
|
613
|
+
.action((tenantId, opts) => {
|
|
531
614
|
checkSetupStatus()
|
|
532
615
|
try {
|
|
533
|
-
|
|
616
|
+
const manifests = {};
|
|
617
|
+
if (opts.file) {
|
|
618
|
+
const manifestFilePath = path.resolve(opts.file)
|
|
619
|
+
const manifest = JSON.parse(readFileSync(manifestFilePath))
|
|
620
|
+
manifests[tenantId] = manifest;
|
|
621
|
+
}
|
|
622
|
+
enableTenantAccess(tenantId, manifests);
|
|
534
623
|
} catch (error) {
|
|
535
624
|
console.error(`Error: ${error.message}`);
|
|
536
625
|
process.exit(1);
|
|
@@ -541,10 +630,17 @@ program
|
|
|
541
630
|
.command('disable-access')
|
|
542
631
|
.description('Disable external access for a tenant')
|
|
543
632
|
.argument('<tenant-id>', 'Tenant identifier')
|
|
544
|
-
.
|
|
633
|
+
.option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
|
|
634
|
+
.action((tenantId, opts) => {
|
|
545
635
|
checkSetupStatus()
|
|
546
636
|
try {
|
|
547
|
-
|
|
637
|
+
const manifests = {};
|
|
638
|
+
if (opts.file) {
|
|
639
|
+
const manifestFilePath = path.resolve(opts.file)
|
|
640
|
+
const manifest = JSON.parse(readFileSync(manifestFilePath))
|
|
641
|
+
manifests[tenantId] = manifest;
|
|
642
|
+
}
|
|
643
|
+
disableTenantAccess(tenantId, manifests);
|
|
548
644
|
} catch (error) {
|
|
549
645
|
console.error(`Error: ${error.message}`);
|
|
550
646
|
process.exit(1);
|
|
@@ -552,4 +648,3 @@ program
|
|
|
552
648
|
});
|
|
553
649
|
|
|
554
650
|
program.parse();
|
|
555
|
-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sysnee/pgs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7-rc.10",
|
|
4
4
|
"description": "Dynamic PostgreSQL service instance manager",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"stop": "node manager.js stop",
|
|
15
15
|
"enable-access": "node manager.js enable-access",
|
|
16
16
|
"disable-access": "node manager.js disable-access",
|
|
17
|
-
"
|
|
17
|
+
"setup": "node manager.js setup"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"js-yaml": "^4.1.0",
|
package/traefik.yml
ADDED
package/haproxy-lua/pg-route.lua
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
-- PostgreSQL Protocol Parser for HAProxy
|
|
2
|
-
-- Routes connections based on username extracted from startup packet
|
|
3
|
-
-- Checks tenant-access.json for access control
|
|
4
|
-
|
|
5
|
-
-- Simple JSON parser for our limited use case (flat object with string keys and boolean values)
|
|
6
|
-
local function parse_json(str)
|
|
7
|
-
local result = {}
|
|
8
|
-
-- Match patterns like "key": true or "key": false
|
|
9
|
-
for key, value in string.gmatch(str, '"([^"]+)":%s*(%w+)') do
|
|
10
|
-
if value == "true" then
|
|
11
|
-
result[key] = true
|
|
12
|
-
elseif value == "false" then
|
|
13
|
-
result[key] = false
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
return result
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
-- Cache for tenant access configuration
|
|
20
|
-
local tenant_access_cache = {}
|
|
21
|
-
local cache_timestamp = 0
|
|
22
|
-
local CACHE_TTL = 5 -- seconds
|
|
23
|
-
|
|
24
|
-
-- Load tenant access configuration
|
|
25
|
-
local function load_tenant_access()
|
|
26
|
-
local now = core.now().sec
|
|
27
|
-
if now - cache_timestamp < CACHE_TTL then
|
|
28
|
-
return tenant_access_cache
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
local file = io.open("/etc/haproxy/tenant-access.json", "r")
|
|
32
|
-
if not file then
|
|
33
|
-
core.Warning("tenant-access.json not found, denying all connections")
|
|
34
|
-
tenant_access_cache = {}
|
|
35
|
-
cache_timestamp = now
|
|
36
|
-
return tenant_access_cache
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
local content = file:read("*all")
|
|
40
|
-
file:close()
|
|
41
|
-
|
|
42
|
-
local data = parse_json(content)
|
|
43
|
-
tenant_access_cache = data or {}
|
|
44
|
-
cache_timestamp = now
|
|
45
|
-
|
|
46
|
-
return tenant_access_cache
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
-- Parse PostgreSQL startup packet to extract username
|
|
50
|
-
-- PostgreSQL startup packet format:
|
|
51
|
-
-- [4 bytes: length] [4 bytes: protocol version] [key=value pairs\0]
|
|
52
|
-
local function parse_startup_packet(data)
|
|
53
|
-
if #data < 8 then
|
|
54
|
-
return nil
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
-- Read packet length (big-endian)
|
|
58
|
-
local len = (string.byte(data, 1) * 16777216) +
|
|
59
|
-
(string.byte(data, 2) * 65536) +
|
|
60
|
-
(string.byte(data, 3) * 256) +
|
|
61
|
-
string.byte(data, 4)
|
|
62
|
-
|
|
63
|
-
if #data < len then
|
|
64
|
-
return nil
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
-- Read protocol version
|
|
68
|
-
local major = (string.byte(data, 5) * 256) + string.byte(data, 6)
|
|
69
|
-
local minor = (string.byte(data, 7) * 256) + string.byte(data, 8)
|
|
70
|
-
|
|
71
|
-
-- Check for SSL request (protocol 1234.5679)
|
|
72
|
-
if major == 1234 and minor == 5679 then
|
|
73
|
-
return { ssl_request = true }
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
-- Check for cancel request (protocol 1234.5678)
|
|
77
|
-
if major == 1234 and minor == 5678 then
|
|
78
|
-
return { cancel_request = true }
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
-- Normal startup message (protocol 3.0)
|
|
82
|
-
if major ~= 3 then
|
|
83
|
-
return nil
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
-- Parse key-value pairs starting at byte 9
|
|
87
|
-
local params = {}
|
|
88
|
-
local pos = 9
|
|
89
|
-
|
|
90
|
-
while pos < len do
|
|
91
|
-
-- Read key
|
|
92
|
-
local key_start = pos
|
|
93
|
-
while pos <= len and string.byte(data, pos) ~= 0 do
|
|
94
|
-
pos = pos + 1
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
if pos > len then break end
|
|
98
|
-
|
|
99
|
-
local key = string.sub(data, key_start, pos - 1)
|
|
100
|
-
pos = pos + 1 -- skip null
|
|
101
|
-
|
|
102
|
-
if key == "" then break end -- end of parameters
|
|
103
|
-
|
|
104
|
-
-- Read value
|
|
105
|
-
local val_start = pos
|
|
106
|
-
while pos <= len and string.byte(data, pos) ~= 0 do
|
|
107
|
-
pos = pos + 1
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
local value = string.sub(data, val_start, pos - 1)
|
|
111
|
-
pos = pos + 1 -- skip null
|
|
112
|
-
|
|
113
|
-
params[key] = value
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
return params
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
-- Main routing function called by HAProxy
|
|
120
|
-
function pg_route(txn)
|
|
121
|
-
-- Get data from the request buffer
|
|
122
|
-
local data = txn.req:data(0)
|
|
123
|
-
|
|
124
|
-
if not data or #data == 0 then
|
|
125
|
-
-- No data yet, need to wait
|
|
126
|
-
return
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
local params = parse_startup_packet(data)
|
|
130
|
-
|
|
131
|
-
if not params then
|
|
132
|
-
core.Warning("Failed to parse PostgreSQL startup packet")
|
|
133
|
-
txn:set_var("txn.pg_blocked", true)
|
|
134
|
-
return
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
-- Handle SSL request - route to default pool for SSL negotiation
|
|
138
|
-
-- PostgreSQL will respond 'N' (no SSL), then client retries without SSL
|
|
139
|
-
if params.ssl_request then
|
|
140
|
-
core.Info("SSL request received, routing to SSL negotiation pool")
|
|
141
|
-
-- Route to SSL pool backend which forwards to any available PostgreSQL
|
|
142
|
-
-- This allows SSL negotiation to complete
|
|
143
|
-
txn:set_var("txn.pg_backend", "pg_ssl_pool")
|
|
144
|
-
return
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
-- Handle cancel request
|
|
148
|
-
if params.cancel_request then
|
|
149
|
-
txn:set_var("txn.pg_blocked", true)
|
|
150
|
-
return
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
local username = params["user"]
|
|
154
|
-
if not username then
|
|
155
|
-
core.Warning("No username in PostgreSQL startup packet")
|
|
156
|
-
txn:set_var("txn.pg_blocked", true)
|
|
157
|
-
return
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
-- Load tenant access configuration
|
|
161
|
-
local access = load_tenant_access()
|
|
162
|
-
|
|
163
|
-
-- Check if tenant has external access enabled
|
|
164
|
-
if not access[username] then
|
|
165
|
-
core.Warning("Access denied: " .. username)
|
|
166
|
-
txn:set_var("txn.pg_blocked", true)
|
|
167
|
-
return
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
-- Route to tenant's backend
|
|
171
|
-
local backend = "pgs_" .. username
|
|
172
|
-
core.Info("Routing user " .. username .. " to backend " .. backend)
|
|
173
|
-
txn:set_var("txn.pg_backend", backend)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
-- Register the action with HAProxy
|
|
177
|
-
core.register_action("pg_route", { "tcp-req" }, pg_route, 0)
|