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.
- {cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/METADATA +80 -1
- {cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/RECORD +14 -13
- {cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/WHEEL +1 -1
- google/cloud/sql/connector/connection_info.py +4 -0
- google/cloud/sql/connector/connector.py +128 -32
- google/cloud/sql/connector/instance.py +236 -0
- google/cloud/sql/connector/lazy.py +45 -1
- google/cloud/sql/connector/monitored_cache.py +6 -1
- google/cloud/sql/connector/postgres_connection.py +235 -0
- google/cloud/sql/connector/sqldata_client.py +1 -1
- google/cloud/sql/connector/utils.py +1 -1
- google/cloud/sql/connector/version.py +1 -1
- {cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/licenses/LICENSE +0 -0
- {cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/top_level.txt +0 -0
{cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloud-sql-python-connector
|
|
3
|
-
Version: 1.
|
|
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]
|
{cloud_sql_python_connector-1.23.0.dist-info → cloud_sql_python_connector-1.24.0.dist-info}/RECORD
RENAMED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
cloud_sql_python_connector-1.
|
|
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=
|
|
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=
|
|
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=
|
|
11
|
-
google/cloud/sql/connector/lazy.py,sha256=
|
|
12
|
-
google/cloud/sql/connector/monitored_cache.py,sha256=
|
|
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=
|
|
22
|
-
google/cloud/sql/connector/utils.py,sha256=
|
|
23
|
-
google/cloud/sql/connector/version.py,sha256=
|
|
24
|
-
cloud_sql_python_connector-1.
|
|
25
|
-
cloud_sql_python_connector-1.
|
|
26
|
-
cloud_sql_python_connector-1.
|
|
27
|
-
cloud_sql_python_connector-1.
|
|
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,,
|
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
55
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
56
56
|
encryption_algorithm=serialization.NoEncryption(),
|
|
57
57
|
)
|
|
58
58
|
|
|
File without changes
|
|
File without changes
|