cloud-sql-python-connector 1.23.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloud-sql-python-connector
3
- Version: 1.23.0
3
+ Version: 1.24.0
4
4
  Summary: Google Cloud SQL Python Connector library
5
5
  Author-email: Google LLC <googleapis-packages@google.com>
6
6
  License-Expression: Apache-2.0
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Classifier: Operating System :: OS Independent
20
21
  Requires-Python: >=3.11
21
22
  Description-Content-Type: text/markdown
@@ -789,6 +790,84 @@ async def main():
789
790
  await pool.dispose()
790
791
  ```
791
792
 
793
+ ### Post-Quantum Cryptography (PQC)
794
+
795
+ The Cloud SQL Python Connector establishes mTLS connections over TLS 1.3 using
796
+ Python's standard `ssl` module without restricting key-exchange groups. When
797
+ Python's `ssl` module is linked against **OpenSSL 3.5 or later**, TLS 1.3
798
+ connections automatically offer and negotiate the hybrid post-quantum key
799
+ exchange group **`X25519MLKEM768`** (ML-KEM-768 combined with X25519) whenever
800
+ the Cloud SQL instance supports it. No code changes or connector configuration
801
+ are required.
802
+
803
+ #### Checking your OpenSSL version
804
+
805
+ Post-quantum TLS key exchange depends on the OpenSSL library linked into your
806
+ Python runtime's `ssl` module (not the Python language version or the
807
+ `cryptography` package version alone). Run the following command to check which
808
+ OpenSSL version your Python interpreter uses:
809
+
810
+ ```sh
811
+ python3 -c "import ssl; print(ssl.OPENSSL_VERSION)"
812
+ ```
813
+
814
+ - **OpenSSL 3.5.0 or higher** (for example, `python:3.13-trixie` / Debian 13
815
+ "Trixie" container images; check your runtime with the one-liner above):
816
+ `X25519MLKEM768` is enabled by default on TLS 1.3 connections.
817
+ - **OpenSSL earlier than 3.5.0** (for example, Ubuntu 24.04 LTS, which ships
818
+ system OpenSSL 3.0.x): TLS 1.3 connections continue to use classical elliptic
819
+ curve Diffie-Hellman (such as `X25519` or `P-256`).
820
+
821
+ > [!NOTE]
822
+ >
823
+ > Connections configured with `ip_type="sql_data"` (`IPTypes.SQL_DATA`) stream
824
+ > over gRPC (`grpcio`), which uses its bundled BoringSSL (supporting
825
+ > `X25519MLKEM768` independently of Python's `ssl` module and `OPENSSL_CONF`),
826
+ > whereas its direct-TLS fallback path uses `create_ssl_context()` and Python's
827
+ > linked OpenSSL library as described above.
828
+
829
+ #### Backwards compatibility
830
+
831
+ Hybrid post-quantum key exchange is backwards-compatible. On OpenSSL 3.5+,
832
+ Python's `ssl` module advertises both hybrid (`X25519MLKEM768`) and classical
833
+ (`X25519`, `secp256r1`, etc.) groups in the TLS 1.3 `ClientHello`. If either the
834
+ client runtime (OpenSSL `< 3.5`) or the Cloud SQL instance only supports
835
+ classical groups, the TLS 1.3 handshake automatically falls back to classical
836
+ ECDHE without error.
837
+
838
+ #### Opting out
839
+
840
+ If you need to disable post-quantum key exchange on an OpenSSL 3.5+ client (for
841
+ example, to troubleshoot a network middlebox sensitive to larger TLS
842
+ `ClientHello` records), configure OpenSSL's `Groups` list via an OpenSSL
843
+ configuration file (`OPENSSL_CONF`) to advertise only classical curves.
844
+
845
+ > [!WARNING]
846
+ >
847
+ > Setting `OPENSSL_CONF` replaces the system default OpenSSL configuration for
848
+ > the entire Python process. It affects **all** TLS connections made by the
849
+ > process (not just Cloud SQL connections), and starting from a minimal file
850
+ > omits distribution defaults (such as Debian's `MinProtocol` or
851
+ > `CipherString`). Copy your system's `openssl.cnf` (check `openssl version -d`,
852
+ > typically `/etc/ssl/openssl.cnf`) and add `Groups = X25519:P-256:P-384` under
853
+ > its `[system_default_sect]` section rather than using a bare file from
854
+ > scratch.
855
+
856
+ For example, after copying `/etc/ssl/openssl.cnf`, ensure its
857
+ `[system_default_sect]` (referenced via `ssl_conf` -> `system_default`) sets
858
+ `Groups`:
859
+
860
+ ```ini
861
+ [system_default_sect]
862
+ Groups = X25519:P-256:P-384
863
+ ```
864
+
865
+ Then point `OPENSSL_CONF` to your modified copy when starting your application:
866
+
867
+ ```sh
868
+ OPENSSL_CONF=/path/to/openssl.cnf python3 app.py
869
+ ```
870
+
792
871
  ### Debug Logging
793
872
 
794
873
  The Cloud SQL Python Connector uses the standard [Python logging module][python-logging]
@@ -1,16 +1,17 @@
1
- cloud_sql_python_connector-1.23.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
1
+ cloud_sql_python_connector-1.24.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
2
2
  google/cloud/sql/connector/__init__.py,sha256=pfa-FD-VRpYI49OUul9p6UFDp7G_yT2f6WS9rZMn8CM,1148
3
3
  google/cloud/sql/connector/asyncpg.py,sha256=MpZhwFke0WXvYD3OcEF1Ok0qHl-CU7ZIPy85PCog5QY,2002
4
4
  google/cloud/sql/connector/client.py,sha256=GfiZKDDBOxeA0yfqETQuaw3Nhg0BS71oQNZNxq3CooY,14991
5
- google/cloud/sql/connector/connection_info.py,sha256=lwhrOG01efvvvR2gXiesiZ1BrXJ4iP7geoRi0zI80do,5666
5
+ google/cloud/sql/connector/connection_info.py,sha256=NnHSHs1PZ_9WeFICkZ53gbA2hVvlWuzdBdhWizOI6hs,5770
6
6
  google/cloud/sql/connector/connection_name.py,sha256=SCCVeWb8YN0f9My09_l9FGTr6iRbi1b_52TJQaS6xzM,2574
7
- google/cloud/sql/connector/connector.py,sha256=at6WZJJCwQ3Ds8Ssd3ZF9bSJJYwN10Yxh-oK9UhkmFc,37843
7
+ google/cloud/sql/connector/connector.py,sha256=8A4uxTIGJKxnRgG2TqBHQwi4dG5KPO_1vNAQkNrR5vs,41574
8
8
  google/cloud/sql/connector/enums.py,sha256=F_mUdwscpW2fAzw4XElSy4BWkWcGQAkBOAUG8bvd3vg,3118
9
9
  google/cloud/sql/connector/exceptions.py,sha256=-QgsUq4gZaMrl3MH5KnsfJybEegAKW84-sdRmPKT-Dg,3137
10
- google/cloud/sql/connector/instance.py,sha256=Lz4kjwBdp3IVb6xpjPEJkawR7LS89XPLZSOt-IDxunI,8677
11
- google/cloud/sql/connector/lazy.py,sha256=MDJTAcB5iOqYYMewbzuX0RWh5PdIrY4RWpYfmFUYkr0,4918
12
- google/cloud/sql/connector/monitored_cache.py,sha256=5YAVjdiQaywZOqzQlvx9HkPLxA8tlV0HhQDJFsa1_qc,6674
10
+ google/cloud/sql/connector/instance.py,sha256=j3xj3ZuSpV7AcQz6_eYsS6F6bFPySKcGeFYfNOnIEKU,17269
11
+ google/cloud/sql/connector/lazy.py,sha256=9OOSfr13aX1OSA77V72Utp8M8VUQlwLloogRlUm0GDE,6746
12
+ google/cloud/sql/connector/monitored_cache.py,sha256=BarNBg-VsthW5bguGl3aq17IP4QaiF099W0AnrboujM,6919
13
13
  google/cloud/sql/connector/pg8000.py,sha256=tbqFSIoSguMX1hTZ4kWeBOKLhLHKX1Vq5SespfA0Y0w,1801
14
+ google/cloud/sql/connector/postgres_connection.py,sha256=NCUCZ_X6svNoJaqSClYDyqXaIArZYPQrPZu7wC-LtjU,7509
14
15
  google/cloud/sql/connector/psycopg.py,sha256=UD7OFVJ7C_PPsiIWzpbi1k3YIkqC4rM1pQS08wPeSM0,10950
15
16
  google/cloud/sql/connector/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
17
  google/cloud/sql/connector/pymysql.py,sha256=MOau37nTOTesUR5Hor7lLtEckvvGThzjECEWQYHcM8U,2166
@@ -18,10 +19,10 @@ google/cloud/sql/connector/pytds.py,sha256=e-KBCqv0m3a7cRi9YmFHNuYqClhlJbf-KoF61
18
19
  google/cloud/sql/connector/rate_limiter.py,sha256=60k-r9h-nUBqovVIHnIGbW0NMgUGD4exH_rZ02ukNa0,2924
19
20
  google/cloud/sql/connector/refresh_utils.py,sha256=Eghd-_uR5ndCGLsPgnyeUOoAvN7Tz4yzTlQFW4QWOjo,5228
20
21
  google/cloud/sql/connector/resolver.py,sha256=4U8G0Pxwmy64qoRmP5shLnmIykxcGcGCoFSH_7tr120,6353
21
- google/cloud/sql/connector/sqldata_client.py,sha256=gIgfgyYN5tBjYJxEWzvJkL67FCjhYxjg2Qs_2Z7QBhU,23862
22
- google/cloud/sql/connector/utils.py,sha256=ApLG4z4H0S4lWXQ9PyclXbrEoY5VrshqN9QB6BG8lyg,4132
23
- google/cloud/sql/connector/version.py,sha256=cbp_THzh62LsaoqJEz9Prs5EyZAWmeu6HB74bMAMqGY,598
24
- cloud_sql_python_connector-1.23.0.dist-info/METADATA,sha256=7dGxD8x9D5bsEnn9BmzbZtSN1mMGpAmcqj8w6EFPRjo,31777
25
- cloud_sql_python_connector-1.23.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
26
- cloud_sql_python_connector-1.23.0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
27
- cloud_sql_python_connector-1.23.0.dist-info/RECORD,,
22
+ google/cloud/sql/connector/sqldata_client.py,sha256=vov9R3xI_0LBxByMUIBDBsXZOE64cnUADp25Tp4Kvo0,23892
23
+ google/cloud/sql/connector/utils.py,sha256=bGsWj_SupSBmxcnsaMlmREP7hyLV7CcZ4g2_qHzHVoA,4119
24
+ google/cloud/sql/connector/version.py,sha256=juuKD5HYyccQin3zqup25QoyaBFJQnEtQgG7cgdeFIg,598
25
+ cloud_sql_python_connector-1.24.0.dist-info/METADATA,sha256=fLmn8G44gYU1v-gXeu3FvCGScL1xqipmX6lh3UfSB1w,35170
26
+ cloud_sql_python_connector-1.24.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
27
+ cloud_sql_python_connector-1.24.0.dist-info/top_level.txt,sha256=_1QvSJIhFAGfxb79D6DhB7SUw2X6T4rwnz_LLrbcD3c,7
28
+ cloud_sql_python_connector-1.24.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.0.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -46,6 +46,10 @@ class ConnectionInfoCache(abc.ABC):
46
46
  async def force_refresh(self) -> None:
47
47
  pass
48
48
 
49
+ @abc.abstractmethod
50
+ def record_principal(self, user: str, database: str) -> None:
51
+ pass
52
+
49
53
  @abc.abstractmethod
50
54
  async def close(self) -> None:
51
55
  pass
@@ -23,6 +23,7 @@ import logging
23
23
  import os
24
24
  import random
25
25
  import socket
26
+ import ssl
26
27
  from threading import Thread
27
28
  import time
28
29
  from types import TracebackType
@@ -38,6 +39,7 @@ from google.cloud.sql.connector import psycopg
38
39
  from google.cloud.sql.connector import pymysql
39
40
  from google.cloud.sql.connector import pytds
40
41
  from google.cloud.sql.connector.client import CloudSQLClient
42
+ from google.cloud.sql.connector.connection_info import ConnectionInfo
41
43
  from google.cloud.sql.connector.connection_name import ConnectionName
42
44
  from google.cloud.sql.connector.enums import DriverMapping
43
45
  from google.cloud.sql.connector.enums import IPTypes
@@ -49,6 +51,7 @@ from google.cloud.sql.connector.exceptions import ResourceExhaustedError
49
51
  from google.cloud.sql.connector.instance import RefreshAheadCache
50
52
  from google.cloud.sql.connector.lazy import LazyRefreshCache
51
53
  from google.cloud.sql.connector.monitored_cache import MonitoredCache
54
+ from google.cloud.sql.connector.postgres_connection import PostgresConnection
52
55
  from google.cloud.sql.connector.resolver import DefaultResolver
53
56
  from google.cloud.sql.connector.resolver import DnsResolver
54
57
  from google.cloud.sql.connector.sqldata_client import SqlDataClient
@@ -346,6 +349,7 @@ class Connector:
346
349
  self,
347
350
  conn_name: ConnectionName,
348
351
  enable_iam_auth: bool,
352
+ ip_type: IPTypes | None = None,
349
353
  ) -> MonitoredCache:
350
354
  assert self._client is not None, "client must be initialized before creating cache"
351
355
  assert self._keys is not None, "keys must be initialized before creating cache"
@@ -355,6 +359,13 @@ class Connector:
355
359
  ].closed:
356
360
  return self._cache[(str(conn_name), enable_iam_auth)]
357
361
 
362
+ effective_ip_type = ip_type if ip_type is not None else self._ip_type
363
+ dial_func = partial(
364
+ self._dial_probe_connection,
365
+ enable_iam_auth=enable_iam_auth,
366
+ ip_type=effective_ip_type,
367
+ )
368
+
358
369
  if self._refresh_strategy == RefreshStrategy.LAZY:
359
370
  logger.debug(
360
371
  f"['{conn_name}']: Refresh strategy is set to lazy refresh"
@@ -364,6 +375,9 @@ class Connector:
364
375
  self._client,
365
376
  self._keys,
366
377
  enable_iam_auth,
378
+ ip_type=effective_ip_type,
379
+ timeout=self._timeout,
380
+ dial_func=dial_func,
367
381
  )
368
382
  else:
369
383
  logger.debug(
@@ -374,6 +388,9 @@ class Connector:
374
388
  self._client,
375
389
  self._keys,
376
390
  enable_iam_auth,
391
+ ip_type=effective_ip_type,
392
+ timeout=self._timeout,
393
+ dial_func=dial_func,
377
394
  )
378
395
  # wrap cache as a MonitoredCache
379
396
  monitored_cache = MonitoredCache(
@@ -452,7 +469,9 @@ class Connector:
452
469
 
453
470
  conn_name = await self._resolver.resolve(instance_connection_string)
454
471
  if ip_type != IPTypes.SQL_DATA:
455
- monitored_cache = self._get_or_create_cache(conn_name, enable_iam_auth)
472
+ monitored_cache = self._get_or_create_cache(
473
+ conn_name, enable_iam_auth, ip_type
474
+ )
456
475
 
457
476
  connect_func = {
458
477
  "pymysql": pymysql.connect,
@@ -587,33 +606,7 @@ class Connector:
587
606
  await self._remove_cached(str(conn_name), enable_iam_auth)
588
607
  raise
589
608
 
590
- targets = []
591
- if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
592
- try:
593
- ips = await self._resolver.resolve_a_record(conn_info.conn_name.domain_name)
594
- if ips:
595
- targets.extend(ips)
596
- logger.debug(
597
- f"['{instance_connection_string}']: Custom DNS name "
598
- f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
599
- "using it to connect"
600
- )
601
- else:
602
- logger.debug(
603
- f"['{instance_connection_string}']: Custom DNS name "
604
- f"'{conn_info.conn_name.domain_name}' resolved but returned no "
605
- f"entries, using '{preferred_ips}' from instance metadata"
606
- )
607
- targets.extend(preferred_ips)
608
- except Exception as e: # noqa: BLE001
609
- logger.debug(
610
- f"['{instance_connection_string}']: Custom DNS name "
611
- f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
612
- f"address: {e}, using '{preferred_ips}' from instance metadata"
613
- )
614
- targets.extend(preferred_ips)
615
- else:
616
- targets.extend(preferred_ips)
609
+ targets = await self._resolve_targets(conn_info, preferred_ips)
617
610
  # format `user` param for automatic IAM database authn
618
611
  if enable_iam_auth:
619
612
  formatted_user = format_database_user(
@@ -636,14 +629,28 @@ class Connector:
636
629
  await conn_info.create_ssl_context(enable_iam_auth),
637
630
  **kwargs,
638
631
  )
632
+ if (
633
+ enable_iam_auth
634
+ and conn_info.database_version.startswith("POSTGRES")
635
+ ):
636
+ db_name = kwargs.get("database")
637
+ if db_name is None:
638
+ db_name = kwargs.get("db")
639
+ user = kwargs.get("user")
640
+ if user:
641
+ monitored_cache.record_principal(
642
+ str(user),
643
+ str(db_name) if db_name else str(user),
644
+ )
639
645
  last_ex = None
640
646
  return conn
641
647
 
642
648
  # Create socket with SSLContext for sync drivers
643
649
  ctx = await conn_info.create_ssl_context(enable_iam_auth)
644
650
  raw_sock = socket.create_connection((target_ip, SERVER_PROXY_PORT))
651
+ sync_sock: socket.socket | ssl.SSLSocket | PostgresConnection
645
652
  try:
646
- sock = ctx.wrap_socket(
653
+ sync_sock = ctx.wrap_socket(
647
654
  raw_sock,
648
655
  server_hostname=target_ip,
649
656
  )
@@ -651,15 +658,24 @@ class Connector:
651
658
  raw_sock.close()
652
659
  raise
653
660
 
661
+ if (
662
+ enable_iam_auth
663
+ and conn_info.database_version.startswith("POSTGRES")
664
+ ):
665
+ sync_sock = PostgresConnection(
666
+ sync_sock,
667
+ on_startup=monitored_cache.record_principal,
668
+ )
669
+
654
670
  # If this connection was opened using a domain name, then store it
655
671
  # for later in case we need to forcibly close it on failover.
656
672
  if conn_info.conn_name.domain_name:
657
- monitored_cache.sockets.append(sock)
673
+ monitored_cache.sockets.append(sync_sock)
658
674
  # Synchronous drivers are blocking and run using executor
659
675
  connect_partial = partial(
660
676
  connector,
661
677
  target_ip,
662
- sock,
678
+ sync_sock,
663
679
  **kwargs,
664
680
  )
665
681
  conn = await self._loop.run_in_executor(None, connect_partial)
@@ -670,7 +686,6 @@ class Connector:
670
686
  f"['{conn_info.conn_name}']: Connection to {target_ip} failed: {e}"
671
687
  )
672
688
  last_ex = e
673
-
674
689
  if last_ex:
675
690
  raise last_ex
676
691
 
@@ -694,6 +709,87 @@ class Connector:
694
709
  cache = self._cache.pop((instance_connection_string, enable_iam_auth))
695
710
  await cache.close()
696
711
 
712
+ async def _resolve_targets(
713
+ self,
714
+ conn_info: ConnectionInfo,
715
+ preferred_ips: list[str],
716
+ ) -> list[str]:
717
+ """Resolves target IP addresses for a Cloud SQL instance, with DNS fallback."""
718
+ targets: list[str] = []
719
+ if conn_info.conn_name.domain_name and isinstance(self._resolver, DnsResolver):
720
+ try:
721
+ ips = await self._resolver.resolve_a_record(
722
+ conn_info.conn_name.domain_name
723
+ )
724
+ if ips:
725
+ targets.extend(ips)
726
+ logger.debug(
727
+ f"['{conn_info.conn_name}']: Custom DNS name "
728
+ f"'{conn_info.conn_name.domain_name}' resolved to '{ips}', "
729
+ "using it to connect"
730
+ )
731
+ else:
732
+ logger.debug(
733
+ f"['{conn_info.conn_name}']: Custom DNS name "
734
+ f"'{conn_info.conn_name.domain_name}' resolved but returned no "
735
+ f"entries, using '{preferred_ips}' from instance metadata"
736
+ )
737
+ targets.extend(preferred_ips)
738
+ except Exception as e: # noqa: BLE001
739
+ logger.debug(
740
+ f"['{conn_info.conn_name}']: Custom DNS name "
741
+ f"'{conn_info.conn_name.domain_name}' did not resolve to an IP "
742
+ f"address: {e}, using '{preferred_ips}' from instance metadata"
743
+ )
744
+ targets.extend(preferred_ips)
745
+ else:
746
+ targets.extend(preferred_ips)
747
+ return targets
748
+
749
+ async def _dial_probe_connection(
750
+ self,
751
+ conn_info: ConnectionInfo,
752
+ enable_iam_auth: bool,
753
+ ip_type: IPTypes | None = None,
754
+ ) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
755
+ """Dials a TLS probe connection using the connector's IP resolution and SSLContext."""
756
+ effective_ip_type = ip_type if ip_type is not None else self._ip_type
757
+ preferred_ips = conn_info.get_preferred_ips(effective_ip_type)
758
+ targets = await self._resolve_targets(conn_info, preferred_ips)
759
+
760
+ if not targets:
761
+ raise ConnectionError(
762
+ f"No target IP addresses found for {conn_info.conn_name}"
763
+ )
764
+
765
+ ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
766
+ server_hostname = (
767
+ conn_info.conn_name.domain_name
768
+ if conn_info.conn_name.domain_name
769
+ else None
770
+ )
771
+
772
+ last_err: Exception | None = None
773
+ for target in targets:
774
+ try:
775
+ reader, writer = await asyncio.wait_for(
776
+ asyncio.open_connection(
777
+ host=target,
778
+ port=SERVER_PROXY_PORT,
779
+ ssl=ssl_context,
780
+ server_hostname=server_hostname,
781
+ ),
782
+ timeout=float(max(1, self._timeout)),
783
+ )
784
+ return reader, writer
785
+ except Exception as e: # noqa: BLE001
786
+ last_err = e
787
+ if last_err:
788
+ raise last_err
789
+ raise ConnectionError(
790
+ f"Failed to connect to any target for {conn_info.conn_name}"
791
+ )
792
+
697
793
  def __enter__(self) -> Any:
698
794
  """Enter context manager by returning Connector object"""
699
795
  return self
@@ -17,6 +17,8 @@ limitations under the License.
17
17
  from __future__ import annotations
18
18
 
19
19
  import asyncio
20
+ from collections.abc import Awaitable
21
+ from collections.abc import Callable
20
22
  from datetime import datetime
21
23
  from datetime import timedelta
22
24
  from datetime import UTC
@@ -26,7 +28,10 @@ from google.cloud.sql.connector.client import CloudSQLClient
26
28
  from google.cloud.sql.connector.connection_info import ConnectionInfo
27
29
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
28
30
  from google.cloud.sql.connector.connection_name import ConnectionName
31
+ from google.cloud.sql.connector.enums import IPTypes
29
32
  from google.cloud.sql.connector.exceptions import RefreshNotValidError
33
+ from google.cloud.sql.connector.postgres_connection import build_postgres_startup_packet
34
+ from google.cloud.sql.connector.postgres_connection import build_terminate_packet
30
35
  from google.cloud.sql.connector.rate_limiter import AsyncRateLimiter
31
36
  from google.cloud.sql.connector.refresh_utils import _is_valid
32
37
  from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
@@ -34,6 +39,8 @@ from google.cloud.sql.connector.refresh_utils import _seconds_until_refresh
34
39
  logger = logging.getLogger(name=__name__)
35
40
 
36
41
  APPLICATION_NAME = "cloud-sql-python-connector"
42
+ SERVER_PROXY_PORT = 3307
43
+ DEFAULT_CONNECT_TIMEOUT = 30
37
44
 
38
45
 
39
46
  class RefreshAheadCache(ConnectionInfoCache):
@@ -50,6 +57,15 @@ class RefreshAheadCache(ConnectionInfoCache):
50
57
  client: CloudSQLClient,
51
58
  keys: asyncio.Future,
52
59
  enable_iam_auth: bool = False,
60
+ ip_type: IPTypes | str = IPTypes.PUBLIC,
61
+ timeout: int = DEFAULT_CONNECT_TIMEOUT,
62
+ dial_func: (
63
+ Callable[
64
+ [ConnectionInfo],
65
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
66
+ ]
67
+ | None
68
+ ) = None,
53
69
  ) -> None:
54
70
  """Initializes a RefreshAheadCache instance.
55
71
 
@@ -62,10 +78,18 @@ class RefreshAheadCache(ConnectionInfoCache):
62
78
  enable_iam_auth (bool): Enables automatic IAM database authentication
63
79
  (Postgres and MySQL) as the default authentication method for all
64
80
  connections.
81
+ ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
82
+ timeout (int): Connect timeout in seconds.
83
+ dial_func: Optional callable to dial probe connections.
65
84
  """
66
85
  self._conn_name = conn_name
67
86
 
68
87
  self._enable_iam_auth = enable_iam_auth
88
+ if isinstance(ip_type, str):
89
+ ip_type = IPTypes._from_str(ip_type)
90
+ self._ip_type = ip_type
91
+ self._timeout = timeout
92
+ self._dial_func = dial_func
69
93
  self._keys = keys
70
94
  self._client = client
71
95
  self._refresh_rate_limiter = AsyncRateLimiter(
@@ -73,10 +97,15 @@ class RefreshAheadCache(ConnectionInfoCache):
73
97
  rate=1 / 30,
74
98
  )
75
99
  self._refresh_in_progress = asyncio.locks.Event()
100
+ self._iam_principals: list[tuple[str, str]] = []
76
101
  self._current: asyncio.Task = self._schedule_refresh(0)
77
102
  self._next: asyncio.Task = self._current
78
103
  self._closed = False
79
104
 
105
+ def record_principal(self, user: str, database: str) -> None:
106
+ """Records a PostgreSQL (user, database) pair observed on an IAM connection."""
107
+ _append_iam_principal(self._iam_principals, user, database)
108
+
80
109
  @property
81
110
  def conn_name(self) -> ConnectionName:
82
111
  return self._conn_name
@@ -119,6 +148,8 @@ class RefreshAheadCache(ConnectionInfoCache):
119
148
  self._keys,
120
149
  self._enable_iam_auth,
121
150
  )
151
+ if self._enable_iam_auth:
152
+ await self._probe_connection(connection_info)
122
153
  logger.debug(
123
154
  f"['{self._conn_name}']: Connection info refresh operation complete"
124
155
  )
@@ -138,6 +169,18 @@ class RefreshAheadCache(ConnectionInfoCache):
138
169
  self._refresh_in_progress.clear()
139
170
  return connection_info
140
171
 
172
+ async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
173
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
174
+ await _probe_instance_connection(
175
+ self._conn_name,
176
+ conn_info,
177
+ self._enable_iam_auth,
178
+ self._ip_type,
179
+ self._timeout,
180
+ self._iam_principals,
181
+ self._dial_func,
182
+ )
183
+
141
184
  def _schedule_refresh(self, delay: int) -> asyncio.Task:
142
185
  """
143
186
  Schedule task to sleep and then perform refresh to get ConnectionInfo.
@@ -223,3 +266,196 @@ class RefreshAheadCache(ConnectionInfoCache):
223
266
  tasks = asyncio.gather(self._current, self._next, return_exceptions=True)
224
267
  await asyncio.wait_for(tasks, timeout=2.0)
225
268
  self._closed = True
269
+
270
+
271
+ def _append_iam_principal(
272
+ principals: list[tuple[str, str]], user: str, database: str
273
+ ) -> None:
274
+ """Appends (user, database) to principals if not already present."""
275
+ if not user:
276
+ return
277
+ if not database:
278
+ database = user
279
+ pair = (user, database)
280
+ if pair not in principals:
281
+ principals.append(pair)
282
+
283
+
284
+ async def _probe_instance_connection(
285
+ conn_name: ConnectionName,
286
+ conn_info: ConnectionInfo,
287
+ enable_iam_auth: bool,
288
+ ip_type: IPTypes,
289
+ timeout: int,
290
+ iam_principals: list[tuple[str, str]],
291
+ dial_func: (
292
+ Callable[
293
+ [ConnectionInfo],
294
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
295
+ ]
296
+ | None
297
+ ) = None,
298
+ ) -> None:
299
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
300
+ if not enable_iam_auth:
301
+ return
302
+
303
+ # Proactive IAM refresh probe is PostgreSQL-only.
304
+ if not conn_info.database_version.startswith("POSTGRES"):
305
+ logger.debug(
306
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: instance engine '{conn_info.database_version}' is not PostgreSQL"
307
+ )
308
+ return
309
+
310
+ # Do not probe if no IAM principals have connected yet.
311
+ if not iam_principals:
312
+ logger.debug(
313
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: no IAM principals connected"
314
+ )
315
+ return
316
+
317
+ targets: list[str] = []
318
+ ssl_context = None
319
+ if dial_func is None:
320
+ if conn_name.domain_name:
321
+ targets.append(conn_name.domain_name)
322
+ if ip_type.value in conn_info.ip_addrs:
323
+ targets.extend(conn_info.ip_addrs[ip_type.value])
324
+
325
+ if not targets:
326
+ logger.debug(
327
+ f"['{conn_name}']: Proactive IAM token refresh probe skipped: no target IP addresses"
328
+ )
329
+ return
330
+
331
+ try:
332
+ ssl_context = await conn_info.create_ssl_context(enable_iam_auth)
333
+ except Exception as e: # noqa: BLE001
334
+ logger.debug(
335
+ f"['{conn_name}']: Failed to create SSL context for probe: {e!s}"
336
+ )
337
+ return
338
+
339
+ loop = asyncio.get_running_loop()
340
+ deadline = loop.time() + float(max(1, timeout))
341
+
342
+ all_succeeded = True
343
+ for user, database in list(iam_principals):
344
+ remaining = deadline - loop.time()
345
+ if remaining <= 0:
346
+ all_succeeded = False
347
+ break
348
+
349
+ probed_principal = False
350
+ if dial_func is not None:
351
+ try:
352
+ reader, writer = await asyncio.wait_for(
353
+ dial_func(conn_info),
354
+ timeout=remaining,
355
+ )
356
+ probed_principal = await _send_startup_probe(
357
+ reader,
358
+ writer,
359
+ user,
360
+ database,
361
+ max(0.0, deadline - loop.time()),
362
+ conn_name,
363
+ )
364
+ except Exception as e: # noqa: BLE001
365
+ logger.debug(
366
+ f"['{conn_name}']: Probing IAM token refresh for ({user}, {database}) failed: {e!s}"
367
+ )
368
+ else:
369
+ for target in targets:
370
+ remaining = deadline - loop.time()
371
+ if remaining <= 0:
372
+ break
373
+ try:
374
+ logger.debug(
375
+ f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT}"
376
+ )
377
+ reader, writer = await asyncio.wait_for(
378
+ asyncio.open_connection(
379
+ host=target,
380
+ port=SERVER_PROXY_PORT,
381
+ ssl=ssl_context,
382
+ server_hostname=(
383
+ conn_name.domain_name
384
+ if conn_name.domain_name
385
+ else None
386
+ ),
387
+ ),
388
+ timeout=remaining,
389
+ )
390
+ probed_principal = await _send_startup_probe(
391
+ reader,
392
+ writer,
393
+ user,
394
+ database,
395
+ max(0.0, deadline - loop.time()),
396
+ conn_name,
397
+ )
398
+ break
399
+ except Exception as e: # noqa: BLE001
400
+ logger.debug(
401
+ f"['{conn_name}']: Probing IAM token refresh on {target}:{SERVER_PROXY_PORT} failed: {e!s}"
402
+ )
403
+ if not probed_principal:
404
+ all_succeeded = False
405
+
406
+ if all_succeeded:
407
+ logger.debug(
408
+ f"['{conn_name}']: Proactive IAM token refresh probe successful"
409
+ )
410
+ else:
411
+ logger.debug(
412
+ f"['{conn_name}']: Proactive IAM token refresh probe encountered error across all targets"
413
+ )
414
+
415
+
416
+ async def _send_startup_probe(
417
+ reader: asyncio.StreamReader,
418
+ writer: asyncio.StreamWriter,
419
+ user: str,
420
+ database: str,
421
+ timeout: float,
422
+ conn_name: ConnectionName,
423
+ ) -> bool:
424
+ """Sends a PostgreSQL StartupMessage probe and gracefully terminates."""
425
+ loop = asyncio.get_running_loop()
426
+ deadline = loop.time() + max(0.0, float(timeout))
427
+ try:
428
+ remaining = deadline - loop.time()
429
+ if remaining <= 0:
430
+ return False
431
+ writer.write(build_postgres_startup_packet(user, database))
432
+ await asyncio.wait_for(writer.drain(), timeout=remaining)
433
+ await asyncio.wait_for(
434
+ reader.read(1024),
435
+ timeout=max(0.0, deadline - loop.time()),
436
+ )
437
+ writer.write(build_terminate_packet())
438
+ await asyncio.wait_for(
439
+ writer.drain(),
440
+ timeout=max(0.0, deadline - loop.time()),
441
+ )
442
+ eof_timeout = min(max(0.0, deadline - loop.time()), 1.0)
443
+ if eof_timeout > 0:
444
+ try:
445
+ await asyncio.wait_for(reader.read(1024), timeout=eof_timeout)
446
+ except Exception as e: # noqa: BLE001
447
+ logger.debug(
448
+ f"['{conn_name}']: Ignoring error while waiting for server EOF: {e!s}"
449
+ )
450
+ return True
451
+ finally:
452
+ writer.close()
453
+ try:
454
+ await asyncio.wait_for(writer.wait_closed(), timeout=2.0)
455
+ except Exception as e: # noqa: BLE001
456
+ logger.debug(
457
+ f"['{conn_name}']: Ignoring error while waiting for socket to close: {e!s}"
458
+ )
459
+ transport = getattr(writer, "transport", None)
460
+ if transport is not None and hasattr(transport, "abort"):
461
+ transport.abort()
@@ -15,6 +15,8 @@
15
15
  from __future__ import annotations
16
16
 
17
17
  import asyncio
18
+ from collections.abc import Awaitable
19
+ from collections.abc import Callable
18
20
  from datetime import datetime
19
21
  from datetime import timedelta
20
22
  from datetime import UTC
@@ -24,6 +26,10 @@ from google.cloud.sql.connector.client import CloudSQLClient
24
26
  from google.cloud.sql.connector.connection_info import ConnectionInfo
25
27
  from google.cloud.sql.connector.connection_info import ConnectionInfoCache
26
28
  from google.cloud.sql.connector.connection_name import ConnectionName
29
+ from google.cloud.sql.connector.enums import IPTypes
30
+ from google.cloud.sql.connector.instance import _append_iam_principal
31
+ from google.cloud.sql.connector.instance import _probe_instance_connection
32
+ from google.cloud.sql.connector.instance import DEFAULT_CONNECT_TIMEOUT
27
33
  from google.cloud.sql.connector.refresh_utils import _refresh_buffer
28
34
 
29
35
  logger = logging.getLogger(name=__name__)
@@ -44,6 +50,15 @@ class LazyRefreshCache(ConnectionInfoCache):
44
50
  client: CloudSQLClient,
45
51
  keys: asyncio.Future,
46
52
  enable_iam_auth: bool = False,
53
+ ip_type: IPTypes | str = IPTypes.PUBLIC,
54
+ timeout: int = DEFAULT_CONNECT_TIMEOUT,
55
+ dial_func: (
56
+ Callable[
57
+ [ConnectionInfo],
58
+ Awaitable[tuple[asyncio.StreamReader, asyncio.StreamWriter]],
59
+ ]
60
+ | None
61
+ ) = None,
47
62
  ) -> None:
48
63
  """Initializes a LazyRefreshCache instance.
49
64
 
@@ -56,16 +71,29 @@ class LazyRefreshCache(ConnectionInfoCache):
56
71
  enable_iam_auth (bool): Enables automatic IAM database authentication
57
72
  (Postgres and MySQL) as the default authentication method for all
58
73
  connections.
74
+ ip_type (IPTypes | str): Preferred IP type used to connect to the instance.
75
+ timeout (int): Connect timeout in seconds.
76
+ dial_func: Optional callable to dial probe connections.
59
77
  """
60
78
  self._conn_name = conn_name
61
79
  self._enable_iam_auth = enable_iam_auth
80
+ if isinstance(ip_type, str):
81
+ ip_type = IPTypes._from_str(ip_type)
82
+ self._ip_type = ip_type
83
+ self._timeout = timeout
84
+ self._dial_func = dial_func
62
85
  self._keys = keys
63
86
  self._client = client
64
87
  self._lock = asyncio.Lock()
88
+ self._iam_principals: list[tuple[str, str]] = []
65
89
  self._cached: ConnectionInfo | None = None
66
90
  self._needs_refresh = False
67
91
  self._closed = False
68
92
 
93
+ def record_principal(self, user: str, database: str) -> None:
94
+ """Records a PostgreSQL (user, database) pair observed on an IAM connection."""
95
+ _append_iam_principal(self._iam_principals, user, database)
96
+
69
97
  @property
70
98
  def conn_name(self) -> ConnectionName:
71
99
  return self._conn_name
@@ -82,6 +110,18 @@ class LazyRefreshCache(ConnectionInfoCache):
82
110
  async with self._lock:
83
111
  self._needs_refresh = True
84
112
 
113
+ async def _probe_connection(self, conn_info: ConnectionInfo) -> None:
114
+ """Proactively probes the database to refresh IAM tokens on server-side MCP."""
115
+ await _probe_instance_connection(
116
+ self._conn_name,
117
+ conn_info,
118
+ self._enable_iam_auth,
119
+ self._ip_type,
120
+ self._timeout,
121
+ self._iam_principals,
122
+ self._dial_func,
123
+ )
124
+
85
125
  async def connect_info(self) -> ConnectionInfo:
86
126
  """Retrieves ConnectionInfo instance for establishing a secure
87
127
  connection to the Cloud SQL instance.
@@ -126,7 +166,11 @@ class LazyRefreshCache(ConnectionInfoCache):
126
166
  )
127
167
  self._cached = conn_info
128
168
  self._needs_refresh = False
129
- return conn_info
169
+
170
+ if self._enable_iam_auth:
171
+ await self._probe_connection(conn_info)
172
+
173
+ return conn_info
130
174
 
131
175
  async def close(self) -> None:
132
176
  """Close is a no-op and provided purely for a consistent interface with
@@ -18,6 +18,7 @@ import asyncio
18
18
  from collections.abc import Callable
19
19
  import logging
20
20
  import socket
21
+ import ssl
21
22
  from typing import Any
22
23
 
23
24
  import aiohttp
@@ -28,6 +29,7 @@ from google.cloud.sql.connector.exceptions import CacheClosedError
28
29
  from google.cloud.sql.connector.exceptions import DnsLoopError
29
30
  from google.cloud.sql.connector.instance import RefreshAheadCache
30
31
  from google.cloud.sql.connector.lazy import LazyRefreshCache
32
+ from google.cloud.sql.connector.postgres_connection import PostgresConnection
31
33
  from google.cloud.sql.connector.resolver import DefaultResolver
32
34
  from google.cloud.sql.connector.resolver import DnsResolver
33
35
  from google.cloud.sql.connector.resolver import is_instance_dns_name
@@ -45,7 +47,7 @@ class MonitoredCache(ConnectionInfoCache):
45
47
  self.resolver = resolver
46
48
  self.cache = cache
47
49
  self.domain_name_ticker: asyncio.Task | None = None
48
- self.sockets: list[socket.socket] = []
50
+ self.sockets: list[socket.socket | ssl.SSLSocket | PostgresConnection] = []
49
51
 
50
52
  # If domain name is configured for instance and failover period is set,
51
53
  # poll for DNS record changes.
@@ -140,6 +142,9 @@ class MonitoredCache(ConnectionInfoCache):
140
142
  return
141
143
  return await self.cache.force_refresh()
142
144
 
145
+ def record_principal(self, user: str, database: str) -> None:
146
+ self.cache.record_principal(user, database)
147
+
143
148
  async def close(self) -> None:
144
149
  # Cancel domain name ticker task.
145
150
  if self.domain_name_ticker:
@@ -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"
@@ -30,7 +30,7 @@ from google.api_core.exceptions import ResourceExhausted
30
30
  from google.auth.credentials import Credentials
31
31
  import grpc
32
32
 
33
- from google.cloud import sqladmin_v1beta4
33
+ from google.cloud import sqladmin_v1beta4 # type: ignore[attr-defined]
34
34
  from google.cloud.sql.connector.enums import IPTypes
35
35
  from google.cloud.sql.connector.exceptions import CloudSQLIPTypeError
36
36
 
@@ -52,7 +52,7 @@ async def generate_keys() -> tuple[bytes, str]:
52
52
 
53
53
  priv_key = private_key_obj.private_bytes(
54
54
  encoding=serialization.Encoding.PEM,
55
- format=serialization.PrivateFormat.TraditionalOpenSSL,
55
+ format=serialization.PrivateFormat.PKCS8,
56
56
  encryption_algorithm=serialization.NoEncryption(),
57
57
  )
58
58
 
@@ -12,4 +12,4 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- __version__ = "1.23.0"
15
+ __version__ = "1.24.0"