@sysnee/pgs 0.1.7-rc.2 → 0.1.7-rc.3
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/haproxy-lua/pg-route.lua +23 -119
- package/haproxy.cfg +3 -8
- package/manager.js +25 -35
- package/package.json +1 -1
package/haproxy-lua/pg-route.lua
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
-- PostgreSQL Protocol
|
|
2
|
-
-- Routes connections based on
|
|
3
|
-
--
|
|
1
|
+
-- PostgreSQL Protocol Router for HAProxy
|
|
2
|
+
-- Routes connections based on SNI hostname
|
|
3
|
+
-- Format: tenant_id.pgs.cloud.sysnee.com
|
|
4
4
|
|
|
5
|
-
-- Simple JSON parser for our limited use case (flat object with string keys and boolean values)
|
|
6
5
|
local function parse_json(str)
|
|
7
6
|
local result = {}
|
|
8
|
-
-- Match patterns like "key": true or "key": false
|
|
9
7
|
for key, value in string.gmatch(str, '"([^"]+)":%s*(%w+)') do
|
|
10
8
|
if value == "true" then
|
|
11
9
|
result[key] = true
|
|
@@ -16,12 +14,10 @@ local function parse_json(str)
|
|
|
16
14
|
return result
|
|
17
15
|
end
|
|
18
16
|
|
|
19
|
-
-- Cache for tenant access configuration
|
|
20
17
|
local tenant_access_cache = {}
|
|
21
18
|
local cache_timestamp = 0
|
|
22
|
-
local CACHE_TTL = 5
|
|
19
|
+
local CACHE_TTL = 5
|
|
23
20
|
|
|
24
|
-
-- Load tenant access configuration
|
|
25
21
|
local function load_tenant_access()
|
|
26
22
|
local now = core.now().sec
|
|
27
23
|
if now - cache_timestamp < CACHE_TTL then
|
|
@@ -39,139 +35,47 @@ local function load_tenant_access()
|
|
|
39
35
|
local content = file:read("*all")
|
|
40
36
|
file:close()
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
tenant_access_cache = data or {}
|
|
38
|
+
tenant_access_cache = parse_json(content) or {}
|
|
44
39
|
cache_timestamp = now
|
|
45
|
-
|
|
46
40
|
return tenant_access_cache
|
|
47
41
|
end
|
|
48
42
|
|
|
49
|
-
--
|
|
50
|
-
--
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
117
49
|
end
|
|
118
50
|
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
51
|
+
function pg_route_by_sni(txn)
|
|
52
|
+
local sni = txn.f:ssl_fc_sni()
|
|
128
53
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if not params then
|
|
132
|
-
core.Warning("Failed to parse PostgreSQL startup packet")
|
|
54
|
+
if not sni or sni == "" then
|
|
55
|
+
core.Warning("No SNI hostname provided")
|
|
133
56
|
txn:set_var("txn.pg_blocked", true)
|
|
134
57
|
return
|
|
135
58
|
end
|
|
136
59
|
|
|
137
|
-
|
|
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
|
|
60
|
+
local tenant_id = extract_tenant_from_sni(sni)
|
|
152
61
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
core.Warning("No username in PostgreSQL startup packet")
|
|
62
|
+
if not tenant_id then
|
|
63
|
+
core.Warning("Invalid hostname format: " .. sni)
|
|
156
64
|
txn:set_var("txn.pg_blocked", true)
|
|
157
65
|
return
|
|
158
66
|
end
|
|
159
67
|
|
|
160
|
-
-- Load tenant access configuration
|
|
161
68
|
local access = load_tenant_access()
|
|
162
69
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
core.Warning("Access denied: " .. username)
|
|
70
|
+
if not access[tenant_id] then
|
|
71
|
+
core.Warning("Access denied for tenant: " .. tenant_id)
|
|
166
72
|
txn:set_var("txn.pg_blocked", true)
|
|
167
73
|
return
|
|
168
74
|
end
|
|
169
75
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
core.Info("Routing user " .. username .. " to backend " .. backend)
|
|
76
|
+
local backend = "pgs_" .. tenant_id
|
|
77
|
+
core.Info("Routing SNI " .. sni .. " to backend " .. backend)
|
|
173
78
|
txn:set_var("txn.pg_backend", backend)
|
|
174
79
|
end
|
|
175
80
|
|
|
176
|
-
|
|
177
|
-
core.register_action("pg_route", { "tcp-req" }, pg_route, 0)
|
|
81
|
+
core.register_action("pg_route_by_sni", { "tcp-req" }, pg_route_by_sni, 0)
|
package/haproxy.cfg
CHANGED
|
@@ -13,23 +13,18 @@ defaults
|
|
|
13
13
|
retries 3
|
|
14
14
|
|
|
15
15
|
frontend postgres_frontend
|
|
16
|
-
bind *:5432
|
|
16
|
+
bind *:5432 ssl crt /etc/haproxy/certs/wildcard.pem
|
|
17
17
|
mode tcp
|
|
18
18
|
|
|
19
|
-
#
|
|
19
|
+
# Extract tenant from SNI hostname
|
|
20
20
|
tcp-request inspect-delay 5s
|
|
21
|
-
tcp-request content lua.
|
|
21
|
+
tcp-request content lua.pg_route_by_sni
|
|
22
22
|
tcp-request content reject if { var(txn.pg_blocked) -m bool }
|
|
23
23
|
|
|
24
|
-
# Route to backend based on username extracted by Lua
|
|
25
24
|
use_backend %[var(txn.pg_backend)] if { var(txn.pg_backend) -m found }
|
|
26
|
-
|
|
27
|
-
# Default backend for unknown connections
|
|
28
25
|
default_backend pg_reject
|
|
29
26
|
|
|
30
27
|
backend pg_reject
|
|
31
28
|
mode tcp
|
|
32
29
|
timeout server 1s
|
|
33
30
|
|
|
34
|
-
backend pg_ssl_pool
|
|
35
|
-
mode tcp
|
package/manager.js
CHANGED
|
@@ -20,6 +20,7 @@ const CONFIG_DIR = path.join(os.homedir(), '.sysnee-config');
|
|
|
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
22
|
const LUA_DIR_PATH = path.join(CONFIG_DIR, 'haproxy-lua');
|
|
23
|
+
const CERTS_DIR_PATH = path.join(CONFIG_DIR, 'certs');
|
|
23
24
|
const HAPROXY_CFG_PATH = path.join(CONFIG_DIR, 'haproxy.cfg');
|
|
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');
|
|
@@ -65,33 +66,17 @@ function generateHAProxyConfig() {
|
|
|
65
66
|
console.debug('In generateHAProxy function')
|
|
66
67
|
const doc = loadCompose();
|
|
67
68
|
|
|
68
|
-
// Get all tenant services
|
|
69
69
|
const tenants = Object.keys(doc.services || {})
|
|
70
70
|
.filter(name => name.startsWith('pgs_') && name !== 'haproxy');
|
|
71
71
|
|
|
72
|
-
console.debug(`tenants.
|
|
72
|
+
console.debug(`tenants.length: ${tenants.length}`)
|
|
73
73
|
|
|
74
|
-
// Get initial HAProxy config
|
|
75
74
|
const templateFilePath = path.join(__dirname, 'haproxy.cfg')
|
|
76
75
|
console.debug(`haproxy template file path: ${templateFilePath}`)
|
|
77
76
|
let config = readFileSync(templateFilePath, 'utf8');
|
|
78
77
|
console.debug(`haproxy template file loaded`)
|
|
79
78
|
|
|
80
|
-
//
|
|
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
|
-
`;
|
|
86
|
-
}
|
|
87
|
-
config += `
|
|
88
|
-
`;
|
|
89
|
-
} else {
|
|
90
|
-
config += ` # No tenants configured yet
|
|
91
|
-
`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Generate backend for each tenant
|
|
79
|
+
// Generate backend for each tenant (routing by SNI hostname)
|
|
95
80
|
for (const serviceName of tenants) {
|
|
96
81
|
config += `backend ${serviceName}
|
|
97
82
|
mode tcp
|
|
@@ -108,25 +93,23 @@ function generateHAProxyConfig() {
|
|
|
108
93
|
function updateHAProxyDependsOn() {
|
|
109
94
|
const doc = loadCompose();
|
|
110
95
|
|
|
111
|
-
// Get all tenant services
|
|
112
96
|
const tenants = Object.keys(doc.services || {})
|
|
113
97
|
.filter(name => name.startsWith('pgs_') && name !== 'haproxy');
|
|
114
98
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
99
|
+
// Always set HAProxy service with current config
|
|
100
|
+
doc.services.haproxy = {
|
|
101
|
+
image: 'haproxy:latest',
|
|
102
|
+
container_name: 'postgres_proxy',
|
|
103
|
+
ports: ['5432:5432'],
|
|
104
|
+
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`
|
|
109
|
+
],
|
|
110
|
+
networks: ['postgres_network'],
|
|
111
|
+
restart: 'unless-stopped'
|
|
112
|
+
};
|
|
130
113
|
|
|
131
114
|
// Update depends_on
|
|
132
115
|
if (tenants.length > 0) {
|
|
@@ -138,7 +121,7 @@ function updateHAProxyDependsOn() {
|
|
|
138
121
|
saveCompose(doc);
|
|
139
122
|
|
|
140
123
|
// Restart HAProxy
|
|
141
|
-
executeCommand('docker
|
|
124
|
+
executeCommand('docker restart postgres_proxy');
|
|
142
125
|
}
|
|
143
126
|
|
|
144
127
|
function createInitScript({ tenantId, password, databaseName }) {
|
|
@@ -244,6 +227,13 @@ function createInitialFiles() {
|
|
|
244
227
|
const luaContent = readFileSync(luaSourceFile, 'utf8');
|
|
245
228
|
writeFileSync(luaDestFile, luaContent);
|
|
246
229
|
}
|
|
230
|
+
|
|
231
|
+
// Certs directory
|
|
232
|
+
if (!existsSync(CERTS_DIR_PATH)) {
|
|
233
|
+
mkdirSync(CERTS_DIR_PATH, { recursive: true });
|
|
234
|
+
console.log(`Created certs directory at ${CERTS_DIR_PATH}`);
|
|
235
|
+
console.log('Place your wildcard.pem certificate file there (combined cert + key)');
|
|
236
|
+
}
|
|
247
237
|
}
|
|
248
238
|
|
|
249
239
|
function checkSetupStatus() {
|