@sysnee/pgs 0.1.7-rc.2 → 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,8 +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
- 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');
24
25
  const TENANT_ACCESS_FILE_PATH = path.join(CONFIG_DIR, 'tenant-access.json');
25
26
  const SETUP_STATUS_PATH = path.join(CONFIG_DIR, 'status.txt');
26
27
 
@@ -59,86 +60,93 @@ function removeTenantAccess(tenantId) {
59
60
  saveTenantAccess(access);
60
61
  }
61
62
 
62
- // ==================== HAProxy Configuration ====================
63
+ // ==================== Traefik Configuration ====================
63
64
 
64
- function generateHAProxyConfig() {
65
- console.debug('In generateHAProxy function')
65
+ function generateTraefikDynamicConfig() {
66
+ console.debug('Generating Traefik dynamic config')
66
67
  const doc = loadCompose();
68
+ const access = loadTenantAccess();
67
69
 
68
- // Get all tenant services
69
70
  const tenants = Object.keys(doc.services || {})
70
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
71
-
72
- console.debug(`tenants.lenght: ${tenants.length}`)
71
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik');
73
72
 
74
- // Get initial HAProxy config
75
- const templateFilePath = path.join(__dirname, 'haproxy.cfg')
76
- console.debug(`haproxy template file path: ${templateFilePath}`)
77
- let config = readFileSync(templateFilePath, 'utf8');
78
- console.debug(`haproxy template file loaded`)
73
+ console.debug(`tenants.length: ${tenants.length}`)
79
74
 
80
- // Add all tenant backends to SSL pool for SSL negotiation
81
- // PostgreSQL will respond 'N' (no SSL) during negotiation, then client retries without SSL
82
- if (tenants.length > 0) {
83
- for (const serviceName of tenants) {
84
- config += ` server ${serviceName}_ssl ${serviceName}:5432 check
85
- `;
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
+ ]
86
87
  }
87
- config += `
88
- `;
89
- } else {
90
- config += ` # No tenants configured yet
91
- `;
92
- }
88
+ };
93
89
 
94
- // Generate backend for each tenant
95
90
  for (const serviceName of tenants) {
96
- config += `backend ${serviceName}
97
- mode tcp
98
- 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
+ };
99
107
 
100
- `;
108
+ dynamicConfig.tcp.services[svcName] = {
109
+ loadBalancer: {
110
+ servers: [
111
+ { address: `${serviceName}:5432` }
112
+ ]
113
+ }
114
+ };
101
115
  }
102
116
 
103
- console.log(`Preparing to save haproxy file in ${HAPROXY_CFG_PATH}`)
104
- writeFileSync(HAPROXY_CFG_PATH, config);
105
- 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')
106
122
  }
107
123
 
108
- function updateHAProxyDependsOn() {
124
+ function updateTraefikService() {
109
125
  const doc = loadCompose();
110
126
 
111
- // Get all tenant services
112
127
  const tenants = Object.keys(doc.services || {})
113
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
114
-
115
- // Ensure HAProxy service exists
116
- if (!doc.services.haproxy) {
117
- doc.services.haproxy = {
118
- image: 'haproxy:latest',
119
- container_name: 'postgres_proxy',
120
- ports: ['5432:5432'],
121
- volumes: [
122
- `${HAPROXY_CFG_PATH}:/usr/local/etc/haproxy/haproxy.cfg:ro`,
123
- `${LUA_DIR_PATH}:/etc/haproxy/lua:ro`,
124
- `${TENANT_ACCESS_FILE_PATH}:/etc/haproxy/tenant-access.json:ro`
125
- ],
126
- networks: ['postgres_network'],
127
- restart: 'unless-stopped'
128
- };
129
- }
128
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik');
129
+
130
+ doc.services.traefik = {
131
+ image: 'traefik:v3.0',
132
+ container_name: 'postgres_proxy',
133
+ ports: ['5432:5432'],
134
+ volumes: [
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`
138
+ ],
139
+ networks: ['postgres_network'],
140
+ restart: 'unless-stopped'
141
+ };
130
142
 
131
- // Update depends_on
132
143
  if (tenants.length > 0) {
133
- doc.services.haproxy.depends_on = tenants;
134
- } else {
135
- delete doc.services.haproxy.depends_on;
144
+ doc.services.traefik.depends_on = tenants;
136
145
  }
137
146
 
138
147
  saveCompose(doc);
139
148
 
140
- // Restart HAProxy
141
- executeCommand('docker compose up -d haproxy');
149
+ executeCommand('docker compose up -d --force-recreate traefik');
142
150
  }
143
151
 
144
152
  function createInitScript({ tenantId, password, databaseName }) {
@@ -228,21 +236,33 @@ function createInitialFiles() {
228
236
  writeFileSync(TENANT_ACCESS_FILE_PATH, '{}');
229
237
  }
230
238
 
231
- // haproxy.cfg
232
- if (!existsSync(HAPROXY_CFG_PATH)) {
233
- const haproxyTemplate = readFileSync(path.join(__dirname, 'haproxy.cfg'), 'utf8');
234
- 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);
235
243
  }
236
244
 
237
- // Lua
238
- if (!existsSync(LUA_DIR_PATH)) {
239
- mkdirSync(LUA_DIR_PATH, { recursive: true });
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);
240
257
  }
241
- const luaSourceFile = path.join(__dirname, 'haproxy-lua', 'pg-route.lua');
242
- const luaDestFile = path.join(LUA_DIR_PATH, 'pg-route.lua');
243
- if (!existsSync(luaDestFile)) {
244
- const luaContent = readFileSync(luaSourceFile, 'utf8');
245
- writeFileSync(luaDestFile, luaContent);
258
+
259
+ // Certs directory
260
+ if (!existsSync(CERTS_DIR_PATH)) {
261
+ mkdirSync(CERTS_DIR_PATH, { recursive: true });
262
+ console.log(`Created certs directory at ${CERTS_DIR_PATH}`);
263
+ console.log('Place your SSL certificate files there:');
264
+ console.log(' - fullchain.pem (certificate chain)');
265
+ console.log(' - privkey.pem (private key)');
246
266
  }
247
267
  }
248
268
 
@@ -291,21 +311,23 @@ function createTenant(tenantId, options = {}) {
291
311
 
292
312
  saveCompose(doc);
293
313
 
294
- // Add tenant to access control (disabled by default for security)
314
+ // Add tenant to access control
295
315
  setTenantAccess(tenantId, true);
296
316
 
297
- // Regenerate HAProxy config and update depends_on
298
- generateHAProxyConfig();
299
- updateHAProxyDependsOn();
317
+ // Regenerate Traefik config and update service
318
+ generateTraefikDynamicConfig();
319
+ updateTraefikService();
300
320
 
301
321
  // start the tenant
302
322
  executeCommand(`docker compose up -d ${serviceName}`.trim());
303
323
 
304
324
  console.log(`✓ Created tenant: ${tenantId}`);
305
325
  console.log(` Service: ${serviceName}`);
326
+ console.log(` Host: ${tenantId}.pgs.us-central-1.sysnee.com`);
306
327
  console.log(` Port: 5432`);
307
328
  console.log(` Database: ${tenantId}`);
308
329
  console.log(` Password: ${password}`);
330
+ console.log(` SSL: required`);
309
331
  console.log(` Access: enabled (use 'disable-access ${tenantId}' to disable)`);
310
332
 
311
333
  return { tenantId, serviceName };
@@ -321,7 +343,7 @@ function listTenants() {
321
343
  }
322
344
 
323
345
  const tenants = Object.keys(doc.services)
324
- .filter(name => name.startsWith('pgs_') && name !== 'haproxy')
346
+ .filter(name => name.startsWith('pgs_') && name !== 'traefik')
325
347
  .map(name => {
326
348
  const service = doc.services[name];
327
349
  const db = service.environment?.POSTGRES_DB || 'N/A';
@@ -337,14 +359,15 @@ function listTenants() {
337
359
  }
338
360
 
339
361
  console.log('\nTenants:');
340
- console.log('─'.repeat(75));
341
- console.log(` ${'ID'.padEnd(20)} ${'Database'.padEnd(20)} ${'Access'.padEnd(15)}`);
342
- 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));
343
365
  tenants.forEach(t => {
344
366
  const accessStr = t.access ? '✓ enabled' : '✗ disabled';
345
- 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}`);
346
369
  });
347
- console.log('─'.repeat(75));
370
+ console.log('─'.repeat(90));
348
371
  }
