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/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)