@sysnee/pgs 0.1.7-rc.3 → 0.1.7-rc.4

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.
@@ -1,16 +1,19 @@
1
+ version: "3.8"
2
+
1
3
  services:
2
- haproxy:
3
- image: haproxy:latest
4
+ traefik:
5
+ image: traefik:v3.0
4
6
  container_name: postgres_proxy
5
7
  ports:
6
- - '5432:5432'
8
+ - "5432:5432"
7
9
  volumes:
8
- - ./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
9
- - ./haproxy-lua:/etc/haproxy/lua:ro
10
- - ./tenant-access.json:/etc/haproxy/tenant-access.json:ro
10
+ - ./traefik.yml:/etc/traefik/traefik.yml:ro
11
+ - ./dynamic.yml:/etc/traefik/dynamic.yml:ro
12
+ - ./certs:/etc/traefik/certs:ro
11
13
  networks:
12
14
  - postgres_network
13
15
  restart: unless-stopped
16
+
14
17
  networks:
15
18
  postgres_network:
16
19
  driver: bridge
package/manager.js CHANGED
@@ -19,9 +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 LUA_DIR_PATH = path.join(CONFIG_DIR, 'haproxy-lua');
23
22
  const CERTS_DIR_PATH = path.join(CONFIG_DIR, 'certs');
24
- const HAPROXY_CFG_PATH = path.join(CONFIG_DIR, 'haproxy.cfg');
23
+ const TRAEFIK_STATIC_PATH = path.join(CONFIG_DIR, 'traefik.yml');
24
+ const TRAEFIK_DYNAMIC_PATH = path.join(CONFIG_DIR, 'dynamic.yml');
25
25
  const TENANT_ACCESS_FILE_PATH = path.join(CONFIG_DIR, 'tenant-access.json');
26
26
  const SETUP_STATUS_PATH = path.join(CONFIG_DIR, 'status.txt');
27
27
 
@@ -60,68 +60,93 @@ function removeTenantAccess(tenantId) {
60
60
  saveTenantAccess(access);
61
61
  }
62
62
 
63
- // ==================== HAProxy Configuration ====================
63
+ // ==================== Traefik Configuration ====================
64
64
 