349
372
 
350
373
  function enableTenantAccess(tenantId) {
@@ -355,11 +378,8 @@ function enableTenantAccess(tenantId) {
355
378
  throw new Error(`Tenant ${tenantId} not found`);
356
379
  }
357
380
 
358
- // Update access control
359
381
  setTenantAccess(tenantId, true);
360
-
361
- // Restart HAProxy
362
- executeCommand('docker restart postgres_proxy');
382
+ generateTraefikDynamicConfig();
363
383
 
364
384
  console.log(`✓ External access enabled for tenant: ${tenantId}`);
365
385
  }
@@ -372,11 +392,8 @@ function disableTenantAccess(tenantId) {
372
392
  throw new Error(`Tenant ${tenantId} not found`);
373
393
  }
374
394
 
375
- // Update access control
376
395
  setTenantAccess(tenantId, false);
377
-
378
- // Restart HAProxy
379
- executeCommand('docker restart postgres_proxy');
396
+ generateTraefikDynamicConfig();
380
397
 
381
398
  console.log(`✓ External access disabled for tenant: ${tenantId}`);
382
399
  }
@@ -403,10 +420,9 @@ function removeTenant(tenantId) {
403
420
 
404
421
  saveCompose(doc);
405
422
 
406
- // Remove from tenant access and regenerate HAProxy config
407
423
  removeTenantAccess(tenantId);
408
- generateHAProxyConfig();
409
- updateHAProxyDependsOn();
424
+ generateTraefikDynamicConfig();
425
+ updateTraefikService();
410
426
 
411
427
  console.log(`✓ Removed tenant: ${tenantId}`);
412
428
  console.log(` Run: docker compose down ${serviceName}`);
@@ -538,7 +554,6 @@ program
538
554
  checkSetupStatus()
539
555
  const service = tenantId ? `pgs_${tenantId}` : '';
540
556
  executeCommand(`docker compose stop ${service}`.trim());
541
- executeCommand('docker restart postgres_proxy');
542
557
  });
543
558
 
544
559
  program
@@ -570,4 +585,3 @@ program
570
585
  });
571
586
 
572
587
  program.parse();
573
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sysnee/pgs",
3
- "version": "0.1.7-rc.2",
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,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)
package/haproxy.cfg DELETED
@@ -1,35 +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
17
- mode tcp
18
-
19
- # Use Lua script to parse PostgreSQL startup packet and route by username
20
- tcp-request inspect-delay 5s
21
- tcp-request content lua.pg_route
22
- tcp-request content reject if { var(txn.pg_blocked) -m bool }
23
-
24
- # Route to backend based on username extracted by Lua
25
- use_backend %[var(txn.pg_backend)] if { var(txn.pg_backend) -m found }
26
-
27
- # Default backend for unknown connections
28
- default_backend pg_reject
29
-
30
- backend pg_reject
31
- mode tcp
32
- timeout server 1s
33
-
34
- backend pg_ssl_pool
35
- mode tcp