@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.
- package/docker-compose.yml +9 -6
- package/manager.js +94 -70
- package/package.json +1 -1
- package/traefik.yml +15 -0
- package/haproxy-lua/pg-route.lua +0 -81
- package/haproxy.cfg +0 -30
package/docker-compose.yml
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
version: "3.8"
|
|
2
|
+
|
|
1
3
|
services:
|
|
2
|
-
|
|
3
|
-
image:
|
|
4
|
+
traefik:
|
|
5
|
+
image: traefik:v3.0
|
|
4
6
|
container_name: postgres_proxy
|
|
5
7
|
ports:
|
|
6
|
-
-
|
|
8
|
+
- "5432:5432"
|
|
7
9
|
volumes:
|
|
8
|
-
- ./
|
|
9
|
-
- ./
|
|
10
|
-
- ./
|
|
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
|
|
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
|
-
// ====================
|
|
63
|
+
// ==================== Traefik Configuration ====================
|
|
64
64
|
|
|
65
|
-
function
|
|
66
|
-
console.debug('
|
|
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 !== '
|
|
71
|
+
.filter(name => name.startsWith('pgs_') && name !== 'traefik');
|
|
71
72
|
|
|
72
73
|
console.debug(`tenants.length: ${tenants.length}`)
|
|
73
74
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
console.log(`
|
|
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
|
|
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 !== '
|
|
128
|
+
.filter(name => name.startsWith('pgs_') && name !== 'traefik');
|
|
98
129
|
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
`${
|
|
106
|
-
`${
|
|
107
|
-
`${
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
215
|
-
if (!existsSync(
|
|
216
|
-
const
|
|
217
|
-
writeFileSync(
|
|
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
|
-
//
|
|
221
|
-
if (!existsSync(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
|
314
|
+
// Add tenant to access control
|
|
285
315
|
setTenantAccess(tenantId, true);
|
|
286
316
|
|
|
287
|
-
// Regenerate
|
|
288
|
-
|
|
289
|
-
|
|
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 !== '
|
|
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(
|
|
331
|
-
console.log(` ${'ID'.padEnd(
|
|
332
|
-
console.log('─'.repeat(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
399
|
-
|
|
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
package/traefik.yml
ADDED
package/haproxy-lua/pg-route.lua
DELETED
|
@@ -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
|
-
|