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..b391c870 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"`` (requires ``python>=3.10``, see :ref:`configuration`).
+2. Extract the required CA certificate:
- .. 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).
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..e808e71f 100644
--- a/src/ansys/simai/core/api/mixin.py
+++ b/src/ansys/simai/core/api/mixin.py
@@ -22,6 +22,8 @@
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
@@ -29,12 +31,13 @@
from urllib.request import getproxies
import requests
+import requests.adapters
from requests.adapters import HTTPAdapter, Retry
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor
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
@@ -43,11 +46,28 @@
logger = logging.getLogger(__name__)
+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)
+ 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()
+ 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/src/ansys/simai/core/utils/configuration.py b/src/ansys/simai/core/utils/configuration.py
index 2a5166d3..0d1d073c 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,36 @@ 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] = None
+ "Name of the project to use by default."
credentials: Optional[Credentials] = Field(
default=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 (python >= 3.10)
+ * A ``PathLike`` object: use a custom CA
+ * ``"unsecure-none"``: no TLS certificate validation
+ """
@field_validator("url", mode="before")
def clean_url(cls, url):
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..83370bc8
--- /dev/null
+++ b/tests/test_client_config_tls_ca_bundle.py
@@ -0,0 +1,144 @@
+# Copyright (C) 2024 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_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
+ 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, daemon=True)
+ server_thread.start()
+ yield "https://localhost:48219"
+ httpd.shutdown()
+
+
+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_server)
+ # One can use REQUESTS_CA_BUNDLE
+ 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, 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
+ with pytest.raises(err.ConnectionError):
+ clt._api._get(https_server)
+
+ # 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_server, return_json=False)
+
+
+@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_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_server, return_json=False)