@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/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 HAPROXY_CFG_PATH = path.join(CONFIG_DIR, 'haproxy.cfg');
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
- // ==================== HAProxy Configuration ====================
63
+ // ==================== Traefik Configuration ====================
62
64
 
63
- function generateHAProxyConfig() {
64
- console.debug('In generateHAProxy function')
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 !== 'haproxy');
70
-
71
- console.debug(`tenants.lenght: ${tenants.length}`)
71
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik');
72
72
 
73
- // Get initial HAProxy config
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
- // Add all tenant backends to SSL pool for SSL negotiation
80
- // PostgreSQL will respond 'N' (no SSL) during negotiation, then client retries without SSL
81
- if (tenants.length > 0) {
82
- for (const serviceName of tenants) {
83
- config += ` server ${serviceName}_ssl ${serviceName}:5432 check
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
- config += `
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
- config += `backend ${serviceName}
96
- mode tcp
97
- server pg1 ${serviceName}:5432 check
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
- console.log(`Preparing to save haproxy file in ${HAPROXY_CFG_PATH}`)
103
- writeFileSync(HAPROXY_CFG_PATH, config);
104
- console.log(`Succesfully saved haproxy file in ${HAPROXY_CFG_PATH}`)
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 updateHAProxyDependsOn() {
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 !== 'haproxy');
113
-
114
- // Ensure HAProxy service exists
115
- if (!doc.services.haproxy) {
116
- doc.services.haproxy = {
117
- image: 'haproxy:latest',
118
- container_name: 'postgres_proxy',
119
- ports: ['5432:5432'],
120
- volumes: [
121
- './haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro',
122
- './haproxy-lua:/etc/haproxy/lua:ro',
123
- './tenant-access.json:/etc/haproxy/tenant-access.json:ro'
124
- ],
125
- networks: ['postgres_network'],
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.haproxy.depends_on = tenants;
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
- // Restart HAProxy
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 (!fs.existsSync(COMPOSE_FILE_PATH)) {
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 (!fs.existsSync(TENANT_ACCESS_FILE_PATH)) {
227
- fs.writeFileSync(TENANT_ACCESS_FILE_PATH, '{}');
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
- // Regenerate HAProxy config and update depends_on
280
- generateHAProxyConfig();
281
- updateHAProxyDependsOn();
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 !== 'haproxy')
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(75));
323
- console.log(` ${'ID'.padEnd(20)} ${'Database'.padEnd(20)} ${'Access'.padEnd(15)}`);
324
- console.log('─'.repeat(75));
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
- console.log(` ${t.tenantId.padEnd(20)} ${t.db.padEnd(20)} ${accessStr}`);
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(75));
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
- generateHAProxyConfig();
391
- updateHAProxyDependsOn();
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}_${randomSuffix}`;
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
- .action((tenantId) => {
571
+ .option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
572
+ .action((tenantId, opts) => {
496
573
  checkSetupStatus()
497
574
  try {
498
- removeTenant(tenantId);
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
- .action((tenantId) => {
612
+ .option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
613
+ .action((tenantId, opts) => {
531
614
  checkSetupStatus()
532
615
  try {
533
- enableTenantAccess(tenantId);
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
- .action((tenantId) => {
633
+ .option('-f, --file <file>', 'Manifest file path (optional, for firewall rules)')
634
+ .action((tenantId, opts) => {
545
635
  checkSetupStatus()
546
636
  try {
547
- disableTenantAccess(tenantId);
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.6",
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
- "reload-haproxy": "node manager.js reload-haproxy"
17
+ "setup": "node manager.js setup"
18
18
  },
19
19
  "dependencies": {
20
20
  "js-yaml": "^4.1.0",
package/traefik.yml ADDED
@@ -0,0 +1,15 @@
1
+ api:
2
+ dashboard: false
3
+
4
+ log:
5
+ level: INFO
6
+
7
+ entryPoints:
8
+ postgres:
9
+ address: ":5432"
10
+
11
+ providers:
12
+ file:
13
+ filename: /etc/traefik/dynamic.yml
14
+ watch: true
15
+
@@ -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)