@sysnee/pgs 0.1.7-rc.1 → 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.
@@ -1,11 +1,9 @@
1
- -- PostgreSQL Protocol Parser for HAProxy
2
- -- Routes connections based on username extracted from startup packet
3
- -- Checks tenant-access.json for access control
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 -- seconds
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
- local data = parse_json(content)
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
- -- 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
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
- -- 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
51
+ function pg_route_by_sni(txn)
52
+ local sni = txn.f:ssl_fc_sni()
128
53
 
129
- local params = parse_startup_packet(data)
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
- -- 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
60
+ local tenant_id = extract_tenant_from_sni(sni)
152
61
 
153
- local username = params["user"]
154
- if not username then
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
- -- Check if tenant has external access enabled
164
- if not access[username] then
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
- -- Route to tenant's backend
171
- local backend = "pgs_" .. username
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
- -- Register the action with HAProxy
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
- # Use Lua script to parse PostgreSQL startup packet and route by username
19
+ # Extract tenant from SNI hostname
20
20
  tcp-request inspect-delay 5s
21
- tcp-request content lua.pg_route
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
@@ -19,6 +19,8 @@ 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 CERTS_DIR_PATH = path.join(CONFIG_DIR, 'certs');
22
24
  const HAPROXY_CFG_PATH = path.join(CONFIG_DIR, 'haproxy.cfg');
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');
@@ -64,33 +66,17 @@ function generateHAProxyConfig() {
64
66
  console.debug('In generateHAProxy function')
65
67
  const doc = loadCompose();
66
68
 
67
- // Get all tenant services
68
69
  const tenants = Object.keys(doc.services || {})
69
70
  .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
70
71
 
71
- console.debug(`tenants.lenght: ${tenants.length}`)
72
+ console.debug(`tenants.length: ${tenants.length}`)
72
73
 
73
- // Get initial HAProxy config
74
74
  const templateFilePath = path.join(__dirname, 'haproxy.cfg')
75
75
  console.debug(`haproxy template file path: ${templateFilePath}`)
76
76
  let config = readFileSync(templateFilePath, 'utf8');
77
77
  console.debug(`haproxy template file loaded`)
78
78
 
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
- `;
85
- }
86
- config += `
87
- `;
88
- } else {
89
- config += ` # No tenants configured yet
90
- `;
91
- }
92
-
93
- // Generate backend for each tenant
79
+ // Generate backend for each tenant (routing by SNI hostname)
94
80
  for (const serviceName of tenants) {
95
81
  config += `backend ${serviceName}
96
82
  mode tcp
@@ -107,25 +93,23 @@ function generateHAProxyConfig() {
107
93
  function updateHAProxyDependsOn() {
108
94
  const doc = loadCompose();
109
95
 
110
- // Get all tenant services
111
96
  const tenants = Object.keys(doc.services || {})
112
97
  .filter(name => name.startsWith('pgs_') && name !== 'haproxy');
113
98
 
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_PATH}:/usr/local/etc/haproxy/haproxy.cfg:ro`,
122
- './haproxy-lua:/etc/haproxy/lua:ro',
123
- `${TENANT_ACCESS_FILE_PATH}:/etc/haproxy/tenant-access.json:ro`
124
- ],
125
- networks: ['postgres_network'],
126
- restart: 'unless-stopped'
127
- };
128
- }
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
+ };
129
113
 
130
114
  // Update depends_on
131
115
  if (tenants.length > 0) {
@@ -232,6 +216,24 @@ function createInitialFiles() {
232
216
  const haproxyTemplate = readFileSync(path.join(__dirname, 'haproxy.cfg'), 'utf8');
233
217
  writeFileSync(HAPROXY_CFG_PATH, haproxyTemplate);
234
218
  }
219
+
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);
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
+ }
235
237
  }
236
238
 
237
239
  function checkSetupStatus() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sysnee/pgs",
3
- "version": "0.1.7-rc.1",
3
+ "version": "0.1.7-rc.3",
4
4
  "description": "Dynamic PostgreSQL service instance manager",
5
5
  "type": "module",
6
6
  "bin": {