2020import ssl
2121import tempfile
2222import threading
23- from typing import Any , TYPE_CHECKING
23+ from types import TracebackType
24+ from typing import Any , Self , TYPE_CHECKING
2425
2526if TYPE_CHECKING :
2627 import psycopg
2728
2829logger = 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
31101def _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