From 61ef1c68819456c1d3f7453feeb04fb78352cc8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Mon, 25 Nov 2024 18:16:35 +0100 Subject: [PATCH 1/8] config struct --- src/ansys/simai/core/utils/configuration.py | 56 +++++++++++---------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/ansys/simai/core/utils/configuration.py b/src/ansys/simai/core/utils/configuration.py index 2a5166d3..4468fffe 100644 --- a/src/ansys/simai/core/utils/configuration.py +++ b/src/ansys/simai/core/utils/configuration.py @@ -22,7 +22,8 @@ import hashlib import logging -from typing import Optional +from os import PathLike +from typing import Literal, Optional, Union from urllib.parse import urlparse, urlunparse from pydantic import ( @@ -79,36 +80,37 @@ def prompt(cls, values, info): class ClientConfig(BaseModel, extra="allow"): - interactive: Optional[bool] = Field( - default=True, description="If True, it enables interaction with the terminal." - ) - url: HttpUrl = Field( - default=HttpUrl("https://api.simai.ansys.com/v2/"), - description="URL to the SimAI API.", - ) - organization: str = Field( - default=None, - validate_default=True, - description="Name of the organization(/company) that the user belongs to.", + interactive: Optional[bool] = True + "If True, it enables interaction with the terminal." + url: HttpUrl = HttpUrl("https://api.simai.ansys.com/v2/") + "URL to the SimAI API." + organization: str = Field(None, validate_default=True) + "Name of the organization(/company) that the user belongs to." + workspace: Optional[str] = None + "Name of the workspace to use by default." + project: Optional[str] = Field( + None, description="Name of the project to use by default." ) credentials: Optional[Credentials] = Field( - default=None, + None, validate_default=True, - description="Authenticate via username/password instead of the device authorization code.", - ) - workspace: Optional[str] = Field( - default=None, description="Name of the workspace to use by default." - ) - project: Optional[str] = Field( - default=None, description="Name of the project to use by default." - ) - https_proxy: Optional[AnyHttpUrl] = Field( - default=None, description="URL of the HTTPS proxy to use." - ) - skip_version_check: bool = Field(default=False, description="Skip checking for updates.") - no_sse_connection: bool = Field( - default=False, description="Don't receive live updates from the SimAI API." ) + "Authenticate via username/password instead of the device authorization code." + skip_version_check: bool = False + "Skip checking for updates." + no_sse_connection: bool = False + "Don't receive live updates from the SimAI API." + https_proxy: Optional[AnyHttpUrl] = None + "URL of the HTTPS proxy to use." + tls_ca_bundle: Union[Literal["system", "unsecure-none"], PathLike, None] = None + """ + Custom TLS CA certificate configuration. Possible values: + + * ``None``: use secure defaults + * ``"system"``: uses system CA certificates + * A ``PathLike`` object: use a custom CA + * ``"unsecure-none"``: no TLS validation + """ @field_validator("url", mode="before") def clean_url(cls, url): From bda52e152e8c6806449af89308dcb431bd4aa6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Tue, 26 Nov 2024 14:50:32 +0100 Subject: [PATCH 2/8] add test & implement --- pdm.lock | 14 +++- pyproject.toml | 3 +- src/ansys/simai/core/api/mixin.py | 17 +++++ src/ansys/simai/core/utils/configuration.py | 11 ++- tests/test_client.py | 81 +++++++++++++++++++++ 5 files changed, 118 insertions(+), 8 deletions(-) diff --git a/pdm.lock b/pdm.lock index 02c8d18b..e1634b3d 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "doc", "linting", "test"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:f468459c4c870aaff4722a0c872be718af9d9b6dacb6dad5dae0134b0826abbf" +content_hash = "sha256:53567da81d0f9c7e5545bc0330d4ebe0ddffe60732da514f1ad5b227da1d97c1" [[metadata.targets]] requires_python = "~=3.9" @@ -1372,6 +1372,18 @@ files = [ {file = "tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a"}, ] +[[package]] +name = "truststore" +version = "0.10.0" +requires_python = ">=3.10" +summary = "Verify certificates using native system trust stores" +groups = ["default"] +marker = "python_version >= \"3.10\"" +files = [ + {file = "truststore-0.10.0-py3-none-any.whl", hash = "sha256:b3798548e421ffe2ca2a6217cca49e7a17baf40b72d86a5505dc7d701e77d15b"}, + {file = "truststore-0.10.0.tar.gz", hash = "sha256:5da347c665714fdfbd46f738c823fe9f0d8775e41ac5fb94f325749091187896"}, +] + [[package]] name = "typing-extensions" version = "4.12.2" diff --git a/pyproject.toml b/pyproject.toml index fd757999..9d05d358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,8 @@ dependencies = [ "wakepy>=0.8.0", "tqdm>=4.66.1", "filelock>=3.10.7", - "typing_extensions>=4.12.0" + "typing_extensions>=4.12.0", + "truststore>=0.10.0; python_version >= '3.10'", ] [tool.flit.module] diff --git a/src/ansys/simai/core/api/mixin.py b/src/ansys/simai/core/api/mixin.py index f1d2d155..cb27612c 100644 --- a/src/ansys/simai/core/api/mixin.py +++ b/src/ansys/simai/core/api/mixin.py @@ -22,6 +22,7 @@ import logging import os +import ssl from io import BytesIO from pathlib import Path from typing import Any, BinaryIO, Dict, List, Optional, Union @@ -29,6 +30,7 @@ from urllib.request import getproxies import requests +import requests.adapters from requests.adapters import HTTPAdapter, Retry from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor @@ -43,11 +45,26 @@ logger = logging.getLogger(__name__) +class TruststoreAdapter(HTTPAdapter): + def init_poolmanager(self, *a, **kw): + import truststore + + ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + return super().init_poolmanager(*a, **kw, ssl_context=ctx) + + class ApiClientMixin: """Provides the core that all mixins and the API client are built on.""" def __init__(self, *args, config: ClientConfig): # noqa: D107 self._session = requests.Session() + match config.tls_ca_bundle: + case "system": + self._session.mount("https://", TruststoreAdapter()) + case "unsecure-none": + self._session.verify = False + case os.PathLike(): + self._session.verify = str(config.tls_ca_bundle) retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504]) self._session.mount("http", HTTPAdapter(max_retries=retries)) diff --git a/src/ansys/simai/core/utils/configuration.py b/src/ansys/simai/core/utils/configuration.py index 4468fffe..0d1d073c 100644 --- a/src/ansys/simai/core/utils/configuration.py +++ b/src/ansys/simai/core/utils/configuration.py @@ -88,11 +88,10 @@ class ClientConfig(BaseModel, extra="allow"): "Name of the organization(/company) that the user belongs to." workspace: Optional[str] = None "Name of the workspace to use by default." - project: Optional[str] = Field( - None, description="Name of the project to use by default." - ) + project: Optional[str] = None + "Name of the project to use by default." credentials: Optional[Credentials] = Field( - None, + default=None, validate_default=True, ) "Authenticate via username/password instead of the device authorization code." @@ -107,9 +106,9 @@ class ClientConfig(BaseModel, extra="allow"): Custom TLS CA certificate configuration. Possible values: * ``None``: use secure defaults - * ``"system"``: uses system CA certificates + * ``"system"``: uses system CA certificates (python >= 3.10) * A ``PathLike`` object: use a custom CA - * ``"unsecure-none"``: no TLS validation + * ``"unsecure-none"``: no TLS certificate validation """ @field_validator("url", mode="before") diff --git a/tests/test_client.py b/tests/test_client.py index 3880fd21..7cc82e75 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,7 +20,14 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import contextlib +import os +import ssl +import subprocess +import threading +from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path +from unittest.mock import patch import pytest import responses @@ -70,3 +77,77 @@ def test_client_version_auto_warn(caplog, mocker, local_ver, latest_ver, expecte skip_version_check=False, ) assert f"A new version of ansys-simai-core is {expected}" in caplog.text + + +def test_https_server_with_custom_ca(tmp_path, mocker): + """Test HTTPS server with custom CA verification""" + # Create CA key and certificate + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "ca_key.pem", "-out", tmp_path / "test_ca.pem", "-days", "3650", "-subj", "/CN=Test CA"], check=False) # fmt: skip # noqa: S607, S603 + # Create server key and certificate + subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "server_key.pem", "-out", tmp_path / "server_csr.pem", "-subj", "/CN=localhost"], check=False) # fmt: skip # noqa: S607, S603 + with open(tmp_path / "openssl.cnf", "w") as f: + # Create a config file for OpenSSL to include the SAN + f.write("[SAN]\nsubjectAltName = DNS:localhost\n") + subprocess.run(["openssl", "x509", "-req", "-in", tmp_path / "server_csr.pem", "-CA", tmp_path / "test_ca.pem", "-CAkey", tmp_path / "ca_key.pem", "-CAcreateserial", "-out", tmp_path / "server_cert.pem", "-days", "3650", "-extensions", "SAN", "-extfile", tmp_path / "openssl.cnf"], check=False) # fmt: skip # noqa: S607, S603 + + # Spawn an HTTPS server + class SimpleHTTPSHandler(SimpleHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Hello, Secure World!") + + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain( + certfile=tmp_path / "server_cert.pem", keyfile=tmp_path / "server_key.pem" + ) + httpd = HTTPServer(("localhost", 48219), SimpleHTTPSHandler) + httpd.socket = ssl_context.wrap_socket( + httpd.socket, + server_side=True, + ) + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) + server_thread.start() + + # Test requests + clt_args = { + "url": "https://test.example.com", + "organization": "dummy", + "_disable_authentication": True, + "no_sse_connection": True, + "skip_version_check": True, + } + + ## Default config, using certifi's certificate store + clt = SimAIClient(**clt_args) + ### By default, the self-signed test CA is not accepted + with pytest.raises(err.ConnectionError): + clt._api._get("https://localhost:48219") + ### One can use REQUESTS_CA_BUNDLE + with patch.dict(os.environ, {"REQUESTS_CA_BUNDLE": str(tmp_path / "test_ca.pem")}): + clt._api._get("https://localhost:48219", return_json=False) + + ## Using the system CA + @contextlib.contextmanager + def load_test_ca_as_truststore_system_ca(ctx: "ssl.SSLContext"): + ctx.load_verify_locations(cafile=tmp_path / "test_ca.pem") + yield + + clt = SimAIClient(**clt_args, tls_ca_bundle="system") + ### The system CA rejects the test CA by default + with pytest.raises(err.ConnectionError): + clt._api._get("https://localhost:48219") + ### If the host system trusts the test CA, pysimai trusts it ! + with patch( + "truststore._api._configure_context", side_effect=load_test_ca_as_truststore_system_ca + ): + clt._api._get("https://localhost:48219", return_json=False) + + ## Disabling CA validation + clt = SimAIClient(**clt_args, tls_ca_bundle="unsecure-none") + clt._api._get("https://localhost:48219", return_json=False) + + ## Passing the path to the CA cert + clt = SimAIClient(**clt_args, tls_ca_bundle=tmp_path / "test_ca.pem") + clt._api._get("https://localhost:48219", return_json=False) From 96326dc2804df35042a5f9cb28e5c4970c4cb519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Tue, 26 Nov 2024 15:16:09 +0100 Subject: [PATCH 3/8] docs --- doc/source/user_guide/config_file.rst | 14 +------------- doc/source/user_guide/proxy.rst | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/doc/source/user_guide/config_file.rst b/doc/source/user_guide/config_file.rst index 16cd5271..63f5f278 100644 --- a/doc/source/user_guide/config_file.rst +++ b/doc/source/user_guide/config_file.rst @@ -52,7 +52,7 @@ Content You write the configuration file in `TOML `_. From this file, you can pass parameters for configuring -the :class:`~ansys.simai.core.client.SimAIClient` instance. +the :class:`~ansys.simai.core.client.SimAIClient` instance (see :ref:`configuration`). Example @@ -69,18 +69,6 @@ Example totp_enabled = true -Proxy -""""" - -If your network is situated behind a proxy, you must add its address -in a ``https_proxy`` key in the ``[default]`` block: - -.. code-block:: TOML - - [default] - organization = "company" - https_proxy = "http://company_proxy_host:3128" # replacing host and port by the real value - Profiles -------- diff --git a/doc/source/user_guide/proxy.rst b/doc/source/user_guide/proxy.rst index dc7a1f11..ab6d3603 100644 --- a/doc/source/user_guide/proxy.rst +++ b/doc/source/user_guide/proxy.rst @@ -32,17 +32,17 @@ Because your web browser uses a special `proxy auto-configuration `_ file, the proxy is not trusted by your computer. -To fix the issue: +There are multiple ways to fix this issue: -1. Extract the certificates used by your company-configured browser on ``https://simai.ansys.com``. -2. Set the ``REQUESTS_CA_BUNDLE`` environment variable: +1. Try ``tls_ca_bundle="system"`` (see :ref:`configuration`). +2. Extract the CA certificate from your web browser: - .. code:: python + a. Extract the certificates used by your company-configured browser on ``https://simai.ansys.com``. + b. Set ``tls_ca_bundle`` (or the ``REQUESTS_CA_BUNDLE`` environment variable): - import os - from pathlib import Path + .. code-block:: TOML - os.environ["REQUESTS_CA_BUNDLE"] = Path( - "~/Downloads/ansys-simai-chain.pem" - ).expanduser() - client = ansys.simai.core.from_config() + [default] + organization = "company" + tls_ca_bundle = "/home/username/Documents/my_company_proxy_ca_bundle.pem" +3. As a temporary last resort, one can use ``tls_ca_bundle="unsecure-none"`` (contact your IT department) From aee3b9f75b2528fab4b0824accd3ab3c2d2910c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Wed, 27 Nov 2024 15:30:56 +0100 Subject: [PATCH 4/8] split tests in separate file --- tests/test_client.py | 81 ------------- tests/test_client_config_tls_ca_bundle.py | 132 ++++++++++++++++++++++ 2 files changed, 132 insertions(+), 81 deletions(-) create mode 100644 tests/test_client_config_tls_ca_bundle.py diff --git a/tests/test_client.py b/tests/test_client.py index 7cc82e75..3880fd21 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,14 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -import contextlib -import os -import ssl -import subprocess -import threading -from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path -from unittest.mock import patch import pytest import responses @@ -77,77 +70,3 @@ def test_client_version_auto_warn(caplog, mocker, local_ver, latest_ver, expecte skip_version_check=False, ) assert f"A new version of ansys-simai-core is {expected}" in caplog.text - - -def test_https_server_with_custom_ca(tmp_path, mocker): - """Test HTTPS server with custom CA verification""" - # Create CA key and certificate - subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "ca_key.pem", "-out", tmp_path / "test_ca.pem", "-days", "3650", "-subj", "/CN=Test CA"], check=False) # fmt: skip # noqa: S607, S603 - # Create server key and certificate - subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "server_key.pem", "-out", tmp_path / "server_csr.pem", "-subj", "/CN=localhost"], check=False) # fmt: skip # noqa: S607, S603 - with open(tmp_path / "openssl.cnf", "w") as f: - # Create a config file for OpenSSL to include the SAN - f.write("[SAN]\nsubjectAltName = DNS:localhost\n") - subprocess.run(["openssl", "x509", "-req", "-in", tmp_path / "server_csr.pem", "-CA", tmp_path / "test_ca.pem", "-CAkey", tmp_path / "ca_key.pem", "-CAcreateserial", "-out", tmp_path / "server_cert.pem", "-days", "3650", "-extensions", "SAN", "-extfile", tmp_path / "openssl.cnf"], check=False) # fmt: skip # noqa: S607, S603 - - # Spawn an HTTPS server - class SimpleHTTPSHandler(SimpleHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header("Content-type", "text/plain") - self.end_headers() - self.wfile.write(b"Hello, Secure World!") - - ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - ssl_context.load_cert_chain( - certfile=tmp_path / "server_cert.pem", keyfile=tmp_path / "server_key.pem" - ) - httpd = HTTPServer(("localhost", 48219), SimpleHTTPSHandler) - httpd.socket = ssl_context.wrap_socket( - httpd.socket, - server_side=True, - ) - server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) - server_thread.start() - - # Test requests - clt_args = { - "url": "https://test.example.com", - "organization": "dummy", - "_disable_authentication": True, - "no_sse_connection": True, - "skip_version_check": True, - } - - ## Default config, using certifi's certificate store - clt = SimAIClient(**clt_args) - ### By default, the self-signed test CA is not accepted - with pytest.raises(err.ConnectionError): - clt._api._get("https://localhost:48219") - ### One can use REQUESTS_CA_BUNDLE - with patch.dict(os.environ, {"REQUESTS_CA_BUNDLE": str(tmp_path / "test_ca.pem")}): - clt._api._get("https://localhost:48219", return_json=False) - - ## Using the system CA - @contextlib.contextmanager - def load_test_ca_as_truststore_system_ca(ctx: "ssl.SSLContext"): - ctx.load_verify_locations(cafile=tmp_path / "test_ca.pem") - yield - - clt = SimAIClient(**clt_args, tls_ca_bundle="system") - ### The system CA rejects the test CA by default - with pytest.raises(err.ConnectionError): - clt._api._get("https://localhost:48219") - ### If the host system trusts the test CA, pysimai trusts it ! - with patch( - "truststore._api._configure_context", side_effect=load_test_ca_as_truststore_system_ca - ): - clt._api._get("https://localhost:48219", return_json=False) - - ## Disabling CA validation - clt = SimAIClient(**clt_args, tls_ca_bundle="unsecure-none") - clt._api._get("https://localhost:48219", return_json=False) - - ## Passing the path to the CA cert - clt = SimAIClient(**clt_args, tls_ca_bundle=tmp_path / "test_ca.pem") - clt._api._get("https://localhost:48219", return_json=False) diff --git a/tests/test_client_config_tls_ca_bundle.py b/tests/test_client_config_tls_ca_bundle.py new file mode 100644 index 00000000..a33322e3 --- /dev/null +++ b/tests/test_client_config_tls_ca_bundle.py @@ -0,0 +1,132 @@ +# Copyright (C) 2023 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import contextlib +import os +import ssl +import subprocess +import sys +import threading +from http.server import HTTPServer, SimpleHTTPRequestHandler +from unittest.mock import patch + +import pytest + +import ansys.simai.core.errors as err +from ansys.simai.core import SimAIClient + + +# +# SETUP +# +@pytest.fixture(scope="module") +def tls_root_certificate(tmp_path): + # Create CA key and certificate + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "ca_key.pem", "-out", tmp_path / "test_ca.pem", "-days", "3650", "-subj", "/CN=Test CA"], check=False) # fmt: skip # noqa: S607, S603 + # Create server key and certificate + subprocess.run(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "server_key.pem", "-out", tmp_path / "server_csr.pem", "-subj", "/CN=localhost"], check=False) # fmt: skip # noqa: S607, S603 + with open(tmp_path / "openssl.cnf", "w") as f: + # Create a config file for OpenSSL to include the SAN + f.write("[SAN]\nsubjectAltName = DNS:localhost\n") + subprocess.run(["openssl", "x509", "-req", "-in", tmp_path / "server_csr.pem", "-CA", tmp_path / "test_ca.pem", "-CAkey", tmp_path / "ca_key.pem", "-CAcreateserial", "-out", tmp_path / "server_cert.pem", "-days", "3650", "-extensions", "SAN", "-extfile", tmp_path / "openssl.cnf"], check=False) # fmt: skip # noqa: S607, S603 + + return { + "ca": tmp_path / "test_ca.pem", + "server_key": tmp_path / "server_key.pem", + "server_cert": tmp_path / "server_cert.pem", + } + + +@pytest.fixture(scope="module") +def https_server(tls_root_certificate): + class SimpleHTTPSHandler(SimpleHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-type", "text/plain") + self.end_headers() + self.wfile.write(b"Hello, Secure World!") + + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain( + certfile=tls_root_certificate["server_cert"], keyfile=tls_root_certificate["server_key"] + ) + httpd = HTTPServer(("localhost", 48219), SimpleHTTPSHandler) + httpd.socket = ssl_context.wrap_socket( + httpd.socket, + server_side=True, + ) + server_thread = threading.Thread(target=httpd.serve_forever) + server_thread.start() + + return "https://localhost:48219" + + +BASE_CLT_ARGS = { + "url": "https://test.example.com", + "organization": "dummy", + "_disable_authentication": True, + "no_sse_connection": True, + "skip_version_check": True, +} + + +# +# TESTS +# +def test_client_without_config_tls_ca_bundle(tls_root_certificate, https_server): + # Default config, using certifi's certificate store + clt = SimAIClient(**BASE_CLT_ARGS) + # By default, the self-signed test CA is not accepted + with pytest.raises(err.ConnectionError): + clt._api._get("https://localhost:48219") + # One can use REQUESTS_CA_BUNDLE + with patch.dict(os.environ, {"REQUESTS_CA_BUNDLE": tls_root_certificate["ca"]}): + clt._api._get("https://localhost:48219", return_json=False) + + +@pytest.mark.skipIf(sys.version_info < (3, 10), "Requires Python 3.10+") +def test_client_config_tls_ca_bundle_system(tls_root_certificate, https_server): + clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="system") + # The system CA rejects the test CA by default + with pytest.raises(err.ConnectionError): + clt._api._get("https://localhost:48219") + + # If the host system trusts the test CA, pysimai trusts it ! + @contextlib.contextmanager + def load_test_ca_as_truststore_system_ca(ctx: "ssl.SSLContext"): + ctx.load_verify_locations(cafile=tls_root_certificate["ca"]) + yield + + with patch( + "truststore._api._configure_context", side_effect=load_test_ca_as_truststore_system_ca + ): + clt._api._get("https://localhost:48219", return_json=False) + + +def test_client_config_tls_ca_bundle_unsecure_none(https_server): + clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="unsecure-none") + clt._api._get("https://localhost:48219", return_json=False) + + +def test_client_config_tls_ca_bundle_path(tls_root_certificate, https_server): + clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle=tls_root_certificate["ca"]) + clt._api._get("https://localhost:48219", return_json=False) From 48268e3e8b7e8e362b6d02491843bf6c71f9756f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Wed, 27 Nov 2024 15:36:06 +0100 Subject: [PATCH 5/8] better handle unsupported config on python39 --- src/ansys/simai/core/api/mixin.py | 6 +++++- tests/test_client_config_tls_ca_bundle.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ansys/simai/core/api/mixin.py b/src/ansys/simai/core/api/mixin.py index cb27612c..327d5f13 100644 --- a/src/ansys/simai/core/api/mixin.py +++ b/src/ansys/simai/core/api/mixin.py @@ -23,6 +23,7 @@ import logging import os import ssl +import sys from io import BytesIO from pathlib import Path from typing import Any, BinaryIO, Dict, List, Optional, Union @@ -36,7 +37,7 @@ from ansys.simai.core import __version__ from ansys.simai.core.data.types import APIResponse, File, MonitorCallback -from ansys.simai.core.errors import ConnectionError +from ansys.simai.core.errors import ConfigurationError, ConnectionError from ansys.simai.core.utils.auth import Authenticator from ansys.simai.core.utils.configuration import ClientConfig from ansys.simai.core.utils.files import file_path_to_obj_file @@ -47,6 +48,9 @@ class TruststoreAdapter(HTTPAdapter): def init_poolmanager(self, *a, **kw): + if sys.version_info < (3, 10): + raise ConfigurationError("The system CA store can only be used with python >= 3.10") + import truststore ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) diff --git a/tests/test_client_config_tls_ca_bundle.py b/tests/test_client_config_tls_ca_bundle.py index a33322e3..4f161a5e 100644 --- a/tests/test_client_config_tls_ca_bundle.py +++ b/tests/test_client_config_tls_ca_bundle.py @@ -122,6 +122,12 @@ def load_test_ca_as_truststore_system_ca(ctx: "ssl.SSLContext"): clt._api._get("https://localhost:48219", return_json=False) +@pytest.mark.skipIf(sys.version_info >= (3, 10), "Requires Python < 3.10") +def test_client_config_tls_ca_bundle_system_on_usupported_python_version(tls_root_certificate, https_server): + with pytest.raises(err.ConfigurationError, match="python >= 3.10"): + SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="system") + + def test_client_config_tls_ca_bundle_unsecure_none(https_server): clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="unsecure-none") clt._api._get("https://localhost:48219", return_json=False) From 8b507f3678e6b5841d43c11826f7e33abfd2f6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Wed, 27 Nov 2024 15:49:46 +0100 Subject: [PATCH 6/8] small fixes --- src/ansys/simai/core/api/mixin.py | 13 +++++----- tests/test_client_config_tls_ca_bundle.py | 31 +++++++++++++---------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/ansys/simai/core/api/mixin.py b/src/ansys/simai/core/api/mixin.py index 327d5f13..e808e71f 100644 --- a/src/ansys/simai/core/api/mixin.py +++ b/src/ansys/simai/core/api/mixin.py @@ -62,13 +62,12 @@ class ApiClientMixin: def __init__(self, *args, config: ClientConfig): # noqa: D107 self._session = requests.Session() - match config.tls_ca_bundle: - case "system": - self._session.mount("https://", TruststoreAdapter()) - case "unsecure-none": - self._session.verify = False - case os.PathLike(): - self._session.verify = str(config.tls_ca_bundle) + if config.tls_ca_bundle == "system": + self._session.mount("https://", TruststoreAdapter()) + elif config.tls_ca_bundle == "unsecure-none": + self._session.verify = False + elif isinstance(config.tls_ca_bundle, os.PathLike): + self._session.verify = str(config.tls_ca_bundle) retries = Retry(total=5, backoff_factor=1, status_forcelist=[502, 503, 504]) self._session.mount("http", HTTPAdapter(max_retries=retries)) diff --git a/tests/test_client_config_tls_ca_bundle.py b/tests/test_client_config_tls_ca_bundle.py index 4f161a5e..593efb7b 100644 --- a/tests/test_client_config_tls_ca_bundle.py +++ b/tests/test_client_config_tls_ca_bundle.py @@ -39,7 +39,8 @@ # SETUP # @pytest.fixture(scope="module") -def tls_root_certificate(tmp_path): +def tls_root_certificate(tmp_path_factory): + tmp_path = tmp_path_factory.mktemp("tls_root_certificate") # Create CA key and certificate subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", tmp_path / "ca_key.pem", "-out", tmp_path / "test_ca.pem", "-days", "3650", "-subj", "/CN=Test CA"], check=False) # fmt: skip # noqa: S607, S603 # Create server key and certificate @@ -74,10 +75,10 @@ def do_GET(self): httpd.socket, server_side=True, ) - server_thread = threading.Thread(target=httpd.serve_forever) + server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) server_thread.start() - - return "https://localhost:48219" + yield "https://localhost:48219" + httpd.shutdown() BASE_CLT_ARGS = { @@ -97,18 +98,18 @@ def test_client_without_config_tls_ca_bundle(tls_root_certificate, https_server) clt = SimAIClient(**BASE_CLT_ARGS) # By default, the self-signed test CA is not accepted with pytest.raises(err.ConnectionError): - clt._api._get("https://localhost:48219") + clt._api._get(https_server) # One can use REQUESTS_CA_BUNDLE - with patch.dict(os.environ, {"REQUESTS_CA_BUNDLE": tls_root_certificate["ca"]}): - clt._api._get("https://localhost:48219", return_json=False) + with patch.dict(os.environ, {"REQUESTS_CA_BUNDLE": str(tls_root_certificate["ca"])}): + clt._api._get(https_server, return_json=False) -@pytest.mark.skipIf(sys.version_info < (3, 10), "Requires Python 3.10+") +@pytest.mark.skipif(sys.version_info < (3, 10), reason='"system" requires Python >= 3.10') def test_client_config_tls_ca_bundle_system(tls_root_certificate, https_server): clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="system") # The system CA rejects the test CA by default with pytest.raises(err.ConnectionError): - clt._api._get("https://localhost:48219") + clt._api._get(https_server) # If the host system trusts the test CA, pysimai trusts it ! @contextlib.contextmanager @@ -119,20 +120,22 @@ def load_test_ca_as_truststore_system_ca(ctx: "ssl.SSLContext"): with patch( "truststore._api._configure_context", side_effect=load_test_ca_as_truststore_system_ca ): - clt._api._get("https://localhost:48219", return_json=False) + clt._api._get(https_server, return_json=False) -@pytest.mark.skipIf(sys.version_info >= (3, 10), "Requires Python < 3.10") -def test_client_config_tls_ca_bundle_system_on_usupported_python_version(tls_root_certificate, https_server): +@pytest.mark.skipif( + sys.version_info >= (3, 10), reason="The error is only raised for python < 3.10" +) +def test_client_config_tls_ca_bundle_system_on_usupported_python_version(): with pytest.raises(err.ConfigurationError, match="python >= 3.10"): SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="system") def test_client_config_tls_ca_bundle_unsecure_none(https_server): clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="unsecure-none") - clt._api._get("https://localhost:48219", return_json=False) + clt._api._get(https_server, return_json=False) def test_client_config_tls_ca_bundle_path(tls_root_certificate, https_server): clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle=tls_root_certificate["ca"]) - clt._api._get("https://localhost:48219", return_json=False) + clt._api._get(https_server, return_json=False) From 97f4ec1bee9dad6b25d8943b7ba65ab0019cc64e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Wed, 27 Nov 2024 16:45:06 +0100 Subject: [PATCH 7/8] mock.patch broekn in python 3.10 --- tests/test_client_config_tls_ca_bundle.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_client_config_tls_ca_bundle.py b/tests/test_client_config_tls_ca_bundle.py index 593efb7b..d0768d53 100644 --- a/tests/test_client_config_tls_ca_bundle.py +++ b/tests/test_client_config_tls_ca_bundle.py @@ -104,7 +104,10 @@ def test_client_without_config_tls_ca_bundle(tls_root_certificate, https_server) clt._api._get(https_server, return_json=False) -@pytest.mark.skipif(sys.version_info < (3, 10), reason='"system" requires Python >= 3.10') +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason='"system" requires Python >= 3.10, "patch" is broken in python 3.10', +) def test_client_config_tls_ca_bundle_system(tls_root_certificate, https_server): clt = SimAIClient(**BASE_CLT_ARGS, tls_ca_bundle="system") # The system CA rejects the test CA by default From 9aca5bad00053d10a55bb3a9775551548a26c1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arthur=20Woimb=C3=A9e?= Date: Thu, 28 Nov 2024 12:06:56 +0100 Subject: [PATCH 8/8] nits --- doc/source/user_guide/proxy.rst | 6 +++--- tests/test_client_config_tls_ca_bundle.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/source/user_guide/proxy.rst b/doc/source/user_guide/proxy.rst index ab6d3603..b391c870 100644 --- a/doc/source/user_guide/proxy.rst +++ b/doc/source/user_guide/proxy.rst @@ -34,8 +34,8 @@ proxy is not trusted by your computer. There are multiple ways to fix this issue: -1. Try ``tls_ca_bundle="system"`` (see :ref:`configuration`). -2. Extract the CA certificate from your web browser: +1. Try ``tls_ca_bundle="system"`` (requires ``python>=3.10``, see :ref:`configuration`). +2. Extract the required CA certificate: a. Extract the certificates used by your company-configured browser on ``https://simai.ansys.com``. b. Set ``tls_ca_bundle`` (or the ``REQUESTS_CA_BUNDLE`` environment variable): @@ -45,4 +45,4 @@ There are multiple ways to fix this issue: [default] organization = "company" tls_ca_bundle = "/home/username/Documents/my_company_proxy_ca_bundle.pem" -3. As a temporary last resort, one can use ``tls_ca_bundle="unsecure-none"`` (contact your IT department) +3. As a temporary last resort, one can use ``tls_ca_bundle="unsecure-none"`` (contact your IT department). diff --git a/tests/test_client_config_tls_ca_bundle.py b/tests/test_client_config_tls_ca_bundle.py index d0768d53..83370bc8 100644 --- a/tests/test_client_config_tls_ca_bundle.py +++ b/tests/test_client_config_tls_ca_bundle.py @@ -1,4 +1,4 @@ -# Copyright (C) 2023 ANSYS, Inc. and/or its affiliates. +# Copyright (C) 2024 ANSYS, Inc. and/or its affiliates. # SPDX-License-Identifier: MIT # #