65
- function generateHAProxyConfig() {
66
- console.debug('In generateHAProxy function')
65
+ function generateTraefikDynamicConfig() {
66
+ console.debug('Generating Traefik dynamic config')
67
67
  const doc = loadCompose();
68
+ const access = loadTenantAccess();
68
69
 
69
70
  const tenants = Object.keys(doc.services || {})
70
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
71
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik');
71
72
 
72
73
  console.debug(`tenants.length: ${tenants.length}`)
73
74
 
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`)
75
+ const dynamicConfig = {
76
+ tcp: {
77
+ routers: {},
78
+ services: {}
79
+ },
80
+ tls: {
81
+ certificates: [
82
+ {
83
+ certFile: '/etc/traefik/certs/fullchain.pem',
84
+ keyFile: '/etc/traefik/certs/privkey.pem'
85
+ }
86
+ ]
87
+ }
88
+ };
78
89
 
79
- // Generate backend for each tenant (routing by SNI hostname)
80
90
  for (const serviceName of tenants) {
81
- config += `backend ${serviceName}
82
- mode tcp
83
- server pg1 ${serviceName}:5432 check
91
+ const tenantId = serviceName.replace('pgs_', '');
92
+
93
+ if (access[tenantId] !== true) {
94
+ console.debug(`Skipping disabled tenant: ${tenantId}`);
95
+ continue;
96
+ }
97
+
98
+ const routerName = `router_${tenantId}`.replace(/-/g, '_');
99
+ const svcName = `svc_${tenantId}`.replace(/-/g, '_');
100
+
101
+ dynamicConfig.tcp.routers[routerName] = {
102
+ entryPoints: ['postgres'],
103
+ rule: `HostSNI(\`${tenantId}.pgs.us-central-1.sysnee.com\`)`,
104
+ service: svcName,
105
+ tls: {}
106
+ };
84
107
 
85
- `;
108
+ dynamicConfig.tcp.services[svcName] = {
109
+ loadBalancer: {
110
+ servers: [
111
+ { address: `${serviceName}:5432` }
112
+ ]
113
+ }
114
+ };
86
115
  }
87
116
 
88
- console.log(`Preparing to save haproxy file in ${HAPROXY_CFG_PATH}`)
89
- writeFileSync(HAPROXY_CFG_PATH, config);
90
- console.log(`Succesfully saved haproxy file in ${HAPROXY_CFG_PATH}`)
117
+ const yamlContent = yaml.dump(dynamicConfig, { indent: 2, lineWidth: -1, quotingType: '"' });
118
+
119
+ console.log(`Saving Traefik dynamic config to ${TRAEFIK_DYNAMIC_PATH}`)
120
+ writeFileSync(TRAEFIK_DYNAMIC_PATH, yamlContent);
121
+ console.log('Traefik dynamic config saved successfully')
91
122
  }
92
123
 
93
- function updateHAProxyDependsOn() {
124
+ function updateTraefikService() {
94
125
  const doc = loadCompose();
95
126
 
96
127
  const tenants = Object.keys(doc.services || {})
97
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
128
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik');
98
129
 
99
- // Always set HAProxy service with current config
100
- doc.services.haproxy = {
101
- image: 'haproxy:latest',
130
+ doc.services.traefik = {
131
+ image: 'traefik:v3.0',
102
132
  container_name: 'postgres_proxy',
103
133
  ports: ['5432:5432'],
104
134
  volumes: [
105
- `${HAPROXY_CFG_PATH}:/usr/local/etc/haproxy/haproxy.cfg:ro`,
106
- `${LUA_DIR_PATH}:/etc/haproxy/lua:ro`,
107
- `${TENANT_ACCESS_FILE_PATH}:/etc/haproxy/tenant-access.json:ro`,
108
- `${CERTS_DIR_PATH}:/etc/haproxy/certs:ro`
135
+ `${TRAEFIK_STATIC_PATH}:/etc/traefik/traefik.yml:ro`,
136
+ `${TRAEFIK_DYNAMIC_PATH}:/etc/traefik/dynamic.yml:ro`,
137
+ `${CERTS_DIR_PATH}:/etc/traefik/certs:ro`
109
138
  ],
110
139
  networks: ['postgres_network'],
111
140
  restart: 'unless-stopped'
112
141
  };
113
142
 
114
- // Update depends_on
115
143
  if (tenants.length > 0) {
116
- doc.services.haproxy.depends_on = tenants;
117
- } else {
118
- delete doc.services.haproxy.depends_on;
144
+ doc.services.traefik.depends_on = tenants;
119
145
  }
120
146
 
121
147
  saveCompose(doc);
122
148
 
123
- // Restart HAProxy
124
- executeCommand('docker restart postgres_proxy');
149
+ executeCommand('docker compose up -d --force-recreate traefik');
125
150
  }
126
151
 
127
152
  function createInitScript({ tenantId, password, databaseName }) {
@@ -211,28 +236,33 @@ function createInitialFiles() {
211
236
  writeFileSync(TENANT_ACCESS_FILE_PATH, '{}');
212
237
  }
213
238
 
214
- // haproxy.cfg
215
- if (!existsSync(HAPROXY_CFG_PATH)) {
216
- const haproxyTemplate = readFileSync(path.join(__dirname, 'haproxy.cfg'), 'utf8');
217
- writeFileSync(HAPROXY_CFG_PATH, haproxyTemplate);
239
+ // Traefik static config
240
+ if (!existsSync(TRAEFIK_STATIC_PATH)) {
241
+ const traefikTemplate = readFileSync(path.join(__dirname, 'traefik.yml'), 'utf8');
242
+ writeFileSync(TRAEFIK_STATIC_PATH, traefikTemplate);
218
243
  }
219
244
 
220
- // Lua
221
- if (!existsSync(LUA_DIR_PATH)) {
222
- mkdirSync(LUA_DIR_PATH, { recursive: true });
223
- }
224
- const luaSourceFile = path.join(__dirname, 'haproxy-lua', 'pg-route.lua');
225
- const luaDestFile = path.join(LUA_DIR_PATH, 'pg-route.lua');
226
- if (!existsSync(luaDestFile)) {
227
- const luaContent = readFileSync(luaSourceFile, 'utf8');
228
- writeFileSync(luaDestFile, luaContent);
245
+ // Traefik dynamic config (empty initially)
246
+ if (!existsSync(TRAEFIK_DYNAMIC_PATH)) {
247
+ const emptyDynamic = yaml.dump({
248
+ tcp: { routers: {}, services: {} },
249
+ tls: {
250
+ certificates: [{
251
+ certFile: '/etc/traefik/certs/fullchain.pem',
252
+ keyFile: '/etc/traefik/certs/privkey.pem'
253
+ }]
254
+ }
255
+ }, { indent: 2 });
256
+ writeFileSync(TRAEFIK_DYNAMIC_PATH, emptyDynamic);
229
257
  }
230
258
 
231
259
  // Certs directory
232
260
  if (!existsSync(CERTS_DIR_PATH)) {
233
261
  mkdirSync(CERTS_DIR_PATH, { recursive: true });
234
262
  console.log(`Created certs directory at ${CERTS_DIR_PATH}`);
235
- console.log('Place your wildcard.pem certificate file there (combined cert + key)');
263
+ console.log('Place your SSL certificate files there:');
264
+ console.log(' - fullchain.pem (certificate chain)');
265
+ console.log(' - privkey.pem (private key)');
236
266
  }
237
267
  }
238
268
 
@@ -281,21 +311,23 @@ function createTenant(tenantId, options = {}) {
281
311
 
282
312
  saveCompose(doc);
283
313
 
284
- // Add tenant to access control (disabled by default for security)
314
+ // Add tenant to access control
285
315
  setTenantAccess(tenantId, true);
286
316
 
287
- // Regenerate HAProxy config and update depends_on
288
- generateHAProxyConfig();
289
- updateHAProxyDependsOn();
317
+ // Regenerate Traefik config and update service
318
+ generateTraefikDynamicConfig();
319
+ updateTraefikService();
290
320
 
291
321
  // start the tenant
292
322
  executeCommand(`docker compose up -d ${serviceName}`.trim());
293
323
 
294
324
  console.log(`✓ Created tenant: ${tenantId}`);
295
325
  console.log(` Service: ${serviceName}`);
326
+ console.log(` Host: ${tenantId}.pgs.us-central-1.sysnee.com`);
296
327
  console.log(` Port: 5432`);
297
328
  console.log(` Database: ${tenantId}`);
298
329
  console.log(` Password: ${password}`);
330
+ console.log(` SSL: required`);
299
331
  console.log(` Access: enabled (use 'disable-access ${tenantId}' to disable)`);
300
332
 
301
333
  return { tenantId, serviceName };
@@ -311,7 +343,7 @@ function listTenants() {
311
343
  }
312
344
 
313
345
  const tenants = Object.keys(doc.services)
314
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy')
346
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik')
315
347
  .map(name => {
316
348
  const service = doc.services[name];
317
349
  const db = service.environment?.POSTGRES_DB || 'N/A';
@@ -327,14 +359,15 @@ function listTenants() {
327
359
  }
328
360
 
329
361
  console.log('\nTenants:');
330
- console.log('─'.repeat(75));
331
- console.log(` ${'ID'.padEnd(20)} ${'Database'.padEnd(20)} ${'Access'.padEnd(15)}`);
332
- console.log('─'.repeat(75));
362
+ console.log('─'.repeat(90));
363
+ console.log(` ${'ID'.padEnd(25)} ${'Host'.padEnd(45)} ${'Access'.padEnd(15)}`);
364
+ console.log('─'.repeat(90));
333
365
  tenants.forEach(t => {
334
366
  const accessStr = t.access ? '✓ enabled' : '✗ disabled';
335
- console.log(` ${t.tenantId.padEnd(20)} ${t.db.padEnd(20)} ${accessStr}`);
367
+ const host = `${t.tenantId}.pgs.us-central-1.sysnee.com`;
368
+ console.log(` ${t.tenantId.padEnd(25)} ${host.padEnd(45)} ${accessStr}`);
336
369
  });
337
- console.log('─'.repeat(75));
370
+ console.log('─'.repeat(90));
338
371
  }
339
372
 
340
373
  function enableTenantAccess(tenantId) {
@@ -345,11 +378,8 @@ function enableTenantAccess(tenantId) {
345
378
  throw new Error(`Tenant ${tenantId} not found`);
346
379
  }
347
380
 
348
- // Update access control
349
381
  setTenantAccess(tenantId, true);
350
-
351
- // Restart HAProxy
352
- executeCommand('docker restart postgres_proxy');
382
+ generateTraefikDynamicConfig();
353
383
 
354
384
  console.log(`✓ External access enabled for tenant: ${tenantId}`);
355
385
  }
@@ -362,11 +392,8 @@ function disableTenantAccess(tenantId) {
362
392
  throw new Error(`Tenant ${tenantId} not found`);
363
393
  }
364
394
 
365
- // Update access control
366
395
  setTenantAccess(tenantId, false);
367
-
368
- // Restart HAProxy
369
- executeCommand('docker restart postgres_proxy');
396
+ generateTraefikDynamicConfig();
370
397
 
371
398
  console.log(`✓ External access disabled for tenant: ${tenantId}`);
372
399
  }
@@ -393,10 +420,9 @@ function removeTenant(tenantId) {
393
420
 
394
421
  saveCompose(doc);
395
422
 
396
- // Remove from tenant access and regenerate HAProxy config
397
423
  removeTenantAccess(tenantId);
398
- generateHAProxyConfig();
399
- updateHAProxyDependsOn();
424
+ generateTraefikDynamicConfig();
425
+ updateTraefikService();
400
426
 
401
427
  console.log(`✓ Removed tenant: ${tenantId}`);
402
428
  console.log(` Run: docker compose down ${serviceName}`);
@@ -528,7 +554,6 @@ program
528
554
  checkSetupStatus()
529
555
  const service = tenantId ? `pgs_${tenantId}` : '';
530
556
  executeCommand(`docker compose stop ${service}`.trim());
531
- executeCommand('docker restart postgres_proxy');
532
557
  });
533
558
 
534
559
  program
@@ -560,4 +585,3 @@ program
560
585
  });
561
586
 
562
587
  program.parse();
563
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sysnee/pgs",
3
- "version": "0.1.7-rc.3",
3
+ "version": "0.1.7-rc.4",
4
4
  "description": "Dynamic PostgreSQL service instance manager",
5
5
  "type": "module",
6
6
  "bin": {
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,81 +0,0 @@
1
- -- PostgreSQL Protocol Router for HAProxy
2
- -- Routes connections based on SNI hostname
3
- -- Format: tenant_id.pgs.cloud.sysnee.com
4
-
5
- local function parse_json(str)
6
- local result = {}
7
- for key, value in string.gmatch(str, '"([^"]+)":%s*(%w+)') do
8
- if value == "true" then
9
- result[key] = true
10
- elseif value == "false" then
11
- result[key] = false
12
- end
13
- end
14
- return result
15
- end
16
-
17
- local tenant_access_cache = {}
18
- local cache_timestamp = 0
19
- local CACHE_TTL = 5
20
-
21
- local function load_tenant_access()
22
- local now = core.now().sec
23
- if now - cache_timestamp < CACHE_TTL then
24
- return tenant_access_cache
25
- end
26
-
27
- local file = io.open("/etc/haproxy/tenant-access.json", "r")
28
- if not file then
29
- core.Warning("tenant-access.json not found, denying all connections")
30
- tenant_access_cache = {}
31
- cache_timestamp = now
32
- return tenant_access_cache
33
- end
34
-
35
- local content = file:read("*all")
36
- file:close()
37
-
38
- tenant_access_cache = parse_json(content) or {}
39
- cache_timestamp = now
40
- return tenant_access_cache
41
- end
42
-
43
- -- Extract tenant_id from hostname
44
- -- Supports: tenant_id.pgs.cloud.sysnee.com or tenant_id.any.domain.com
45
- local function extract_tenant_from_sni(hostname)
46
- if not hostname then return nil end
47
- local tenant = string.match(hostname, "^([^.]+)%.")
48
- return tenant
49
- end
50
-
51
- function pg_route_by_sni(txn)
52
- local sni = txn.f:ssl_fc_sni()
53
-
54
- if not sni or sni == "" then
55
- core.Warning("No SNI hostname provided")
56
- txn:set_var("txn.pg_blocked", true)
57
- return
58
- end
59
-
60
- local tenant_id = extract_tenant_from_sni(sni)
61
-
62
- if not tenant_id then
63
- core.Warning("Invalid hostname format: " .. sni)
64
- txn:set_var("txn.pg_blocked", true)
65
- return
66
- end
67
-
68
- local access = load_tenant_access()
69
-
70
- if not access[tenant_id] then
71
- core.Warning("Access denied for tenant: " .. tenant_id)
72
- txn:set_var("txn.pg_blocked", true)
73
- return
74
- end
75
-
76
- local backend = "pgs_" .. tenant_id
77
- core.Info("Routing SNI " .. sni .. " to backend " .. backend)
78
- txn:set_var("txn.pg_backend", backend)
79
- end
80
-
81
- core.register_action("pg_route_by_sni", { "tcp-req" }, pg_route_by_sni, 0)
package/haproxy.cfg DELETED
@@ -1,30 +0,0 @@
1
- global
2
- log stdout format raw local0 info
3
- maxconn 4096
4
- lua-load /etc/haproxy/lua/pg-route.lua
5
-
6
- defaults
7
- log global
8
- mode tcp
9
- option tcplog
10
- timeout connect 5s
11
- timeout client 30s
12
- timeout server 30s
13
- retries 3
14
-
15
- frontend postgres_frontend
16
- bind *:5432 ssl crt /etc/haproxy/certs/wildcard.pem
17
- mode tcp
18
-
19
- # Extract tenant from SNI hostname
20
- tcp-request inspect-delay 5s
21
- tcp-request content lua.pg_route_by_sni
22
- tcp-request content reject if { var(txn.pg_blocked) -m bool }
23
-
24
- use_backend %[var(txn.pg_backend)] if { var(txn.pg_backend) -m found }
25
- default_backend pg_reject
26
-
27
- backend pg_reject
28
- mode tcp
29
- timeout server 1s
30
-