cloud-sql-python-connector 1.22.0__py3-none-any.whl → 1.24.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.
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/METADATA +89 -9
- cloud_sql_python_connector-1.24.0.dist-info/RECORD +28 -0
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/WHEEL +1 -1
- google/cloud/sql/connector/client.py +15 -3
- google/cloud/sql/connector/connection_info.py +12 -1
- google/cloud/sql/connector/connector.py +445 -134
- google/cloud/sql/connector/enums.py +3 -0
- google/cloud/sql/connector/exceptions.py +33 -0
- google/cloud/sql/connector/instance.py +238 -2
- google/cloud/sql/connector/lazy.py +47 -3
- google/cloud/sql/connector/monitored_cache.py +14 -8
- google/cloud/sql/connector/postgres_connection.py +235 -0
- google/cloud/sql/connector/psycopg.py +241 -153
- google/cloud/sql/connector/refresh_utils.py +4 -3
- google/cloud/sql/connector/sqldata_client.py +654 -0
- google/cloud/sql/connector/utils.py +1 -1
- google/cloud/sql/connector/version.py +1 -1
- cloud_sql_python_connector-1.22.0.dist-info/RECORD +0 -26
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/licenses/LICENSE +0 -0
- {cloud_sql_python_connector-1.22.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Copyright 2026 Google LLC
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
import socket
|
|
21
|
+
import ssl
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
MAX_PG_STARTUP_PACKET_LEN = 10000
|
|
25
|
+
PG_PROTOCOL_VERSION_30 = 196608 # 0x00030000
|
|
26
|
+
PG_SSL_REQUEST_CODE = 80877103 # 0x04d2162f
|
|
27
|
+
PG_GSS_ENC_REQUEST_CODE = 80877104 # 0x04d21630
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PostgresConnection:
|
|
31
|
+
"""Wraps a socket to inspect initial client-side PostgreSQL v3 StartupMessage bytes.
|
|
32
|
+
|
|
33
|
+
Extracts the (user, database) principal pair for Auto-IAM refresh probes.
|
|
34
|
+
Handles chunked streaming across multiple write/send operations.
|
|
35
|
+
Once the startup message is detected or determined not to be a PostgreSQL v3
|
|
36
|
+
startup packet, startup_message_sent is set to True, the internal buffer is
|
|
37
|
+
cleared, and all subsequent writes pass through directly with zero overhead.
|
|
38
|
+
|
|
39
|
+
Like standard Python socket and stream wrappers, callers own synchronization
|
|
40
|
+
for concurrent writes; no internal lock is acquired on the write path.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
sock: socket.socket | ssl.SSLSocket,
|
|
46
|
+
on_startup: Callable[[str, str], None],
|
|
47
|
+
) -> None:
|
|
48
|
+
self._sock = sock
|
|
49
|
+
self._on_startup = on_startup
|
|
50
|
+
self._buf = bytearray()
|
|
51
|
+
self.startup_message_sent = False
|
|
52
|
+
|
|
53
|
+
def _inspect(self, data: bytes | bytearray | memoryview) -> None:
|
|
54
|
+
if self.startup_message_sent or not data:
|
|
55
|
+
return
|
|
56
|
+
self._buf.extend(data)
|
|
57
|
+
user, database, complete = parse_postgres_startup_packet(self._buf)
|
|
58
|
+
if complete:
|
|
59
|
+
self.startup_message_sent = True
|
|
60
|
+
self._buf.clear()
|
|
61
|
+
if user:
|
|
62
|
+
self._on_startup(user, database)
|
|
63
|
+
elif len(self._buf) > MAX_PG_STARTUP_PACKET_LEN + 16:
|
|
64
|
+
self.startup_message_sent = True
|
|
65
|
+
self._buf.clear()
|
|
66
|
+
|
|
67
|
+
def send(
|
|
68
|
+
self,
|
|
69
|
+
data: bytes | bytearray | memoryview,
|
|
70
|
+
*args: Any,
|
|
71
|
+
**kwargs: Any,
|
|
72
|
+
) -> int:
|
|
73
|
+
sent = self._sock.send(data, *args, **kwargs)
|
|
74
|
+
if not self.startup_message_sent:
|
|
75
|
+
chunk = data[:sent] if isinstance(sent, int) else data
|
|
76
|
+
if chunk:
|
|
77
|
+
self._inspect(chunk)
|
|
78
|
+
return sent
|
|
79
|
+
|
|
80
|
+
def sendall(
|
|
81
|
+
self,
|
|
82
|
+
data: bytes | bytearray | memoryview,
|
|
83
|
+
*args: Any,
|
|
84
|
+
**kwargs: Any,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._sock.sendall(data, *args, **kwargs)
|
|
87
|
+
if not self.startup_message_sent and data:
|
|
88
|
+
self._inspect(data)
|
|
89
|
+
|
|
90
|
+
def write(self, data: bytes | bytearray | memoryview) -> int:
|
|
91
|
+
written = (
|
|
92
|
+
self._sock.write(data)
|
|
93
|
+
if hasattr(self._sock, "write")
|
|
94
|
+
else self._sock.send(data)
|
|
95
|
+
)
|
|
96
|
+
if not self.startup_message_sent:
|
|
97
|
+
chunk = data[:written] if isinstance(written, int) else data
|
|
98
|
+
if chunk:
|
|
99
|
+
self._inspect(chunk)
|
|
100
|
+
return written
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def _io_refs(self) -> int:
|
|
104
|
+
return getattr(self._sock, "_io_refs", 0)
|
|
105
|
+
|
|
106
|
+
@_io_refs.setter
|
|
107
|
+
def _io_refs(self, value: int) -> None:
|
|
108
|
+
self._sock._io_refs = value # type: ignore[union-attr]
|
|
109
|
+
|
|
110
|
+
def makefile(
|
|
111
|
+
self,
|
|
112
|
+
mode: str = "r",
|
|
113
|
+
buffering: int | None = None,
|
|
114
|
+
*,
|
|
115
|
+
encoding: str | None = None,
|
|
116
|
+
errors: str | None = None,
|
|
117
|
+
newline: str | None = None,
|
|
118
|
+
) -> Any:
|
|
119
|
+
"""Returns an I/O stream connected to this wrapped socket so makefile writes are inspected."""
|
|
120
|
+
return socket.socket.makefile( # type: ignore[call-overload]
|
|
121
|
+
self,
|
|
122
|
+
mode=mode,
|
|
123
|
+
buffering=buffering,
|
|
124
|
+
encoding=encoding,
|
|
125
|
+
errors=errors,
|
|
126
|
+
newline=newline,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def close(self) -> None:
|
|
130
|
+
self._sock.close()
|
|
131
|
+
|
|
132
|
+
def fileno(self) -> int:
|
|
133
|
+
return self._sock.fileno()
|
|
134
|
+
|
|
135
|
+
def __enter__(self) -> Any:
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, *args: object) -> Any:
|
|
139
|
+
if hasattr(self._sock, "__exit__"):
|
|
140
|
+
return self._sock.__exit__(*args)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def __getattr__(self, name: str) -> Any:
|
|
144
|
+
return getattr(self._sock, name)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_postgres_startup_packet(
|
|
148
|
+
buf: bytes | bytearray | memoryview,
|
|
149
|
+
) -> tuple[str, str, bool]:
|
|
150
|
+
"""Parses a buffer to extract the PostgreSQL v3 StartupMessage (user, database) pair.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
(user, database, complete):
|
|
154
|
+
complete is True if the packet was fully parsed (or rejected),
|
|
155
|
+
False if more bytes are needed (partial packet).
|
|
156
|
+
"""
|
|
157
|
+
data = bytes(buf)
|
|
158
|
+
# Step 1: Skip prepended SSLRequest (80877103) or GSSENCRequest (80877104) (can occur in succession).
|
|
159
|
+
while len(data) >= 8:
|
|
160
|
+
pkt_len = int.from_bytes(data[0:4], byteorder="big")
|
|
161
|
+
code = int.from_bytes(data[4:8], byteorder="big")
|
|
162
|
+
if pkt_len == 8 and (
|
|
163
|
+
code == PG_SSL_REQUEST_CODE or code == PG_GSS_ENC_REQUEST_CODE
|
|
164
|
+
):
|
|
165
|
+
data = data[8:]
|
|
166
|
+
else:
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
# Step 2: Wait for at least 8 bytes (4-byte packet length + 4-byte protocol code).
|
|
170
|
+
if len(data) < 8:
|
|
171
|
+
return "", "", False
|
|
172
|
+
|
|
173
|
+
pkt_len = int.from_bytes(data[0:4], byteorder="big")
|
|
174
|
+
code = int.from_bytes(data[4:8], byteorder="big")
|
|
175
|
+
|
|
176
|
+
# Step 3: Validate protocol version (v3.0) and packet length bounds.
|
|
177
|
+
if (
|
|
178
|
+
code != PG_PROTOCOL_VERSION_30
|
|
179
|
+
or pkt_len < 8
|
|
180
|
+
or pkt_len > MAX_PG_STARTUP_PACKET_LEN
|
|
181
|
+
):
|
|
182
|
+
return "", "", True
|
|
183
|
+
|
|
184
|
+
# Step 4: Wait until the complete packet body has arrived.
|
|
185
|
+
if len(data) < pkt_len:
|
|
186
|
+
return "", "", False
|
|
187
|
+
|
|
188
|
+
# Step 5: Decode null-terminated key-value pairs (user, database) from the payload.
|
|
189
|
+
payload = data[8:pkt_len]
|
|
190
|
+
user = ""
|
|
191
|
+
database = ""
|
|
192
|
+
while payload and payload[0] != 0:
|
|
193
|
+
try:
|
|
194
|
+
k_end = payload.index(0)
|
|
195
|
+
except ValueError:
|
|
196
|
+
break
|
|
197
|
+
key = payload[:k_end].decode("utf-8", errors="replace")
|
|
198
|
+
payload = payload[k_end + 1 :]
|
|
199
|
+
try:
|
|
200
|
+
v_end = payload.index(0)
|
|
201
|
+
except ValueError:
|
|
202
|
+
break
|
|
203
|
+
val = payload[:v_end].decode("utf-8", errors="replace")
|
|
204
|
+
payload = payload[v_end + 1 :]
|
|
205
|
+
|
|
206
|
+
if key == "user":
|
|
207
|
+
user = val
|
|
208
|
+
elif key == "database":
|
|
209
|
+
database = val
|
|
210
|
+
|
|
211
|
+
if user and not database:
|
|
212
|
+
database = user
|
|
213
|
+
|
|
214
|
+
# Step 6: Successfully extracted credentials.
|
|
215
|
+
return user, database, True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_postgres_startup_packet(user: str, database: str) -> bytes:
|
|
219
|
+
"""Builds a PostgreSQL v3.0 StartupMessage for (user, database)."""
|
|
220
|
+
if not database:
|
|
221
|
+
database = user
|
|
222
|
+
body = (
|
|
223
|
+
b"user\x00"
|
|
224
|
+
+ user.encode("utf-8")
|
|
225
|
+
+ b"\x00database\x00"
|
|
226
|
+
+ database.encode("utf-8")
|
|
227
|
+
+ b"\x00\x00"
|
|
228
|
+
)
|
|
229
|
+
total_len = 8 + len(body)
|
|
230
|
+
return total_len.to_bytes(4, byteorder="big") + b"\x00\x03\x00\x00" + body
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def build_terminate_packet() -> bytes:
|
|
234
|
+
"""Builds a PostgreSQL Terminate packet ('X' followed by int32 length 4)."""
|
|
235
|
+
return b"X\x00\x00\x00\x04"
|
|
@@ -12,20 +12,128 @@
|
|
|
12
12
|
# See the License for the specific language governing permissions and
|
|
13
13
|
# limitations under the License.
|
|
14
14
|
|
|
15
|
+
import contextlib
|
|
15
16
|
import logging
|
|
16
17
|
import os
|
|
18
|
+
import platform
|
|
17
19
|
import selectors
|
|
18
20
|
import socket
|
|
19
21
|
import ssl
|
|
20
22
|
import tempfile
|
|
21
23
|
import threading
|
|
22
|
-
from
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import Any, Self, TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from google.cloud.sql.connector.sqldata_client import SqlDataSocket
|
|
23
28
|
|
|
24
29
|
if TYPE_CHECKING:
|
|
25
30
|
import psycopg
|
|
26
31
|
|
|
27
32
|
logger = logging.getLogger(name=__name__)
|
|
28
33
|
|
|
34
|
+
_BUFFER_SIZE = 8192
|
|
35
|
+
_DEFAULT_PGSQL_PORT = 5432
|
|
36
|
+
_LOOPBACK_HOST = "127.0.0.1"
|
|
37
|
+
_UNIX_SOCKET_NAME = ".s.PGSQL.5432"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _close_socket(sock: socket.socket, *, shutdown: bool = True) -> None:
|
|
41
|
+
"""Safely shut down and close a socket, ignoring OSErrors."""
|
|
42
|
+
if shutdown:
|
|
43
|
+
try:
|
|
44
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
try:
|
|
48
|
+
sock.close()
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _forward_bytes(
|
|
54
|
+
src: socket.socket,
|
|
55
|
+
dst: socket.socket,
|
|
56
|
+
src_name: str,
|
|
57
|
+
dst_name: str,
|
|
58
|
+
*,
|
|
59
|
+
nonblocking: bool = False,
|
|
60
|
+
) -> bool:
|
|
61
|
+
"""Read a chunk of bytes from src and send it to dst.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
bool: True if bytes were forwarded, False on EOF or OSError.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
if nonblocking:
|
|
68
|
+
src.setblocking(False)
|
|
69
|
+
try:
|
|
70
|
+
data = src.recv(_BUFFER_SIZE)
|
|
71
|
+
finally:
|
|
72
|
+
if nonblocking:
|
|
73
|
+
with contextlib.suppress(OSError):
|
|
74
|
+
src.setblocking(True)
|
|
75
|
+
except OSError as e:
|
|
76
|
+
if nonblocking and isinstance(e, (ssl.SSLWantReadError, BlockingIOError)):
|
|
77
|
+
return True
|
|
78
|
+
logger.debug(f"Failed to receive from {src_name} socket: {e}")
|
|
79
|
+
return False
|
|
80
|
+
if not data:
|
|
81
|
+
logger.debug(f"EOF reached on {src_name} socket")
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
dst.sendall(data)
|
|
85
|
+
except OSError as e:
|
|
86
|
+
logger.debug(f"Failed to send to {dst_name} socket: {e}")
|
|
87
|
+
return False
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _forward_pending(local: socket.socket, remote: "ssl.SSLSocket") -> bool:
|
|
92
|
+
"""Read any pending decrypted data from SSL buffer and forward it to local.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
bool: True if EOF was reached or error occurred (should exit).
|
|
96
|
+
"""
|
|
97
|
+
if not hasattr(remote, "pending"):
|
|
98
|
+
return False
|
|
99
|
+
pending_bytes = remote.pending()
|
|
100
|
+
if not isinstance(pending_bytes, int):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
while pending_bytes > 0:
|
|
104
|
+
if not _forward_bytes(remote, local, "remote", "local"):
|
|
105
|
+
return True
|
|
106
|
+
try:
|
|
107
|
+
pending_bytes = remote.pending()
|
|
108
|
+
except OSError:
|
|
109
|
+
break
|
|
110
|
+
if not isinstance(pending_bytes, int):
|
|
111
|
+
break
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _forward_local_to_remote(local: socket.socket, remote: SqlDataSocket) -> None:
|
|
116
|
+
"""Forward bytes from local socket to remote SqlDataSocket until EOF or error."""
|
|
117
|
+
try:
|
|
118
|
+
while _forward_bytes(local, remote, "local", "remote"):
|
|
119
|
+
pass
|
|
120
|
+
finally:
|
|
121
|
+
_close_socket(remote, shutdown=False)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _proxy_sqldata(local: socket.socket, remote: SqlDataSocket) -> None:
|
|
125
|
+
"""Bidirectional thread-based proxy for queue-backed SqlDataSocket."""
|
|
126
|
+
remote.settimeout(None)
|
|
127
|
+
threading.Thread(
|
|
128
|
+
target=_forward_local_to_remote, args=(local, remote), daemon=True
|
|
129
|
+
).start()
|
|
130
|
+
try:
|
|
131
|
+
while _forward_bytes(remote, local, "remote", "local"):
|
|
132
|
+
pass
|
|
133
|
+
finally:
|
|
134
|
+
_close_socket(local)
|
|
135
|
+
_close_socket(remote, shutdown=False)
|
|
136
|
+
|
|
29
137
|
|
|
30
138
|
def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
|
|
31
139
|
"""Single-threaded selectors-based proxy to avoid SSLSocket thread-safety issues."""
|
|
@@ -33,104 +141,145 @@ def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
|
|
|
33
141
|
sel.register(local, selectors.EVENT_READ, data="local")
|
|
34
142
|
sel.register(remote, selectors.EVENT_READ, data="remote")
|
|
35
143
|
|
|
36
|
-
def forward_pending() -> bool:
|
|
37
|
-
"""Read any pending decrypted data from SSL buffer and forward it.
|
|
38
|
-
Returns True if EOF was reached or error occurred (should exit).
|
|
39
|
-
"""
|
|
40
|
-
if not hasattr(remote, "pending"):
|
|
41
|
-
return False
|
|
42
|
-
pending_bytes = remote.pending()
|
|
43
|
-
if not isinstance(pending_bytes, int):
|
|
44
|
-
return False
|
|
45
|
-
|
|
46
|
-
while pending_bytes > 0:
|
|
47
|
-
try:
|
|
48
|
-
data = remote.recv(8192)
|
|
49
|
-
except OSError as e:
|
|
50
|
-
logger.debug("psycopg proxy: remote recv pending error: %s", e)
|
|
51
|
-
return True
|
|
52
|
-
if not data:
|
|
53
|
-
logger.debug("psycopg proxy: remote pending EOF")
|
|
54
|
-
return True
|
|
55
|
-
try:
|
|
56
|
-
local.sendall(data)
|
|
57
|
-
except OSError as e:
|
|
58
|
-
logger.debug("psycopg proxy: local send pending error: %s", e)
|
|
59
|
-
return True
|
|
60
|
-
try:
|
|
61
|
-
pending_bytes = remote.pending()
|
|
62
|
-
except OSError:
|
|
63
|
-
break
|
|
64
|
-
if not isinstance(pending_bytes, int):
|
|
65
|
-
break
|
|
66
|
-
return False
|
|
67
|
-
|
|
68
144
|
try:
|
|
69
145
|
while True:
|
|
70
146
|
# First check if there is any pending data in SSL buffer
|
|
71
|
-
if
|
|
147
|
+
if _forward_pending(local, remote):
|
|
72
148
|
break
|
|
73
149
|
|
|
74
150
|
events = sel.select()
|
|
75
151
|
|
|
76
|
-
for key,
|
|
77
|
-
if key.data == "local"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return
|
|
86
|
-
try:
|
|
87
|
-
remote.sendall(data)
|
|
88
|
-
except OSError as e:
|
|
89
|
-
logger.debug("psycopg proxy: remote send error: %s", e)
|
|
90
|
-
return
|
|
91
|
-
elif key.data == "remote":
|
|
92
|
-
try:
|
|
93
|
-
data = remote.recv(8192)
|
|
94
|
-
except OSError as e:
|
|
95
|
-
logger.debug("psycopg proxy: remote recv error: %s", e)
|
|
96
|
-
return
|
|
97
|
-
if not data:
|
|
98
|
-
logger.debug("psycopg proxy: remote EOF")
|
|
99
|
-
return
|
|
100
|
-
try:
|
|
101
|
-
local.sendall(data)
|
|
102
|
-
except OSError as e:
|
|
103
|
-
logger.debug("psycopg proxy: local send error: %s", e)
|
|
104
|
-
return
|
|
152
|
+
for key, _ in events:
|
|
153
|
+
if key.data == "local" and not _forward_bytes(
|
|
154
|
+
local, remote, "local", "remote"
|
|
155
|
+
):
|
|
156
|
+
return
|
|
157
|
+
if key.data == "remote" and not _forward_bytes(
|
|
158
|
+
remote, local, "remote", "local", nonblocking=True
|
|
159
|
+
):
|
|
160
|
+
return
|
|
105
161
|
except OSError as e:
|
|
106
|
-
logger.debug("
|
|
162
|
+
logger.debug(f"Selector loop error in proxy: {e}")
|
|
107
163
|
finally:
|
|
108
164
|
sel.close()
|
|
109
|
-
|
|
165
|
+
_close_socket(local)
|
|
166
|
+
_close_socket(remote)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _PsycopgProxy:
|
|
170
|
+
"""Context manager that sets up a local listener socket and background proxy.
|
|
171
|
+
|
|
172
|
+
Encapsulates the platform-specific listener creation (Unix domain socket on
|
|
173
|
+
POSIX, TCP loopback on Windows), background connection acceptance, and
|
|
174
|
+
deterministic cleanup of temporary filesystem resources.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self, ip_address: str, remote_sock: "ssl.SSLSocket | SqlDataSocket"
|
|
179
|
+
) -> None:
|
|
180
|
+
self._ip_address = ip_address
|
|
181
|
+
self._remote_sock = remote_sock
|
|
182
|
+
self._tmpdir: str | None = None
|
|
183
|
+
self._socket_path: str | None = None
|
|
184
|
+
self._local_sock: socket.socket
|
|
185
|
+
self.host: str = ""
|
|
186
|
+
self.port: int = _DEFAULT_PGSQL_PORT
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> Self:
|
|
189
|
+
self._local_sock = self._create_local_socket()
|
|
190
|
+
threading.Thread(target=self._accept_and_proxy, daemon=True).start()
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(
|
|
194
|
+
self,
|
|
195
|
+
exc_type: type[BaseException] | None,
|
|
196
|
+
exc_val: BaseException | None,
|
|
197
|
+
exc_tb: TracebackType | None,
|
|
198
|
+
) -> None:
|
|
199
|
+
if exc_type is not None:
|
|
200
|
+
# psycopg never connected (or failed mid-handshake); close the server
|
|
201
|
+
# socket so the proxy thread unblocks and exits cleanly.
|
|
202
|
+
_close_socket(self._local_sock, shutdown=False)
|
|
203
|
+
_close_socket(self._remote_sock, shutdown=False)
|
|
204
|
+
self._cleanup_socket_files()
|
|
205
|
+
|
|
206
|
+
def _create_local_socket(self) -> socket.socket:
|
|
207
|
+
"""Determine the platform and create a bound local listener socket."""
|
|
208
|
+
if platform.system() == "Windows":
|
|
209
|
+
# On Windows, libpq does not support Unix domain sockets.
|
|
210
|
+
# Use a local TCP loopback socket on an ephemeral port.
|
|
211
|
+
local_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
212
|
+
local_sock.bind((_LOOPBACK_HOST, 0))
|
|
213
|
+
local_sock.listen(1)
|
|
214
|
+
self.host = _LOOPBACK_HOST
|
|
215
|
+
self.port = local_sock.getsockname()[1]
|
|
216
|
+
logger.debug(
|
|
217
|
+
f"['{self._ip_address}']: Created TCP loopback listener "
|
|
218
|
+
f"on {self.host}:{self.port}"
|
|
219
|
+
)
|
|
220
|
+
return local_sock
|
|
221
|
+
|
|
222
|
+
self._tmpdir = tempfile.mkdtemp()
|
|
223
|
+
self._socket_path = os.path.join(self._tmpdir, _UNIX_SOCKET_NAME)
|
|
224
|
+
logger.debug(
|
|
225
|
+
f"['{self._ip_address}']: Created Unix socket at {self._socket_path}"
|
|
226
|
+
)
|
|
227
|
+
local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
228
|
+
local_sock.bind(self._socket_path)
|
|
229
|
+
local_sock.listen(1)
|
|
230
|
+
self.host = self._tmpdir
|
|
231
|
+
self.port = _DEFAULT_PGSQL_PORT
|
|
232
|
+
return local_sock
|
|
233
|
+
|
|
234
|
+
def _accept_and_proxy(self) -> None:
|
|
235
|
+
"""Accept one connection then proxy bytes until the connection closes."""
|
|
236
|
+
local_conn: socket.socket | None = None
|
|
237
|
+
try:
|
|
238
|
+
local_conn, _ = self._local_sock.accept()
|
|
239
|
+
self._local_sock.close()
|
|
240
|
+
logger.debug(
|
|
241
|
+
f"['{self._ip_address}']: Accepted local connection, starting proxy"
|
|
242
|
+
)
|
|
243
|
+
if isinstance(self._remote_sock, SqlDataSocket):
|
|
244
|
+
_proxy_sqldata(local_conn, self._remote_sock)
|
|
245
|
+
else:
|
|
246
|
+
_proxy(local_conn, self._remote_sock)
|
|
247
|
+
except Exception as e: # noqa: BLE001
|
|
248
|
+
logger.debug(f"['{self._ip_address}']: Error in accept/proxy thread: {e}")
|
|
249
|
+
if local_conn is not None:
|
|
250
|
+
_close_socket(local_conn)
|
|
251
|
+
_close_socket(self._remote_sock)
|
|
252
|
+
|
|
253
|
+
def _cleanup_socket_files(self) -> None:
|
|
254
|
+
"""Remove temporary Unix socket file and directory if created."""
|
|
255
|
+
if self._socket_path:
|
|
110
256
|
try:
|
|
111
|
-
|
|
257
|
+
os.remove(self._socket_path)
|
|
112
258
|
except OSError:
|
|
113
259
|
pass
|
|
260
|
+
if self._tmpdir:
|
|
114
261
|
try:
|
|
115
|
-
|
|
262
|
+
os.rmdir(self._tmpdir)
|
|
116
263
|
except OSError:
|
|
117
264
|
pass
|
|
118
265
|
|
|
119
266
|
|
|
120
267
|
def connect(
|
|
121
|
-
ip_address: str,
|
|
268
|
+
ip_address: str,
|
|
269
|
+
remote_sock: "ssl.SSLSocket | SqlDataSocket",
|
|
270
|
+
**kwargs: Any,
|
|
122
271
|
) -> "psycopg.Connection":
|
|
123
272
|
"""Create a psycopg DBAPI connection object.
|
|
124
273
|
|
|
125
274
|
Because psycopg does not accept a pre-connected socket, this function
|
|
126
|
-
creates a
|
|
127
|
-
and runs a background proxy that forwards bytes
|
|
128
|
-
the already-established Cloud SQL
|
|
275
|
+
creates a local listener (Unix domain socket on Unix, TCP loopback on Windows),
|
|
276
|
+
tells psycopg to connect there, and runs a background proxy that forwards bytes
|
|
277
|
+
between that socket and the already-established Cloud SQL connection.
|
|
129
278
|
|
|
130
279
|
Args:
|
|
131
280
|
ip_address (str): IP address of the Cloud SQL instance.
|
|
132
|
-
remote_sock (ssl.SSLSocket):
|
|
133
|
-
Cloud SQL
|
|
281
|
+
remote_sock (ssl.SSLSocket | SqlDataSocket): Secure socket stream
|
|
282
|
+
connected to the Cloud SQL instance.
|
|
134
283
|
|
|
135
284
|
Returns:
|
|
136
285
|
psycopg.Connection: A psycopg Connection object for the Cloud SQL instance.
|
|
@@ -142,94 +291,33 @@ def connect(
|
|
|
142
291
|
'Unable to import module "psycopg." Please install and try again.'
|
|
143
292
|
)
|
|
144
293
|
|
|
145
|
-
if not hasattr(socket, "AF_UNIX"):
|
|
146
|
-
raise NotImplementedError(
|
|
147
|
-
"Unix domain sockets (AF_UNIX) are not supported on this platform"
|
|
148
|
-
)
|
|
149
|
-
|
|
150
|
-
tmpdir = tempfile.mkdtemp()
|
|
151
|
-
socket_path = os.path.join(tmpdir, ".s.PGSQL.5432")
|
|
152
|
-
logger.debug("psycopg: created Unix socket at %s", socket_path)
|
|
153
|
-
|
|
154
|
-
local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
155
|
-
local_sock.bind(socket_path)
|
|
156
|
-
local_sock.listen(1)
|
|
157
|
-
|
|
158
|
-
def _accept_and_proxy() -> None:
|
|
159
|
-
"""Accept one connection then proxy bytes until the connection closes."""
|
|
160
|
-
unix_conn = None
|
|
161
|
-
try:
|
|
162
|
-
unix_conn, _ = local_sock.accept()
|
|
163
|
-
local_sock.close()
|
|
164
|
-
logger.debug("psycopg proxy: accepted connection, starting proxy")
|
|
165
|
-
_proxy(unix_conn, remote_sock)
|
|
166
|
-
except Exception as e: # noqa: BLE001
|
|
167
|
-
logger.debug("psycopg proxy: error in accept/proxy thread: %s", e)
|
|
168
|
-
# Ensure cleanup on any exception
|
|
169
|
-
if unix_conn:
|
|
170
|
-
try:
|
|
171
|
-
unix_conn.shutdown(socket.SHUT_RDWR)
|
|
172
|
-
except OSError:
|
|
173
|
-
pass
|
|
174
|
-
try:
|
|
175
|
-
unix_conn.close()
|
|
176
|
-
except OSError:
|
|
177
|
-
pass
|
|
178
|
-
try:
|
|
179
|
-
remote_sock.shutdown(socket.SHUT_RDWR)
|
|
180
|
-
except OSError:
|
|
181
|
-
pass
|
|
182
|
-
try:
|
|
183
|
-
remote_sock.close()
|
|
184
|
-
except OSError:
|
|
185
|
-
pass
|
|
186
|
-
|
|
187
|
-
threading.Thread(target=_accept_and_proxy, daemon=True).start()
|
|
188
|
-
|
|
189
294
|
user = kwargs.pop("user")
|
|
190
295
|
db = kwargs.pop("db")
|
|
191
296
|
passwd = kwargs.pop("password", None)
|
|
192
297
|
# SSL is already handled by the underlying SSLSocket; disable it on the
|
|
193
|
-
#
|
|
298
|
+
# local socket so psycopg does not attempt a second TLS handshake.
|
|
194
299
|
kwargs.pop("sslmode", None)
|
|
195
300
|
timeout = kwargs.pop("timeout", None)
|
|
196
301
|
if timeout is not None:
|
|
197
302
|
kwargs["connect_timeout"] = int(timeout)
|
|
198
303
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
dbname=db,
|
|
204
|
-
password=passwd,
|
|
205
|
-
host=tmpdir,
|
|
206
|
-
port=5432,
|
|
207
|
-
sslmode="disable",
|
|
208
|
-
**kwargs,
|
|
304
|
+
with _PsycopgProxy(ip_address, remote_sock) as proxy:
|
|
305
|
+
logger.debug(
|
|
306
|
+
f"['{ip_address}']: Connecting as user={user} dbname={db} "
|
|
307
|
+
f"to {proxy.host}:{proxy.port}"
|
|
209
308
|
)
|
|
210
|
-
logger.debug("psycopg: connection established")
|
|
211
|
-
return conn
|
|
212
|
-
except Exception as e:
|
|
213
|
-
logger.debug("psycopg: connection failed: %s", e)
|
|
214
|
-
# psycopg never connected (or failed mid-handshake); close the server
|
|
215
|
-
# socket so the proxy thread unblocks and exits cleanly.
|
|
216
309
|
try:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
pass
|
|
232
|
-
try:
|
|
233
|
-
os.rmdir(tmpdir)
|
|
234
|
-
except OSError:
|
|
235
|
-
pass
|
|
310
|
+
conn = psycopg.connect(
|
|
311
|
+
user=user,
|
|
312
|
+
dbname=db,
|
|
313
|
+
password=passwd,
|
|
314
|
+
host=proxy.host,
|
|
315
|
+
port=proxy.port,
|
|
316
|
+
sslmode="disable",
|
|
317
|
+
**kwargs,
|
|
318
|
+
)
|
|
319
|
+
logger.debug(f"['{ip_address}']: Connection established")
|
|
320
|
+
return conn
|
|
321
|
+
except Exception as e:
|
|
322
|
+
logger.debug(f"['{ip_address}']: Connection failed: {e}")
|
|
323
|
+
raise
|