sproxy 0.1.0__py3-none-any.whl
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.
- sproxy/__init__.py +8 -0
- sproxy/__main__.py +4 -0
- sproxy/addon.py +468 -0
- sproxy/audit.py +88 -0
- sproxy/auth.py +34 -0
- sproxy/ca.py +69 -0
- sproxy/cli.py +293 -0
- sproxy/pg.py +637 -0
- sproxy/policy.py +274 -0
- sproxy/replacements.py +28 -0
- sproxy/runner.py +850 -0
- sproxy/scanner.py +109 -0
- sproxy/secrets_resolver.py +104 -0
- sproxy-0.1.0.dist-info/METADATA +197 -0
- sproxy-0.1.0.dist-info/RECORD +19 -0
- sproxy-0.1.0.dist-info/WHEEL +5 -0
- sproxy-0.1.0.dist-info/entry_points.txt +2 -0
- sproxy-0.1.0.dist-info/licenses/LICENSE +21 -0
- sproxy-0.1.0.dist-info/top_level.txt +1 -0
sproxy/pg.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
"""A protocol-terminating PostgreSQL gateway for guarded sessions.
|
|
2
|
+
|
|
3
|
+
PostgreSQL SCRAM authentication proves knowledge of the password *inside the
|
|
4
|
+
client*, so the HTTP trick of substituting a placeholder in passing traffic
|
|
5
|
+
cannot work. Instead, every ``[postgres.NAME]`` profile gets a local listener
|
|
6
|
+
that terminates the wire protocol: the agent's client authenticates to sproxy
|
|
7
|
+
with a per-session token, and sproxy alone opens the real connection -
|
|
8
|
+
TLS-verified and SCRAM-authenticated - to the host pinned in policy. The real
|
|
9
|
+
password exists only in this process; anything that bypasses the gateway is
|
|
10
|
+
holding a token that no actual database accepts.
|
|
11
|
+
|
|
12
|
+
Nothing here assumes the agent and the gateway share a host: the client hop is
|
|
13
|
+
plain TCP with token auth, so the listener can later move behind a network
|
|
14
|
+
boundary (e.g. a separate pod) unchanged.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
import secrets
|
|
24
|
+
import ssl
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from functools import partial
|
|
30
|
+
|
|
31
|
+
from sproxy.audit import AuditLog
|
|
32
|
+
from sproxy.policy import PostgresProfile
|
|
33
|
+
|
|
34
|
+
PROTOCOL_3_0 = 196608 # protocol version 3.0
|
|
35
|
+
SSL_REQUEST = 80877103
|
|
36
|
+
GSSENC_REQUEST = 80877104
|
|
37
|
+
CANCEL_REQUEST = 80877102
|
|
38
|
+
|
|
39
|
+
_MAX_STARTUP = 10_000
|
|
40
|
+
_MAX_MESSAGE = 1 << 30 # PostgreSQL's own wire limit
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PgError(RuntimeError):
|
|
44
|
+
"""A connection cannot proceed; ``code`` is a PostgreSQL SQLSTATE."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, code: str = "08000"):
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- wire format ------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def message(kind: bytes, payload: bytes) -> bytes:
|
|
54
|
+
return kind + (len(payload) + 4).to_bytes(4, "big") + payload
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def read_message(reader: asyncio.StreamReader) -> tuple[bytes, bytes]:
|
|
58
|
+
kind = await reader.readexactly(1)
|
|
59
|
+
length = int.from_bytes(await reader.readexactly(4), "big")
|
|
60
|
+
if not 4 <= length <= _MAX_MESSAGE:
|
|
61
|
+
raise PgError(f"invalid message length {length}", code="08P01")
|
|
62
|
+
return kind, await reader.readexactly(length - 4)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def startup_message(params: dict[str, str]) -> bytes:
|
|
66
|
+
body = PROTOCOL_3_0.to_bytes(4, "big")
|
|
67
|
+
for key, value in params.items():
|
|
68
|
+
body += key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
|
|
69
|
+
body += b"\x00"
|
|
70
|
+
return (len(body) + 4).to_bytes(4, "big") + body
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def read_startup(reader: asyncio.StreamReader) -> bytes:
|
|
74
|
+
"""Read one length-prefixed startup-phase packet, sans its length word."""
|
|
75
|
+
length = int.from_bytes(await reader.readexactly(4), "big")
|
|
76
|
+
if not 8 <= length <= _MAX_STARTUP:
|
|
77
|
+
raise PgError(f"invalid startup packet length {length}", code="08P01")
|
|
78
|
+
return await reader.readexactly(length - 4)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_startup_params(body: bytes) -> dict[str, str]:
|
|
82
|
+
params = {}
|
|
83
|
+
fields = body.split(b"\x00")
|
|
84
|
+
for key, value in zip(fields[::2], fields[1::2]):
|
|
85
|
+
if key:
|
|
86
|
+
params[key.decode("utf-8", "replace")] = value.decode("utf-8", "replace")
|
|
87
|
+
return params
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def error_response(text: str, code: str = "08000") -> bytes:
|
|
91
|
+
fields = b""
|
|
92
|
+
for kind, value in (("S", "FATAL"), ("V", "FATAL"), ("C", code), ("M", text)):
|
|
93
|
+
fields += kind.encode("ascii") + value.encode("utf-8") + b"\x00"
|
|
94
|
+
return message(b"E", fields + b"\x00")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def error_text(payload: bytes) -> str:
|
|
98
|
+
for chunk in payload.split(b"\x00"):
|
|
99
|
+
if chunk[:1] == b"M":
|
|
100
|
+
return chunk[1:].decode("utf-8", "replace")
|
|
101
|
+
return "unknown error"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- SCRAM-SHA-256 client (RFC 7677) ----------------------------------------
|
|
105
|
+
|
|
106
|
+
def _saslname(value: str) -> str:
|
|
107
|
+
return value.replace("=", "=3D").replace(",", "=2C")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _scram_attrs(text: str) -> dict[str, str]:
|
|
111
|
+
attrs: dict[str, str] = {}
|
|
112
|
+
for part in text.split(","):
|
|
113
|
+
key, sep, value = part.partition("=")
|
|
114
|
+
if sep:
|
|
115
|
+
attrs.setdefault(key, value)
|
|
116
|
+
return attrs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ScramClient:
|
|
120
|
+
"""Client side of SCRAM-SHA-256, without channel binding.
|
|
121
|
+
|
|
122
|
+
libpq sends an empty SASL username because PostgreSQL takes the role from
|
|
123
|
+
the startup message; ``username`` and ``nonce`` are parameterised so the
|
|
124
|
+
RFC 7677 test vectors can drive this implementation directly.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, password: str, *, username: str = "", nonce: str | None = None):
|
|
128
|
+
self._password = password
|
|
129
|
+
self._nonce = nonce or secrets.token_urlsafe(18)
|
|
130
|
+
self._first_bare = f"n={_saslname(username)},r={self._nonce}"
|
|
131
|
+
self._salted: bytes | None = None
|
|
132
|
+
self._auth_message: bytes | None = None
|
|
133
|
+
|
|
134
|
+
def client_first(self) -> str:
|
|
135
|
+
return "n,," + self._first_bare
|
|
136
|
+
|
|
137
|
+
def client_final(self, server_first: str) -> str:
|
|
138
|
+
attrs = _scram_attrs(server_first)
|
|
139
|
+
try:
|
|
140
|
+
combined = attrs["r"]
|
|
141
|
+
salt = base64.b64decode(attrs["s"])
|
|
142
|
+
iterations = int(attrs["i"])
|
|
143
|
+
except (KeyError, ValueError) as exc:
|
|
144
|
+
raise PgError(f"malformed SCRAM server-first message: {server_first!r}") from exc
|
|
145
|
+
if not combined.startswith(self._nonce) or len(combined) <= len(self._nonce):
|
|
146
|
+
raise PgError("SCRAM server nonce does not extend the client nonce")
|
|
147
|
+
self._salted = hashlib.pbkdf2_hmac(
|
|
148
|
+
"sha256", self._password.encode("utf-8"), salt, iterations
|
|
149
|
+
)
|
|
150
|
+
client_key = hmac.digest(self._salted, b"Client Key", "sha256")
|
|
151
|
+
without_proof = f"c=biws,r={combined}" # biws = base64("n,,")
|
|
152
|
+
self._auth_message = ",".join(
|
|
153
|
+
(self._first_bare, server_first, without_proof)
|
|
154
|
+
).encode("utf-8")
|
|
155
|
+
signature = hmac.digest(
|
|
156
|
+
hashlib.sha256(client_key).digest(), self._auth_message, "sha256"
|
|
157
|
+
)
|
|
158
|
+
proof = bytes(a ^ b for a, b in zip(client_key, signature))
|
|
159
|
+
return f"{without_proof},p={base64.b64encode(proof).decode('ascii')}"
|
|
160
|
+
|
|
161
|
+
def verify_server_final(self, server_final: str) -> None:
|
|
162
|
+
attrs = _scram_attrs(server_final)
|
|
163
|
+
if "e" in attrs:
|
|
164
|
+
raise PgError(f"SCRAM authentication failed: {attrs['e']}", code="28P01")
|
|
165
|
+
if self._salted is None or self._auth_message is None or "v" not in attrs:
|
|
166
|
+
raise PgError("SCRAM exchange out of order")
|
|
167
|
+
server_key = hmac.digest(self._salted, b"Server Key", "sha256")
|
|
168
|
+
expected = hmac.digest(server_key, self._auth_message, "sha256")
|
|
169
|
+
try:
|
|
170
|
+
got = base64.b64decode(attrs["v"])
|
|
171
|
+
except ValueError as exc:
|
|
172
|
+
raise PgError("malformed SCRAM server signature") from exc
|
|
173
|
+
if not hmac.compare_digest(expected, got):
|
|
174
|
+
raise PgError("SCRAM server signature mismatch - not the real server?")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- upstream side ----------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def _open_upstream(
|
|
180
|
+
profile: PostgresProfile,
|
|
181
|
+
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
182
|
+
try:
|
|
183
|
+
reader, writer = await asyncio.open_connection(profile.host, profile.port)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
raise PgError(f"cannot reach {profile.host}:{profile.port}: {exc}") from exc
|
|
186
|
+
if profile.sslmode == "disable":
|
|
187
|
+
return reader, writer
|
|
188
|
+
try:
|
|
189
|
+
writer.write((8).to_bytes(4, "big") + SSL_REQUEST.to_bytes(4, "big"))
|
|
190
|
+
await writer.drain()
|
|
191
|
+
answer = await reader.readexactly(1)
|
|
192
|
+
if answer != b"S":
|
|
193
|
+
raise PgError(f"{profile.host} refused TLS, which this profile requires")
|
|
194
|
+
context = ssl.create_default_context(cafile=profile.sslrootcert or None)
|
|
195
|
+
await writer.start_tls(context, server_hostname=profile.host)
|
|
196
|
+
except PgError:
|
|
197
|
+
writer.close()
|
|
198
|
+
raise
|
|
199
|
+
except (ssl.SSLError, OSError, asyncio.IncompleteReadError) as exc:
|
|
200
|
+
writer.close()
|
|
201
|
+
raise PgError(f"TLS to {profile.host} failed: {exc}") from exc
|
|
202
|
+
return reader, writer
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _authenticate_upstream(
|
|
206
|
+
reader: asyncio.StreamReader,
|
|
207
|
+
writer: asyncio.StreamWriter,
|
|
208
|
+
*,
|
|
209
|
+
user: str,
|
|
210
|
+
password: str,
|
|
211
|
+
) -> None:
|
|
212
|
+
scram: ScramClient | None = None
|
|
213
|
+
while True:
|
|
214
|
+
kind, payload = await read_message(reader)
|
|
215
|
+
if kind == b"E":
|
|
216
|
+
raise PgError(
|
|
217
|
+
f"upstream authentication failed: {error_text(payload)}", code="28P01"
|
|
218
|
+
)
|
|
219
|
+
if kind != b"R":
|
|
220
|
+
raise PgError(f"unexpected message {kind!r} during upstream authentication")
|
|
221
|
+
code = int.from_bytes(payload[:4], "big")
|
|
222
|
+
if code == 0: # AuthenticationOk
|
|
223
|
+
return
|
|
224
|
+
if code == 3: # cleartext
|
|
225
|
+
writer.write(message(b"p", password.encode("utf-8") + b"\x00"))
|
|
226
|
+
elif code == 5: # md5
|
|
227
|
+
inner = hashlib.md5(password.encode("utf-8") + user.encode("utf-8")).hexdigest()
|
|
228
|
+
digest = "md5" + hashlib.md5(inner.encode("ascii") + payload[4:8]).hexdigest()
|
|
229
|
+
writer.write(message(b"p", digest.encode("ascii") + b"\x00"))
|
|
230
|
+
elif code == 10: # SASL mechanism negotiation
|
|
231
|
+
if b"SCRAM-SHA-256" not in payload[4:].split(b"\x00"):
|
|
232
|
+
raise PgError("upstream offers no supported SASL mechanism (need SCRAM-SHA-256)")
|
|
233
|
+
scram = ScramClient(password)
|
|
234
|
+
first = scram.client_first().encode("utf-8")
|
|
235
|
+
writer.write(
|
|
236
|
+
message(b"p", b"SCRAM-SHA-256\x00" + len(first).to_bytes(4, "big") + first)
|
|
237
|
+
)
|
|
238
|
+
elif code == 11: # SASL continue
|
|
239
|
+
if scram is None:
|
|
240
|
+
raise PgError("SCRAM continue before mechanism negotiation")
|
|
241
|
+
final = scram.client_final(payload[4:].decode("utf-8"))
|
|
242
|
+
writer.write(message(b"p", final.encode("utf-8")))
|
|
243
|
+
elif code == 12: # SASL final
|
|
244
|
+
if scram is None:
|
|
245
|
+
raise PgError("SCRAM final before mechanism negotiation")
|
|
246
|
+
scram.verify_server_final(payload[4:].decode("utf-8"))
|
|
247
|
+
else:
|
|
248
|
+
raise PgError(f"unsupported upstream authentication method {code}")
|
|
249
|
+
await writer.drain()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def _forward_cancel(profile: PostgresProfile, packet: bytes) -> None:
|
|
253
|
+
"""Relay a CancelRequest; its key is the relayed upstream one, so it fits."""
|
|
254
|
+
try:
|
|
255
|
+
_, writer = await _open_upstream(profile)
|
|
256
|
+
except PgError:
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
writer.write((len(packet) + 4).to_bytes(4, "big") + packet)
|
|
260
|
+
await writer.drain()
|
|
261
|
+
except (ConnectionError, OSError):
|
|
262
|
+
pass
|
|
263
|
+
finally:
|
|
264
|
+
writer.close()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def _splice(
|
|
268
|
+
client_reader: asyncio.StreamReader,
|
|
269
|
+
client_writer: asyncio.StreamWriter,
|
|
270
|
+
up_reader: asyncio.StreamReader,
|
|
271
|
+
up_writer: asyncio.StreamWriter,
|
|
272
|
+
) -> int:
|
|
273
|
+
"""Relay until either side hangs up; returns the number of queries seen."""
|
|
274
|
+
queries = 0
|
|
275
|
+
|
|
276
|
+
async def client_to_upstream() -> None:
|
|
277
|
+
nonlocal queries
|
|
278
|
+
while True:
|
|
279
|
+
kind = await client_reader.read(1)
|
|
280
|
+
if not kind:
|
|
281
|
+
break
|
|
282
|
+
length_bytes = await client_reader.readexactly(4)
|
|
283
|
+
length = int.from_bytes(length_bytes, "big")
|
|
284
|
+
if not 4 <= length <= _MAX_MESSAGE:
|
|
285
|
+
break
|
|
286
|
+
payload = await client_reader.readexactly(length - 4)
|
|
287
|
+
if kind in (b"Q", b"P"): # simple query / extended-protocol Parse
|
|
288
|
+
queries += 1
|
|
289
|
+
up_writer.write(kind + length_bytes + payload)
|
|
290
|
+
await up_writer.drain()
|
|
291
|
+
|
|
292
|
+
async def upstream_to_client() -> None:
|
|
293
|
+
while True:
|
|
294
|
+
chunk = await up_reader.read(65536)
|
|
295
|
+
if not chunk:
|
|
296
|
+
break
|
|
297
|
+
client_writer.write(chunk)
|
|
298
|
+
await client_writer.drain()
|
|
299
|
+
|
|
300
|
+
tasks = [
|
|
301
|
+
asyncio.create_task(client_to_upstream()),
|
|
302
|
+
asyncio.create_task(upstream_to_client()),
|
|
303
|
+
]
|
|
304
|
+
_, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
305
|
+
for task in pending:
|
|
306
|
+
task.cancel()
|
|
307
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
308
|
+
return queries
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --- the gateway ------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
class Endpoint:
|
|
314
|
+
"""One profile plus its resolved upstream password."""
|
|
315
|
+
|
|
316
|
+
def __init__(self, profile: PostgresProfile, password: str):
|
|
317
|
+
self.profile = profile
|
|
318
|
+
self.password = password
|
|
319
|
+
self.bound_port: int | None = None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class PostgresGateway:
|
|
323
|
+
"""Serve every profile on a local port and audit each connection.
|
|
324
|
+
|
|
325
|
+
The gateway is a separate process from the mitmdump addon and each audit
|
|
326
|
+
hash chain tolerates only one writer, so it chains its own file.
|
|
327
|
+
|
|
328
|
+
The live profile set can be replaced while the gateway runs (see
|
|
329
|
+
:meth:`apply`), because a policy file may be edited mid-session. Listeners
|
|
330
|
+
are keyed by profile name and looked up per connection, so re-pointing a
|
|
331
|
+
profile at another database changes only what the *next* connection does.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
endpoints: list[Endpoint],
|
|
337
|
+
token: str,
|
|
338
|
+
*,
|
|
339
|
+
audit_log: str = "",
|
|
340
|
+
listen_host: str = "127.0.0.1",
|
|
341
|
+
):
|
|
342
|
+
self._pending = list(endpoints)
|
|
343
|
+
self._token = token
|
|
344
|
+
self._listen_host = listen_host
|
|
345
|
+
self._audit_path = audit_log
|
|
346
|
+
self._audit: AuditLog | None = None
|
|
347
|
+
# Both keyed by profile name: the endpoint being served, and the
|
|
348
|
+
# listener the session has been told about.
|
|
349
|
+
self._live: dict[str, Endpoint] = {}
|
|
350
|
+
self._listeners: dict[str, asyncio.Server] = {}
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def endpoints(self) -> list[Endpoint]:
|
|
354
|
+
"""The profiles currently being served, in policy order."""
|
|
355
|
+
return list(self._live.values())
|
|
356
|
+
|
|
357
|
+
async def start(self) -> None:
|
|
358
|
+
if self._audit_path:
|
|
359
|
+
self._audit = AuditLog(self._audit_path)
|
|
360
|
+
await self.apply(self._pending)
|
|
361
|
+
|
|
362
|
+
async def apply(
|
|
363
|
+
self, endpoints: list[Endpoint], *, audit_log: str | None = None
|
|
364
|
+
) -> dict[str, list[str]]:
|
|
365
|
+
"""Make ``endpoints`` the live set without disturbing issued endpoints.
|
|
366
|
+
|
|
367
|
+
A profile that is still configured keeps its local port - the session
|
|
368
|
+
was already handed that port and the session token, and a policy edit
|
|
369
|
+
must not invalidate credentials the agent already holds. Established
|
|
370
|
+
connections are never interrupted; a changed profile takes effect on the
|
|
371
|
+
next connection. Removed profiles stop accepting new connections.
|
|
372
|
+
|
|
373
|
+
New listeners are bound before any old one is closed, so a port
|
|
374
|
+
collision aborts the change with the previous set still serving.
|
|
375
|
+
|
|
376
|
+
Returns the per-kind profile names that changed, for the caller to report.
|
|
377
|
+
"""
|
|
378
|
+
current = dict(self._live)
|
|
379
|
+
incoming = {e.profile.name: e for e in endpoints}
|
|
380
|
+
changes: dict[str, list[str]] = {
|
|
381
|
+
"added": [], "removed": [], "updated": [], "rebound": []
|
|
382
|
+
}
|
|
383
|
+
fresh: dict[str, asyncio.Server] = {}
|
|
384
|
+
try:
|
|
385
|
+
for name, endpoint in incoming.items():
|
|
386
|
+
previous = current.get(name)
|
|
387
|
+
if previous is not None and previous.bound_port is not None and (
|
|
388
|
+
endpoint.profile.listen in (0, previous.bound_port)
|
|
389
|
+
):
|
|
390
|
+
# Same local endpoint: keep the listener already advertised.
|
|
391
|
+
endpoint.bound_port = previous.bound_port
|
|
392
|
+
if (
|
|
393
|
+
endpoint.profile != previous.profile
|
|
394
|
+
or endpoint.password != previous.password
|
|
395
|
+
):
|
|
396
|
+
changes["updated"].append(name)
|
|
397
|
+
continue
|
|
398
|
+
fresh[name] = await self._listen(name, endpoint)
|
|
399
|
+
endpoint.bound_port = fresh[name].sockets[0].getsockname()[1]
|
|
400
|
+
changes["rebound" if previous is not None else "added"].append(name)
|
|
401
|
+
except PgError:
|
|
402
|
+
for server in fresh.values():
|
|
403
|
+
server.close()
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
if audit_log is not None and audit_log != self._audit_path:
|
|
407
|
+
self._audit_path = audit_log
|
|
408
|
+
self._audit = AuditLog(audit_log) if audit_log else None
|
|
409
|
+
|
|
410
|
+
for name, server in list(self._listeners.items()):
|
|
411
|
+
if name in fresh or name not in incoming:
|
|
412
|
+
# close() stops accepting; connections in progress are left to
|
|
413
|
+
# finish, so saving a config file never kills a running query.
|
|
414
|
+
# Deliberately no wait_closed(): from Python 3.12 it blocks
|
|
415
|
+
# until every relayed session has ended.
|
|
416
|
+
server.close()
|
|
417
|
+
del self._listeners[name]
|
|
418
|
+
if name not in incoming:
|
|
419
|
+
changes["removed"].append(name)
|
|
420
|
+
self._listeners.update(fresh)
|
|
421
|
+
self._live = incoming
|
|
422
|
+
return changes
|
|
423
|
+
|
|
424
|
+
async def _listen(self, name: str, endpoint: Endpoint) -> asyncio.Server:
|
|
425
|
+
"""Bind this profile's local listener, preferring its advertised port."""
|
|
426
|
+
wanted = endpoint.profile.listen or endpoint.bound_port or 0
|
|
427
|
+
try:
|
|
428
|
+
return await self._start_server(name, wanted)
|
|
429
|
+
except PgError:
|
|
430
|
+
if endpoint.profile.listen or not wanted:
|
|
431
|
+
raise
|
|
432
|
+
# ``bound_port`` was only a hint - the port this profile had before
|
|
433
|
+
# it was removed from the policy. Re-adding it should not fail just
|
|
434
|
+
# because something else took that port meanwhile.
|
|
435
|
+
return await self._start_server(name, 0)
|
|
436
|
+
|
|
437
|
+
async def _start_server(self, name: str, port: int) -> asyncio.Server:
|
|
438
|
+
try:
|
|
439
|
+
return await asyncio.start_server(
|
|
440
|
+
partial(self._serve, name), self._listen_host, port
|
|
441
|
+
)
|
|
442
|
+
except OSError as exc:
|
|
443
|
+
raise PgError(
|
|
444
|
+
f"postgres profile {name!r} cannot listen on "
|
|
445
|
+
f"{self._listen_host}:{port}: {exc}"
|
|
446
|
+
) from exc
|
|
447
|
+
|
|
448
|
+
async def stop(self) -> None:
|
|
449
|
+
for server in self._listeners.values():
|
|
450
|
+
server.close()
|
|
451
|
+
for server in self._listeners.values():
|
|
452
|
+
await server.wait_closed()
|
|
453
|
+
self._listeners.clear()
|
|
454
|
+
|
|
455
|
+
def child_env(self) -> dict[str, str]:
|
|
456
|
+
"""Endpoint advertisements for the guarded session.
|
|
457
|
+
|
|
458
|
+
Values are libpq conninfo strings, so ``psql "$SPROXY_POSTGRES_NAME"`` just
|
|
459
|
+
works and extra parameters can be appended to override the defaults.
|
|
460
|
+
Nothing password-like beyond the session token is ever placed here.
|
|
461
|
+
"""
|
|
462
|
+
env = {"SPROXY_POSTGRES_TOKEN": self._token}
|
|
463
|
+
for endpoint in self.endpoints:
|
|
464
|
+
env[endpoint.profile.env_var] = self._conninfo(endpoint)
|
|
465
|
+
return env
|
|
466
|
+
|
|
467
|
+
def _conninfo(self, endpoint: Endpoint) -> str:
|
|
468
|
+
profile = endpoint.profile
|
|
469
|
+
# sslmode=disable covers only the local hop to the gateway; the
|
|
470
|
+
# upstream connection is TLS-verified by sproxy per the profile.
|
|
471
|
+
parts = [
|
|
472
|
+
f"host={self._listen_host}",
|
|
473
|
+
f"port={endpoint.bound_port}",
|
|
474
|
+
f"password={self._token}",
|
|
475
|
+
"sslmode=disable",
|
|
476
|
+
]
|
|
477
|
+
if profile.user:
|
|
478
|
+
parts.append(f"user={profile.user}")
|
|
479
|
+
if profile.database:
|
|
480
|
+
parts.append(f"dbname={profile.database}")
|
|
481
|
+
return " ".join(parts)
|
|
482
|
+
|
|
483
|
+
async def _serve(
|
|
484
|
+
self,
|
|
485
|
+
name: str,
|
|
486
|
+
reader: asyncio.StreamReader,
|
|
487
|
+
writer: asyncio.StreamWriter,
|
|
488
|
+
) -> None:
|
|
489
|
+
started = time.monotonic()
|
|
490
|
+
# Look the profile up per connection so a reloaded policy applies here
|
|
491
|
+
# without rebinding the port the session was told to use.
|
|
492
|
+
endpoint = self._live.get(name)
|
|
493
|
+
if endpoint is None: # removed by a reload as this connection arrived
|
|
494
|
+
await _fail(writer, PgError(f"postgres profile {name!r} is no longer configured"))
|
|
495
|
+
writer.close()
|
|
496
|
+
return
|
|
497
|
+
profile = endpoint.profile
|
|
498
|
+
record = {
|
|
499
|
+
"kind": "postgres",
|
|
500
|
+
"profile": profile.name,
|
|
501
|
+
"host": profile.host,
|
|
502
|
+
"port": profile.port,
|
|
503
|
+
"tls": profile.sslmode != "disable",
|
|
504
|
+
"user": "",
|
|
505
|
+
"database": "",
|
|
506
|
+
"decision": "error",
|
|
507
|
+
"reason": "",
|
|
508
|
+
"queries": 0,
|
|
509
|
+
}
|
|
510
|
+
up_writer: asyncio.StreamWriter | None = None
|
|
511
|
+
try:
|
|
512
|
+
packet = await read_startup(reader)
|
|
513
|
+
code = int.from_bytes(packet[:4], "big")
|
|
514
|
+
while code in (SSL_REQUEST, GSSENC_REQUEST):
|
|
515
|
+
# The local hop is plain TCP for now; libpq falls back cleanly.
|
|
516
|
+
writer.write(b"N")
|
|
517
|
+
await writer.drain()
|
|
518
|
+
packet = await read_startup(reader)
|
|
519
|
+
code = int.from_bytes(packet[:4], "big")
|
|
520
|
+
if code == CANCEL_REQUEST:
|
|
521
|
+
record["decision"] = "cancel"
|
|
522
|
+
await _forward_cancel(profile, packet)
|
|
523
|
+
return
|
|
524
|
+
if code != PROTOCOL_3_0:
|
|
525
|
+
raise PgError(f"unsupported protocol version {code}", code="08P01")
|
|
526
|
+
|
|
527
|
+
params = parse_startup_params(packet[4:])
|
|
528
|
+
# The profile's user/database are advertised defaults, not
|
|
529
|
+
# overrides: the client's explicit choice is forwarded, and the
|
|
530
|
+
# database itself enforces what this profile's credential may do.
|
|
531
|
+
user = params.get("user") or profile.user
|
|
532
|
+
if not user:
|
|
533
|
+
raise PgError("no user in startup message", code="28000")
|
|
534
|
+
database = params.get("database") or profile.database or user
|
|
535
|
+
record["user"] = user
|
|
536
|
+
record["database"] = database
|
|
537
|
+
|
|
538
|
+
writer.write(message(b"R", (3).to_bytes(4, "big"))) # cleartext request
|
|
539
|
+
await writer.drain()
|
|
540
|
+
kind, payload = await read_message(reader)
|
|
541
|
+
supplied = payload.rstrip(b"\x00") if kind == b"p" else b""
|
|
542
|
+
if not hmac.compare_digest(supplied, self._token.encode("utf-8")):
|
|
543
|
+
record["decision"] = "denied"
|
|
544
|
+
record["reason"] = "bad session token"
|
|
545
|
+
raise PgError(
|
|
546
|
+
"invalid session token (connect with the advertised "
|
|
547
|
+
f"${profile.env_var} conninfo or $SPROXY_POSTGRES_TOKEN as the password)",
|
|
548
|
+
code="28P01",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
upstream_params = dict(params)
|
|
552
|
+
upstream_params["user"] = user
|
|
553
|
+
upstream_params["database"] = database
|
|
554
|
+
up_reader, up_writer = await _open_upstream(profile)
|
|
555
|
+
up_writer.write(startup_message(upstream_params))
|
|
556
|
+
await up_writer.drain()
|
|
557
|
+
await _authenticate_upstream(
|
|
558
|
+
up_reader, up_writer, user=user, password=endpoint.password
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
writer.write(message(b"R", (0).to_bytes(4, "big"))) # AuthenticationOk
|
|
562
|
+
await writer.drain()
|
|
563
|
+
record["decision"] = "allow"
|
|
564
|
+
record["queries"] = await _splice(reader, writer, up_reader, up_writer)
|
|
565
|
+
except PgError as exc:
|
|
566
|
+
record["reason"] = record["reason"] or str(exc)
|
|
567
|
+
await _fail(writer, exc)
|
|
568
|
+
except (asyncio.IncompleteReadError, ConnectionError, OSError) as exc:
|
|
569
|
+
record["reason"] = record["reason"] or f"connection error: {exc}"
|
|
570
|
+
finally:
|
|
571
|
+
record["duration_ms"] = int((time.monotonic() - started) * 1000)
|
|
572
|
+
self._log(record)
|
|
573
|
+
for w in (up_writer, writer):
|
|
574
|
+
if w is not None:
|
|
575
|
+
w.close()
|
|
576
|
+
|
|
577
|
+
def _log(self, record: dict) -> None:
|
|
578
|
+
if self._audit is None:
|
|
579
|
+
return
|
|
580
|
+
try:
|
|
581
|
+
self._audit.append({"ts": datetime.now(timezone.utc).isoformat(), **record})
|
|
582
|
+
except Exception as exc: # never let audit failure break traffic
|
|
583
|
+
print(f"sproxy: postgres audit write failed: {exc}", file=sys.stderr, flush=True)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _fail(writer: asyncio.StreamWriter, exc: PgError) -> None:
|
|
587
|
+
try:
|
|
588
|
+
writer.write(error_response(f"sproxy: {exc}", code=exc.code))
|
|
589
|
+
await writer.drain()
|
|
590
|
+
except (ConnectionError, OSError):
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class GatewayThread:
|
|
595
|
+
"""Run a :class:`PostgresGateway` on its own event loop beside the runner."""
|
|
596
|
+
|
|
597
|
+
def __init__(self, gateway: PostgresGateway):
|
|
598
|
+
self.gateway = gateway
|
|
599
|
+
self._loop = asyncio.new_event_loop()
|
|
600
|
+
self._thread = threading.Thread(
|
|
601
|
+
target=self._run, name="sproxy-postgres", daemon=True
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
def _run(self) -> None:
|
|
605
|
+
asyncio.set_event_loop(self._loop)
|
|
606
|
+
try:
|
|
607
|
+
self._loop.run_forever()
|
|
608
|
+
finally:
|
|
609
|
+
self._loop.close()
|
|
610
|
+
|
|
611
|
+
def start(self, timeout: float = 10.0) -> None:
|
|
612
|
+
self._thread.start()
|
|
613
|
+
asyncio.run_coroutine_threadsafe(self.gateway.start(), self._loop).result(timeout)
|
|
614
|
+
|
|
615
|
+
def apply(
|
|
616
|
+
self,
|
|
617
|
+
endpoints: list[Endpoint],
|
|
618
|
+
*,
|
|
619
|
+
audit_log: str | None = None,
|
|
620
|
+
timeout: float = 10.0,
|
|
621
|
+
) -> dict[str, list[str]]:
|
|
622
|
+
"""Replace the live profile set from another thread (a policy reload)."""
|
|
623
|
+
return asyncio.run_coroutine_threadsafe(
|
|
624
|
+
self.gateway.apply(endpoints, audit_log=audit_log), self._loop
|
|
625
|
+
).result(timeout)
|
|
626
|
+
|
|
627
|
+
def child_env(self) -> dict[str, str]:
|
|
628
|
+
return self.gateway.child_env()
|
|
629
|
+
|
|
630
|
+
def stop(self, timeout: float = 5.0) -> None:
|
|
631
|
+
if not self._thread.is_alive():
|
|
632
|
+
return
|
|
633
|
+
try:
|
|
634
|
+
asyncio.run_coroutine_threadsafe(self.gateway.stop(), self._loop).result(timeout)
|
|
635
|
+
finally:
|
|
636
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
637
|
+
self._thread.join(timeout)
|