Skip to content

Commit 8709178

Browse files
authored
refactor(psycopg): encapsulate local socket proxy in context manager (GoogleCloudPlatform#1493)
1 parent 04ca556 commit 8709178

1 file changed

Lines changed: 189 additions & 161 deletions

File tree

‎google/cloud/sql/connector/psycopg.py‎

Lines changed: 189 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -20,100 +20,204 @@
2020
import ssl
2121
import tempfile
2222
import threading
23-
from typing import Any, TYPE_CHECKING
23+
from types import TracebackType
24+
from typing import Any, Self, TYPE_CHECKING
2425

2526
if TYPE_CHECKING:
2627
import psycopg
2728

2829
logger = logging.getLogger(name=__name__)
2930

31+
_BUFFER_SIZE = 8192
32+
_DEFAULT_PGSQL_PORT = 5432
33+
_LOOPBACK_HOST = "127.0.0.1"
34+
_UNIX_SOCKET_NAME = ".s.PGSQL.5432"
35+
36+
37+
def _close_socket(sock: socket.socket, *, shutdown: bool = True) -> None:
38+
"""Safely shut down and close a socket, ignoring OSErrors."""
39+
if shutdown:
40+
try:
41+
sock.shutdown(socket.SHUT_RDWR)
42+
except OSError:
43+
pass
44+
try:
45+
sock.close()
46+
except OSError:
47+
pass
48+
49+
50+
def _forward_bytes(
51+
src: socket.socket,
52+
dst: socket.socket,
53+
src_name: str,
54+
dst_name: str,
55+
) -> bool:
56+
"""Read a chunk of bytes from src and send it to dst.
57+
58+
Returns:
59+
bool: True if bytes were forwarded, False on EOF or OSError.
60+
"""
61+
try:
62+
data = src.recv(_BUFFER_SIZE)
63+
except OSError as e:
64+
logger.debug(f"Failed to receive from {src_name} socket: {e}")
65+
return False
66+
if not data:
67+
logger.debug(f"EOF reached on {src_name} socket")
68+
return False
69+
try:
70+
dst.sendall(data)
71+
except OSError as e:
72+
logger.debug(f"Failed to send to {dst_name} socket: {e}")
73+
return False
74+
return True
75+
76+
77+
def _forward_pending(local: socket.socket, remote: "ssl.SSLSocket") -> bool:
78+
"""Read any pending decrypted data from SSL buffer and forward it to local.
79+
80+
Returns:
81+
bool: True if EOF was reached or error occurred (should exit).
82+
"""
83+
if not hasattr(remote, "pending"):
84+
return False
85+
pending_bytes = remote.pending()
86+
if not isinstance(pending_bytes, int):
87+
return False
88+
89+
while pending_bytes > 0:
90+
if not _forward_bytes(remote, local, "remote", "local"):
91+
return True
92+
try:
93+
pending_bytes = remote.pending()
94+
except OSError:
95+
break
96+
if not isinstance(pending_bytes, int):
97+
break
98+
return False
99+
30100

31101
def _proxy(local: socket.socket, remote: "ssl.SSLSocket") -> None:
32102
"""Single-threaded selectors-based proxy to avoid SSLSocket thread-safety issues."""
33103
sel = selectors.DefaultSelector()
34104
sel.register(local, selectors.EVENT_READ, data="local")
35105
sel.register(remote, selectors.EVENT_READ, data="remote")
36106

37-
def forward_pending() -> bool:
38-
"""Read any pending decrypted data from SSL buffer and forward it.
39-
Returns True if EOF was reached or error occurred (should exit).
40-
"""
41-
if not hasattr(remote, "pending"):
42-
return False
43-
pending_bytes = remote.pending()
44-
if not isinstance(pending_bytes, int):
45-
return False
46-
47-
while pending_bytes > 0:
48-
try:
49-
data = remote.recv(8192)
50-
except OSError as e:
51-
logger.debug("psycopg proxy: remote recv pending error: %s", e)
52-
return True
53-
if not data:
54-
logger.debug("psycopg proxy: remote pending EOF")
55-
return True
56-
try:
57-
local.sendall(data)
58-
except OSError as e:
59-
logger.debug("psycopg proxy: local send pending error: %s", e)
60-
return True
61-
try:
62-
pending_bytes = remote.pending()
63-
except OSError:
64-
break
65-
if not isinstance(pending_bytes, int):
66-
break
67-
return False
68-
69107
try:
70108
while True:
71109
# First check if there is any pending data in SSL buffer
72-
if forward_pending():
110+
if _forward_pending(local, remote):
73111
break
74112

75113
events = sel.select()
76114

77-
for key, mask in events:
78-
if key.data == "local":
79-
try:
80-
data = local.recv(8192)
81-
except OSError as e:
82-
logger.debug("psycopg proxy: local recv error: %s", e)
83-
return
84-
if not data:
85-
logger.debug("psycopg proxy: local EOF")
86-
return
87-
try:
88-
remote.sendall(data)
89-
except OSError as e:
90-
logger.debug("psycopg proxy: remote send error: %s", e)
91-
return
92-
elif key.data == "remote":
93-
try:
94-
data = remote.recv(8192)
95-
except OSError as e:
96-
logger.debug("psycopg proxy: remote recv error: %s", e)
97-
return
98-
if not data:
99-
logger.debug("psycopg proxy: remote EOF")
100-
return
101-
try:
102-
local.sendall(data)
103-
except OSError as e:
104-
logger.debug("psycopg proxy: local send error: %s", e)
105-
return
115+
for key, _ in events:
116+
if key.data == "local" and not _forward_bytes(
117+
local, remote, "local", "remote"
118+
):
119+
return
120+
if key.data == "remote" and not _forward_bytes(
121+
remote, local, "remote", "local"
122+
):
123+
return
106124
except OSError as e:
107-
logger.debug("psycopg proxy: OSError in loop: %s", e)
125+
logger.debug(f"Selector loop error in proxy: {e}")
108126
finally:
109127
sel.close()
110-
for s in (local, remote):
128+
_close_socket(local)
129+
_close_socket(remote)
130+
131+
132+
class _PsycopgProxy:
133+
"""Context manager that sets up a local listener socket and background proxy.
134+
135+
Encapsulates the platform-specific listener creation (Unix domain socket on
136+
POSIX, TCP loopback on Windows), background connection acceptance, and
137+
deterministic cleanup of temporary filesystem resources.
138+
"""
139+
140+
def __init__(self, ip_address: str, remote_sock: "ssl.SSLSocket") -> None:
141+
self._ip_address = ip_address
142+
self._remote_sock = remote_sock
143+
self._tmpdir: str | None = None
144+
self._socket_path: str | None = None
145+
self._local_sock: socket.socket
146+
self.host: str = ""
147+
self.port: int = _DEFAULT_PGSQL_PORT
148+
149+
def __enter__(self) -> Self:
150+
self._local_sock = self._create_local_socket()
151+
threading.Thread(target=self._accept_and_proxy, daemon=True).start()
152+
return self
153+
154+
def __exit__(
155+
self,
156+
exc_type: type[BaseException] | None,
157+
exc_val: BaseException | None,
158+
exc_tb: TracebackType | None,
159+
) -> None:
160+
if exc_type is not None:
161+
# psycopg never connected (or failed mid-handshake); close the server
162+
# socket so the proxy thread unblocks and exits cleanly.
163+
_close_socket(self._local_sock, shutdown=False)
164+
_close_socket(self._remote_sock, shutdown=False)
165+
self._cleanup_socket_files()
166+
167+
def _create_local_socket(self) -> socket.socket:
168+
"""Determine the platform and create a bound local listener socket."""
169+
if platform.system() == "Windows":
170+
# On Windows, libpq does not support Unix domain sockets.
171+
# Use a local TCP loopback socket on an ephemeral port.
172+
local_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
173+
local_sock.bind((_LOOPBACK_HOST, 0))
174+
local_sock.listen(1)
175+
self.host = _LOOPBACK_HOST
176+
self.port = local_sock.getsockname()[1]
177+
logger.debug(
178+
f"['{self._ip_address}']: Created TCP loopback listener "
179+
f"on {self.host}:{self.port}"
180+
)
181+
return local_sock
182+
183+
self._tmpdir = tempfile.mkdtemp()
184+
self._socket_path = os.path.join(self._tmpdir, _UNIX_SOCKET_NAME)
185+
logger.debug(
186+
f"['{self._ip_address}']: Created Unix socket at {self._socket_path}"
187+
)
188+
local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
189+
local_sock.bind(self._socket_path)
190+
local_sock.listen(1)
191+
self.host = self._tmpdir
192+
self.port = _DEFAULT_PGSQL_PORT
193+
return local_sock
194+
195+
def _accept_and_proxy(self) -> None:
196+
"""Accept one connection then proxy bytes until the connection closes."""
197+
local_conn: socket.socket | None = None
198+
try:
199+
local_conn, _ = self._local_sock.accept()
200+
self._local_sock.close()
201+
logger.debug(
202+
f"['{self._ip_address}']: Accepted local connection, starting proxy"
203+
)
204+
_proxy(local_conn, self._remote_sock)
205+
except Exception as e: # noqa: BLE001
206+
logger.debug(f"['{self._ip_address}']: Error in accept/proxy thread: {e}")
207+
if local_conn is not None:
208+
_close_socket(local_conn)
209+
_close_socket(self._remote_sock)
210+
211+
def _cleanup_socket_files(self) -> None:
212+
"""Remove temporary Unix socket file and directory if created."""
213+
if self._socket_path:
111214
try:
112-
s.shutdown(socket.SHUT_RDWR)
215+
os.remove(self._socket_path)
113216
except OSError:
114217
pass
218+
if self._tmpdir:
115219
try:
116-
s.close()
220+
os.rmdir(self._tmpdir)
117221
except OSError:
118222
pass
119223

@@ -143,61 +247,6 @@ def connect(
143247
'Unable to import module "psycopg." Please install and try again.'
144248
)
145249

146-
is_windows = platform.system() == "Windows"
147-
148-
if is_windows:
149-
# On Windows, libpq does not support Unix domain sockets.
150-
# Use a local TCP loopback socket on an ephemeral port.
151-
local_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
152-
local_sock.bind(("127.0.0.1", 0))
153-
local_sock.listen(1)
154-
host = "127.0.0.1"
155-
port = local_sock.getsockname()[1]
156-
tmpdir = None
157-
socket_path = None
158-
logger.debug("psycopg: created TCP loopback listener on 127.0.0.1:%d", port)
159-
else:
160-
tmpdir = tempfile.mkdtemp()
161-
socket_path = os.path.join(tmpdir, ".s.PGSQL.5432")
162-
logger.debug("psycopg: created Unix socket at %s", socket_path)
163-
164-
local_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
165-
local_sock.bind(socket_path)
166-
local_sock.listen(1)
167-
host = tmpdir
168-
port = 5432
169-
170-
def _accept_and_proxy() -> None:
171-
"""Accept one connection then proxy bytes until the connection closes."""
172-
local_conn = None
173-
try:
174-
local_conn, _ = local_sock.accept()
175-
local_sock.close()
176-
logger.debug("psycopg proxy: accepted connection, starting proxy")
177-
_proxy(local_conn, remote_sock)
178-
except Exception as e: # noqa: BLE001
179-
logger.debug("psycopg proxy: error in accept/proxy thread: %s", e)
180-
# Ensure cleanup on any exception
181-
if local_conn:
182-
try:
183-
local_conn.shutdown(socket.SHUT_RDWR)
184-
except OSError:
185-
pass
186-
try:
187-
local_conn.close()
188-
except OSError:
189-
pass
190-
try:
191-
remote_sock.shutdown(socket.SHUT_RDWR)
192-
except OSError:
193-
pass
194-
try:
195-
remote_sock.close()
196-
except OSError:
197-
pass
198-
199-
threading.Thread(target=_accept_and_proxy, daemon=True).start()
200-
201250
user = kwargs.pop("user")
202251
db = kwargs.pop("db")
203252
passwd = kwargs.pop("password", None)
@@ -208,44 +257,23 @@ def _accept_and_proxy() -> None:
208257
if timeout is not None:
209258
kwargs["connect_timeout"] = int(timeout)
210259

211-
logger.debug(
212-
"psycopg: connecting as user=%s dbname=%s to %s:%s", user, db, host, port
213-
)
214-
try:
215-
conn = psycopg.connect(
216-
user=user,
217-
dbname=db,
218-
password=passwd,
219-
host=host,
220-
port=port,
221-
sslmode="disable",
222-
**kwargs,
260+
with _PsycopgProxy(ip_address, remote_sock) as proxy:
261+
logger.debug(
262+
f"['{ip_address}']: Connecting as user={user} dbname={db} "
263+
f"to {proxy.host}:{proxy.port}"
223264
)
224-
logger.debug("psycopg: connection established")
225-
return conn
226-
except Exception as e:
227-
logger.debug("psycopg: connection failed: %s", e)
228-
# psycopg never connected (or failed mid-handshake); close the server
229-
# socket so the proxy thread unblocks and exits cleanly.
230265
try:
231-
local_sock.close()
232-
except OSError:
233-
pass
234-
try:
235-
remote_sock.close()
236-
except OSError:
237-
pass
238-
raise
239-
finally:
240-
# The socket file and its parent directory are only needed during the
241-
# initial connect() call; remove them now regardless of outcome.
242-
if socket_path:
243-
try:
244-
os.remove(socket_path)
245-
except OSError:
246-
pass
247-
if tmpdir:
248-
try:
249-
os.rmdir(tmpdir)
250-
except OSError:
251-
pass
266+
conn = psycopg.connect(
267+
user=user,
268+
dbname=db,
269+
password=passwd,
270+
host=proxy.host,
271+
port=proxy.port,
272+
sslmode="disable",
273+
**kwargs,
274+
)
275+
logger.debug(f"['{ip_address}']: Connection established")
276+
return conn
277+
except Exception as e:
278+
logger.debug(f"['{ip_address}']: Connection failed: {e}")
279+
raise

0 commit comments

Comments
 (0)