diff --git a/CHANGELOG b/CHANGELOG index 625b63f65..e5a55abd0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,15 @@ # Change Log +## 2.2.50 21/10/2024 + +* Fix issue when pid file contains invalid data +* Add comment to indicate sentry-sdk is optional. Ref https://github.com/GNS3/gns3-server/issues/2423 +* Improve information provided when uploading invalid appliance image. Fixes #3637 +* Use "experimental features" option to force listening for HTTP notification streams. Ref #3579 +* Fix to allow packet capture on more than 6 links. Fixes #3594 +* Support for configuring MAC address in Docker containers +* Add KRDC to pre-configured VNC console commands + ## 2.2.49 06/08/2024 * Upgrade jsonschema and sentry-sdk packages diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 61e9697fb..000000000 --- a/appveyor.yml +++ /dev/null @@ -1,20 +0,0 @@ -version: '{build}-{branch}' - -image: Visual Studio 2022 - -platform: x64 - -environment: - PYTHON: "C:\\Python38-x64" - DISTUTILS_USE_SDK: "1" - -install: - - cinst nmap - - "%PYTHON%\\python.exe -m pip install -U pip setuptools" # upgrade pip & setuptools first - - "%PYTHON%\\python.exe -m pip install -r dev-requirements.txt" - - "%PYTHON%\\python.exe -m pip install -r win-requirements.txt" - -build: off - -test_script: - - "%PYTHON%\\python.exe -m pytest -v" diff --git a/gns3/controller.py b/gns3/controller.py index 5ea402ce3..a659488d1 100644 --- a/gns3/controller.py +++ b/gns3/controller.py @@ -25,6 +25,8 @@ from .symbol import Symbol from .local_server_config import LocalServerConfig from .settings import LOCAL_SERVER_SETTINGS + +from gns3.local_config import LocalConfig from gns3.utils import parse_version import logging @@ -416,19 +418,23 @@ def _startListenNotifications(self): self._notification_stream = None # Qt websocket before Qt 5.6 doesn't support auth - if parse_version(QtCore.QT_VERSION_STR) < parse_version("5.6.0") or parse_version(QtCore.PYQT_VERSION_STR) < parse_version("5.6.0"): + if parse_version(QtCore.QT_VERSION_STR) < parse_version("5.6.0") or parse_version(QtCore.PYQT_VERSION_STR) < parse_version("5.6.0") or LocalConfig.instance().experimental(): + self._notification_stream = Controller.instance().createHTTPQuery("GET", "/notifications", self._endListenNotificationCallback, downloadProgressCallback=self._event_received, networkManager=self._notification_network_manager, timeout=None, showProgress=False, ignoreErrors=True) + url = self._http_client.url() + '/notifications' + log.info("Listening for controller notifications on '{}'".format(url)) else: self._notification_stream = self._http_client.connectWebSocket(self._websocket, "/notifications/ws") self._notification_stream.textMessageReceived.connect(self._websocket_event_received) self._notification_stream.error.connect(self._websocket_error) self._notification_stream.sslErrors.connect(self._sslErrorsSlot) + log.info("Listening for controller notifications on '{}'".format(self._notification_stream.requestUrl().toString())) def stopListenNotifications(self): if self._notification_stream: diff --git a/gns3/crash_report.py b/gns3/crash_report.py index 49fac2206..f3bac1cd2 100644 --- a/gns3/crash_report.py +++ b/gns3/crash_report.py @@ -50,7 +50,7 @@ class CrashReport: Report crash to a third party service """ - DSN = "https://4cbe2abf0323ef3136a900d624b12567@o19455.ingest.us.sentry.io/38506" + DSN = "https://2f7ebda845810e764bfd049a40cc09e3@o19455.ingest.us.sentry.io/38506" _instance = None def __init__(self): diff --git a/gns3/dialogs/appliance_wizard.py b/gns3/dialogs/appliance_wizard.py index 8835887fb..aed9c61af 100644 --- a/gns3/dialogs/appliance_wizard.py +++ b/gns3/dialogs/appliance_wizard.py @@ -543,9 +543,19 @@ def _importPushButtonClickedSlot(self, *args): image = Image(self._appliance.template_type(), path, filename=disk["filename"]) try: if "md5sum" in disk and image.md5sum != disk["md5sum"]: - reply = QtWidgets.QMessageBox.question(self, "Add appliance", - "This is not the correct file. The MD5 sum is {} and should be {}.\nDo you want to accept it at your own risks?".format(image.md5sum, disk["md5sum"]), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + reply = QtWidgets.QMessageBox.question( + self, + "Add appliance", + "This is not the correct file.\n\n" + "MD5 checksum\n" + f"actual:\t{image.md5sum}\n" + f"expected:\t{disk['md5sum']}\n\n" + "File size\n" + f"actual:\t{image.filesize} bytes\n" + f"expected:\t{disk['filesize']} bytes\n\n" + "Do you want to accept it at your own risks?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No + ) if reply == QtWidgets.QMessageBox.No: return except OSError as e: diff --git a/gns3/link.py b/gns3/link.py index 4a9354ef0..6c761306c 100644 --- a/gns3/link.py +++ b/gns3/link.py @@ -23,7 +23,7 @@ from .qt import sip import uuid -from .qt import QtCore +from .qt import QtCore, QtNetwork from .controller import Controller @@ -78,6 +78,7 @@ def __init__(self, source_node, source_port, destination_node, destination_port, self._deleting = False self._capture_file_path = None self._capture_file = None + self._network_manager = None self._response_stream = None self._capture_compute_id = None self._initialized = False @@ -117,12 +118,15 @@ def _parseResponse(self, result): else: self._capture_file = QtCore.QFile(self._capture_file_path) self._capture_file.open(QtCore.QFile.WriteOnly) + if self._network_manager is None: + self._network_manager = QtNetwork.QNetworkAccessManager(self) self._response_stream = Controller.instance().get("/projects/{project_id}/links/{link_id}/pcap".format(project_id=self.project().id(), link_id=self._link_id), None, showProgress=False, downloadProgressCallback=self._downloadPcapProgress, ignoreErrors=True, # If something is wrong avoid disconnect us from server - timeout=None) + timeout=None, + networkManager=self._network_manager) log.debug("Has successfully started capturing packets on link {} to '{}'".format(self._link_id, self._capture_file_path)) else: self._response_stream = None diff --git a/gns3/local_config.py b/gns3/local_config.py index 8b9da10c5..9c5102013 100644 --- a/gns3/local_config.py +++ b/gns3/local_config.py @@ -483,7 +483,7 @@ def isMainGui(): if os.path.exists(pid_path): try: - with open(pid_path) as f: + with open(pid_path, encoding="utf-8") as f: pid = int(f.read()) if pid != my_pid: try: @@ -498,9 +498,17 @@ def isMainGui(): return False else: return True - except (OSError, ValueError) as e: + except OSError as e: log.critical("Can't read pid file %s: %s", pid_path, str(e)) return False + except ValueError as e: + log.warning("Invalid data in pid file %s: %s", pid_path, str(e)) + try: + # try removing the file since it contains invalid data + os.remove(pid_path) + except OSError: + log.critical("Can't remove pid file %s", pid_path) + return False try: with open(pid_path, 'w+') as f: diff --git a/gns3/modules/docker/docker_vm.py b/gns3/modules/docker/docker_vm.py index 525a55bd0..8b31b9e55 100644 --- a/gns3/modules/docker/docker_vm.py +++ b/gns3/modules/docker/docker_vm.py @@ -42,6 +42,7 @@ def __init__(self, module, server, project): docker_vm_settings = {"image": "", "usage": "", "adapters": DOCKER_CONTAINER_SETTINGS["adapters"], + "mac_address": DOCKER_CONTAINER_SETTINGS["mac_address"], "custom_adapters": DOCKER_CONTAINER_SETTINGS["custom_adapters"], "start_command": DOCKER_CONTAINER_SETTINGS["start_command"], "environment": DOCKER_CONTAINER_SETTINGS["environment"], @@ -88,6 +89,9 @@ def info(self): port_name=port.name(), port_description=port.description()) + if port.macAddress(): + port_info += " MAC address is {mac_address}\n".format(mac_address=port.macAddress()) + usage = "\n" + self._settings.get("usage") return info + port_info + usage diff --git a/gns3/modules/docker/pages/docker_vm_configuration_page.py b/gns3/modules/docker/pages/docker_vm_configuration_page.py index 76b29e619..9de063d3c 100644 --- a/gns3/modules/docker/pages/docker_vm_configuration_page.py +++ b/gns3/modules/docker/pages/docker_vm_configuration_page.py @@ -19,6 +19,8 @@ Configuration page for Docker images. """ +import re + from gns3.qt import QtWidgets from gns3.node import Node from gns3.dialogs.custom_adapters_configuration_dialog import CustomAdaptersConfigurationDialog @@ -69,15 +71,25 @@ def _customAdaptersConfigurationSlot(self): if self._node: adapters = self._settings["adapters"] + base_mac_address = self._settings["mac_address"] else: adapters = self.uiAdapterSpinBox.value() + mac = self.uiMacAddrLineEdit.text() + if mac != ":::::": + if not re.search(r"""^([0-9a-fA-F]{2}[:]){5}[0-9a-fA-F]{2}$""", mac): + QtWidgets.QMessageBox.critical(self, "MAC address", "Invalid MAC address (format required: hh:hh:hh:hh:hh:hh)") + return + else: + base_mac_address = mac + else: + base_mac_address = "" ports = [] for adapter_number in range(0, adapters): port_name = "eth{}".format(adapter_number) ports.append(port_name) - dialog = CustomAdaptersConfigurationDialog(ports, self._custom_adapters, parent=self) + dialog = CustomAdaptersConfigurationDialog(ports, self._custom_adapters, "TAP", {"TAP": "Default"}, base_mac_address, parent=self) dialog.show() dialog.exec_() @@ -150,6 +162,13 @@ def loadSettings(self, settings, node=None, group=False): self.uiSymbolLineEdit.hide() self.uiSymbolToolButton.hide() + # load the MAC address setting + self.uiMacAddrLineEdit.setInputMask("HH:HH:HH:HH:HH:HH;_") + if settings["mac_address"]: + self.uiMacAddrLineEdit.setText(settings["mac_address"]) + else: + self.uiMacAddrLineEdit.clear() + self.uiUsageTextEdit.setPlainText(settings["usage"]) def _networkConfigEditSlot(self): @@ -199,6 +218,18 @@ def saveSettings(self, settings, node=None, group=False): else: settings["name"] = name + # check and save the MAC address + mac = self.uiMacAddrLineEdit.text() + if mac != ":::::": + if not re.search(r"""^([0-9a-fA-F]{2}[:]){5}[0-9a-fA-F]{2}$""", mac): + QtWidgets.QMessageBox.critical(self, "MAC address", "Invalid MAC address (format required: hh:hh:hh:hh:hh:hh)") + if node: + raise ConfigurationError() + else: + settings["mac_address"] = mac + else: + settings["mac_address"] = None + if not node: # these are template settings settings["category"] = self.uiCategoryComboBox.itemData(self.uiCategoryComboBox.currentIndex()) diff --git a/gns3/modules/docker/settings.py b/gns3/modules/docker/settings.py index 0ae3c8ac2..255b5031f 100644 --- a/gns3/modules/docker/settings.py +++ b/gns3/modules/docker/settings.py @@ -35,6 +35,7 @@ "name": "", "image": "", "adapters": 1, + "mac_address": "", "custom_adapters": [], "environment": "", "console_type": "telnet", diff --git a/gns3/modules/docker/ui/docker_vm_configuration_page.ui b/gns3/modules/docker/ui/docker_vm_configuration_page.ui index 32f0ab347..251c4e530 100644 --- a/gns3/modules/docker/ui/docker_vm_configuration_page.ui +++ b/gns3/modules/docker/ui/docker_vm_configuration_page.ui @@ -6,8 +6,8 @@ 0 0 - 938 - 872 + 504 + 560 @@ -103,27 +103,37 @@ + + + Base MAC: + + + + + + + Custom adapters: - + &Configure custom adapters - + Console type: - + QLayout::SetNoConstraint @@ -166,14 +176,14 @@ - + VNC console resolution: - + @@ -227,14 +237,14 @@ - + HTTP port in the container: - + 1 @@ -244,17 +254,17 @@ - + HTTP path: - + - + Environment variables: @@ -268,17 +278,17 @@ - + - + Network configuration - + Edit diff --git a/gns3/modules/docker/ui/docker_vm_configuration_page_ui.py b/gns3/modules/docker/ui/docker_vm_configuration_page_ui.py index 772194205..55ab108b1 100644 --- a/gns3/modules/docker/ui/docker_vm_configuration_page_ui.py +++ b/gns3/modules/docker/ui/docker_vm_configuration_page_ui.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file '/home/grossmj/PycharmProjects/gns3-gui/gns3/modules/docker/ui/docker_vm_configuration_page.ui' # -# Created by: PyQt5 UI code generator 5.15.7 +# Created by: PyQt5 UI code generator 5.15.6 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -14,7 +14,7 @@ class Ui_dockerVMConfigPageWidget(object): def setupUi(self, dockerVMConfigPageWidget): dockerVMConfigPageWidget.setObjectName("dockerVMConfigPageWidget") - dockerVMConfigPageWidget.resize(938, 872) + dockerVMConfigPageWidget.resize(504, 560) self.verticalLayout = QtWidgets.QVBoxLayout(dockerVMConfigPageWidget) self.verticalLayout.setObjectName("verticalLayout") self.uiTabWidget = QtWidgets.QTabWidget(dockerVMConfigPageWidget) @@ -67,15 +67,21 @@ def setupUi(self, dockerVMConfigPageWidget): self.uiAdapterSpinBox.setMinimum(1) self.uiAdapterSpinBox.setObjectName("uiAdapterSpinBox") self.gridLayout.addWidget(self.uiAdapterSpinBox, 5, 1, 1, 1) + self.uiMacAddrLabel = QtWidgets.QLabel(self.tab) + self.uiMacAddrLabel.setObjectName("uiMacAddrLabel") + self.gridLayout.addWidget(self.uiMacAddrLabel, 6, 0, 1, 1) + self.uiMacAddrLineEdit = QtWidgets.QLineEdit(self.tab) + self.uiMacAddrLineEdit.setObjectName("uiMacAddrLineEdit") + self.gridLayout.addWidget(self.uiMacAddrLineEdit, 6, 1, 1, 1) self.uiCustomAdaptersLabel = QtWidgets.QLabel(self.tab) self.uiCustomAdaptersLabel.setObjectName("uiCustomAdaptersLabel") - self.gridLayout.addWidget(self.uiCustomAdaptersLabel, 6, 0, 1, 1) + self.gridLayout.addWidget(self.uiCustomAdaptersLabel, 7, 0, 1, 1) self.uiCustomAdaptersConfigurationPushButton = QtWidgets.QPushButton(self.tab) self.uiCustomAdaptersConfigurationPushButton.setObjectName("uiCustomAdaptersConfigurationPushButton") - self.gridLayout.addWidget(self.uiCustomAdaptersConfigurationPushButton, 6, 1, 1, 1) + self.gridLayout.addWidget(self.uiCustomAdaptersConfigurationPushButton, 7, 1, 1, 1) self.uiConsoleTypeLabel = QtWidgets.QLabel(self.tab) self.uiConsoleTypeLabel.setObjectName("uiConsoleTypeLabel") - self.gridLayout.addWidget(self.uiConsoleTypeLabel, 7, 0, 1, 1) + self.gridLayout.addWidget(self.uiConsoleTypeLabel, 8, 0, 1, 1) self.horizontalLayout = QtWidgets.QHBoxLayout() self.horizontalLayout.setSizeConstraint(QtWidgets.QLayout.SetNoConstraint) self.horizontalLayout.setObjectName("horizontalLayout") @@ -90,10 +96,10 @@ def setupUi(self, dockerVMConfigPageWidget): self.uiConsoleAutoStartCheckBox = QtWidgets.QCheckBox(self.tab) self.uiConsoleAutoStartCheckBox.setObjectName("uiConsoleAutoStartCheckBox") self.horizontalLayout.addWidget(self.uiConsoleAutoStartCheckBox) - self.gridLayout.addLayout(self.horizontalLayout, 7, 1, 1, 1) + self.gridLayout.addLayout(self.horizontalLayout, 8, 1, 1, 1) self.uiConsoleResolutionLabel = QtWidgets.QLabel(self.tab) self.uiConsoleResolutionLabel.setObjectName("uiConsoleResolutionLabel") - self.gridLayout.addWidget(self.uiConsoleResolutionLabel, 8, 0, 1, 1) + self.gridLayout.addWidget(self.uiConsoleResolutionLabel, 9, 0, 1, 1) self.uiConsoleResolutionComboBox = QtWidgets.QComboBox(self.tab) self.uiConsoleResolutionComboBox.setObjectName("uiConsoleResolutionComboBox") self.uiConsoleResolutionComboBox.addItem("") @@ -106,35 +112,35 @@ def setupUi(self, dockerVMConfigPageWidget): self.uiConsoleResolutionComboBox.addItem("") self.uiConsoleResolutionComboBox.addItem("") self.uiConsoleResolutionComboBox.addItem("") - self.gridLayout.addWidget(self.uiConsoleResolutionComboBox, 8, 1, 1, 1) + self.gridLayout.addWidget(self.uiConsoleResolutionComboBox, 9, 1, 1, 1) self.label = QtWidgets.QLabel(self.tab) self.label.setObjectName("label") - self.gridLayout.addWidget(self.label, 9, 0, 1, 1) + self.gridLayout.addWidget(self.label, 10, 0, 1, 1) self.uiConsoleHttpPortSpinBox = QtWidgets.QSpinBox(self.tab) self.uiConsoleHttpPortSpinBox.setMinimum(1) self.uiConsoleHttpPortSpinBox.setMaximum(65535) self.uiConsoleHttpPortSpinBox.setObjectName("uiConsoleHttpPortSpinBox") - self.gridLayout.addWidget(self.uiConsoleHttpPortSpinBox, 9, 1, 1, 1) + self.gridLayout.addWidget(self.uiConsoleHttpPortSpinBox, 10, 1, 1, 1) self.label_2 = QtWidgets.QLabel(self.tab) self.label_2.setObjectName("label_2") - self.gridLayout.addWidget(self.label_2, 10, 0, 1, 1) + self.gridLayout.addWidget(self.label_2, 11, 0, 1, 1) self.uiHttpConsolePathLineEdit = QtWidgets.QLineEdit(self.tab) self.uiHttpConsolePathLineEdit.setObjectName("uiHttpConsolePathLineEdit") - self.gridLayout.addWidget(self.uiHttpConsolePathLineEdit, 10, 1, 1, 1) + self.gridLayout.addWidget(self.uiHttpConsolePathLineEdit, 11, 1, 1, 1) self.uiEnvironmentLabel = QtWidgets.QLabel(self.tab) self.uiEnvironmentLabel.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter) self.uiEnvironmentLabel.setWordWrap(False) self.uiEnvironmentLabel.setObjectName("uiEnvironmentLabel") - self.gridLayout.addWidget(self.uiEnvironmentLabel, 11, 0, 1, 1) + self.gridLayout.addWidget(self.uiEnvironmentLabel, 12, 0, 1, 1) self.uiEnvironmentTextEdit = QtWidgets.QTextEdit(self.tab) self.uiEnvironmentTextEdit.setObjectName("uiEnvironmentTextEdit") - self.gridLayout.addWidget(self.uiEnvironmentTextEdit, 11, 1, 1, 1) + self.gridLayout.addWidget(self.uiEnvironmentTextEdit, 12, 1, 1, 1) self.uiNetworkConfigLabel = QtWidgets.QLabel(self.tab) self.uiNetworkConfigLabel.setObjectName("uiNetworkConfigLabel") - self.gridLayout.addWidget(self.uiNetworkConfigLabel, 12, 0, 1, 1) + self.gridLayout.addWidget(self.uiNetworkConfigLabel, 13, 0, 1, 1) self.uiNetworkConfigEditButton = QtWidgets.QPushButton(self.tab) self.uiNetworkConfigEditButton.setObjectName("uiNetworkConfigEditButton") - self.gridLayout.addWidget(self.uiNetworkConfigEditButton, 12, 1, 1, 1) + self.gridLayout.addWidget(self.uiNetworkConfigEditButton, 13, 1, 1, 1) self.uiTabWidget.addTab(self.tab, "") self.tab_2 = QtWidgets.QWidget() self.tab_2.setObjectName("tab_2") @@ -186,6 +192,7 @@ def retranslateUi(self, dockerVMConfigPageWidget): self.uiSymbolToolButton.setText(_translate("dockerVMConfigPageWidget", "&Browse...")) self.uiCMDLabel.setText(_translate("dockerVMConfigPageWidget", "Start command:")) self.uiAdapterLabel.setText(_translate("dockerVMConfigPageWidget", "Adapters:")) + self.uiMacAddrLabel.setText(_translate("dockerVMConfigPageWidget", "Base MAC:")) self.uiCustomAdaptersLabel.setText(_translate("dockerVMConfigPageWidget", "Custom adapters:")) self.uiCustomAdaptersConfigurationPushButton.setText(_translate("dockerVMConfigPageWidget", "&Configure custom adapters")) self.uiConsoleTypeLabel.setText(_translate("dockerVMConfigPageWidget", "Console type:")) diff --git a/gns3/project.py b/gns3/project.py index aa3f602e0..25030836c 100644 --- a/gns3/project.py +++ b/gns3/project.py @@ -627,21 +627,24 @@ def _startListenNotifications(self): return # Qt websocket before Qt 5.6 doesn't support auth - if parse_version(QtCore.QT_VERSION_STR) < parse_version("5.6.0") or parse_version(QtCore.PYQT_VERSION_STR) < parse_version("5.6.0"): + if parse_version(QtCore.QT_VERSION_STR) < parse_version("5.6.0") or parse_version(QtCore.PYQT_VERSION_STR) < parse_version("5.6.0") or LocalConfig.instance().experimental(): path = "/projects/{project_id}/notifications".format(project_id=self._id) self._notification_stream = Controller.instance().createHTTPQuery("GET", path, self._endListenNotificationCallback, - downloadProgressCallback=self._event_received, - networkManager=self._notification_network_manager, - timeout=None, - showProgress=False, - ignoreErrors=True) + downloadProgressCallback=self._event_received, + networkManager=self._notification_network_manager, + timeout=None, + showProgress=False, + ignoreErrors=True) + url = Controller.instance().getHttpClient().url() + path + log.info("Listening for project notifications on '{}'".format(url)) else: - path = "/projects/{project_id}/notifications/ws".format(project_id=self._id) - self._notification_stream = Controller.instance().httpClient().connectWebSocket(self._websocket, path) - self._notification_stream.textMessageReceived.connect(self._websocket_event_received) - self._notification_stream.error.connect(self._websocket_error) - self._notification_stream.sslErrors.connect(self._sslErrorsSlot) + path = "/projects/{project_id}/notifications/ws".format(project_id=self._id) + self._notification_stream = Controller.instance().httpClient().connectWebSocket(self._websocket, path) + self._notification_stream.textMessageReceived.connect(self._websocket_event_received) + self._notification_stream.error.connect(self._websocket_error) + self._notification_stream.sslErrors.connect(self._sslErrorsSlot) + log.info("Listening for project notifications on '{}'".format(self._notification_stream.requestUrl().toString())) def _endListenNotificationCallback(self, result, error=False, **kwargs): """ diff --git a/gns3/version.py b/gns3/version.py index 49bb63f8c..4272b47c0 100644 --- a/gns3/version.py +++ b/gns3/version.py @@ -23,8 +23,8 @@ # or negative for a release candidate or beta (after the base version # number has been incremented) -__version__ = "2.2.49" -__version_info__ = (2, 2, 49, 0) +__version__ = "2.2.50" +__version_info__ = (2, 2, 50, 0) if "dev" in __version__: try: diff --git a/requirements.txt b/requirements.txt index 4ef10c35a..71dd3a53e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ jsonschema>=4.23,<4.24 -sentry-sdk==2.12,<2.13 +sentry-sdk==2.12,<2.13 # optional dependency psutil==6.0.0 distro>=1.9.0 truststore>=0.9.1; python_version >= '3.10'