From 5c1a02694b8503e31c4a49456d3180a8ef3bf98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lle=C3=AFr=20Borr=C3=A0s=20Metje?= Date: Fri, 13 Feb 2026 09:00:07 +0100 Subject: [PATCH 1/4] Respect the Qgis SSL configuration on trusted certificates and trusted root CA configured in settings --- Mergin/plugin.py | 6 ++++ Mergin/utils_auth.py | 68 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 556e8a68..345cd5aa 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -55,6 +55,7 @@ set_qgsexpressionscontext, get_authcfg, AuthSync, + setup_qgis_ssl_for_mergin_client, ) from .mergin.merginproject import MerginProject @@ -64,6 +65,11 @@ MERGIN_CLIENT_LOG = os.path.join(QgsApplication.qgisSettingsDirPath(), "mergin-client-log.txt") os.environ["MERGIN_CLIENT_LOG"] = MERGIN_CLIENT_LOG +try: + setup_qgis_ssl_for_mergin_client() +except Exception: + pass + class MerginPlugin: def __init__(self, iface): diff --git a/Mergin/utils_auth.py b/Mergin/utils_auth.py index 89c26220..c6959ce1 100644 --- a/Mergin/utils_auth.py +++ b/Mergin/utils_auth.py @@ -4,12 +4,11 @@ import hashlib import os import re +import sys import typing import uuid import json from urllib.error import URLError -import requests -import urllib3 from enum import Enum from qgis.core import ( @@ -547,14 +546,65 @@ def mergin_server_deprecated_version(url: str) -> bool: def url_reachable(url: str) -> bool: try: - requests.get(url, timeout=3) - except ( - requests.RequestException, - urllib3.exceptions.LocationParseError, - UnicodeError, - ): + br = QgsBlockingNetworkRequest() + request = QNetworkRequest(QUrl(url)) + request.setTransferTimeout(3000) # 3s timeout + error = br.get(request) + return error == QgsBlockingNetworkRequest.ErrorCode.NoError + except Exception: return False - return True + + +def setup_qgis_ssl_for_mergin_client() -> None: + """ + Export QGIS trusted CA certificates so that MerginClient's SSL context + can verify servers using custom/internal CAs configured in QGIS. + + This does two things: + 1. Writes all QGIS trusted CAs to a PEM file and sets SSL_CERT_FILE + so that Python's default SSL context (used by MerginClient on + Linux/Windows) picks them up. + 2. On macOS, appends the QGIS CAs to MerginClient's bundled cert.pem + so that the macOS fallback code path also trusts them. + """ + auth_manager = QgsApplication.authManager() + ca_certs = auth_manager.trustedCaCertsCache() + + if not ca_certs: + return + + # Convert QSslCertificate objects to PEM text + pem_blocks = [] + for cert in ca_certs: + pem_data = bytes(cert.toPem()).decode("ascii") + if pem_data: + pem_blocks.append(pem_data) + + if not pem_blocks: + return + + qgis_ca_pem = "\n".join(pem_blocks) + + # 1. Write to a PEM file and set SSL_CERT_FILE + settings_dir = QgsApplication.qgisSettingsDirPath() + ca_file_path = os.path.join(settings_dir, "mergin-trusted-cas.pem") + with open(ca_file_path, "w") as f: + f.write(qgis_ca_pem) + os.environ["SSL_CERT_FILE"] = ca_file_path + + # 2. On macOS: patch MerginClient's bundled cert.pem so the fallback + # code path (which ignores SSL_CERT_FILE) also trusts QGIS CAs. + if sys.platform == "darwin": + plugin_dir = os.path.dirname(os.path.realpath(__file__)) + bundled_cert = os.path.join(plugin_dir, "mergin", "cert.pem") + if os.path.exists(bundled_cert): + marker = "# --- QGIS trusted CAs ---" + with open(bundled_cert, "r") as f: + existing = f.read() + if marker not in existing: + with open(bundled_cert, "a") as f: + f.write(f"\n{marker}\n") + f.write(qgis_ca_pem) def qgis_support_sso() -> bool: From 5f512c815a686a4c07a5126e307aabdcedf5e9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lle=C3=AFr=20Borr=C3=A0s=20Metje?= Date: Mon, 2 Mar 2026 12:43:08 +0100 Subject: [PATCH 2/4] Fix SSL CA setup timing and QByteArray decode on macOS - Decode QByteArray returned by trustedCaCertsPemText() to str before writing - Always replace the QGIS CA section in cert.pem instead of append-only, so stale certs from previous sessions are refreshed - Call setup_qgis_ssl_for_mergin_client() in initGui() where the auth manager is guaranteed to be ready, and also before each MerginClient creation in validate_mergin_url() and mergin_server_deprecated_version() --- Mergin/plugin.py | 7 +++++++ Mergin/utils_auth.py | 32 +++++++++++++------------------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/Mergin/plugin.py b/Mergin/plugin.py index 345cd5aa..ad2ea720 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -66,6 +66,8 @@ os.environ["MERGIN_CLIENT_LOG"] = MERGIN_CLIENT_LOG try: + # Best-effort early init; auth manager may not be ready yet so this may be a no-op. + # The real call happens in initGui() and before each MerginClient creation. setup_qgis_ssl_for_mergin_client() except Exception: pass @@ -119,6 +121,11 @@ def initGui(self): self.initProcessing() + try: + setup_qgis_ssl_for_mergin_client() + except Exception: + pass + if self.iface is not None: self.add_action( mm_symbol_path(), diff --git a/Mergin/utils_auth.py b/Mergin/utils_auth.py index c6959ce1..0d6e79a4 100644 --- a/Mergin/utils_auth.py +++ b/Mergin/utils_auth.py @@ -444,6 +444,7 @@ def validate_mergin_url(url): :param url: String Mergin Maps URL to ping. :return: String error message as result of validation. If None, URL is valid. """ + setup_qgis_ssl_for_mergin_client() try: MerginClient(url, proxy_config=get_qgis_proxy_config(url)) @@ -529,6 +530,7 @@ def set_qgsexpressionscontext(url: str, mc: typing.Optional[MerginClient] = None def mergin_server_deprecated_version(url: str) -> bool: + setup_qgis_ssl_for_mergin_client() mc = MerginClient( url=url, auth_token=None, @@ -567,24 +569,13 @@ def setup_qgis_ssl_for_mergin_client() -> None: 2. On macOS, appends the QGIS CAs to MerginClient's bundled cert.pem so that the macOS fallback code path also trusts them. """ - auth_manager = QgsApplication.authManager() - ca_certs = auth_manager.trustedCaCertsCache() - - if not ca_certs: - return + qgis_ca_pem = QgsApplication.authManager().trustedCaCertsPemText() + if hasattr(qgis_ca_pem, "data"): + qgis_ca_pem = qgis_ca_pem.data().decode("utf-8") - # Convert QSslCertificate objects to PEM text - pem_blocks = [] - for cert in ca_certs: - pem_data = bytes(cert.toPem()).decode("ascii") - if pem_data: - pem_blocks.append(pem_data) - - if not pem_blocks: + if not qgis_ca_pem: return - qgis_ca_pem = "\n".join(pem_blocks) - # 1. Write to a PEM file and set SSL_CERT_FILE settings_dir = QgsApplication.qgisSettingsDirPath() ca_file_path = os.path.join(settings_dir, "mergin-trusted-cas.pem") @@ -601,10 +592,13 @@ def setup_qgis_ssl_for_mergin_client() -> None: marker = "# --- QGIS trusted CAs ---" with open(bundled_cert, "r") as f: existing = f.read() - if marker not in existing: - with open(bundled_cert, "a") as f: - f.write(f"\n{marker}\n") - f.write(qgis_ca_pem) + # Always replace the QGIS section so stale CAs get refreshed + if marker in existing: + base_content = existing[: existing.index(marker)].rstrip() + else: + base_content = existing.rstrip() + with open(bundled_cert, "w") as f: + f.write(base_content + f"\n\n{marker}\n" + qgis_ca_pem) def qgis_support_sso() -> bool: From 225f30dbdfeb04d16fac580ec123a48cb176e64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lle=C3=AFr=20Borr=C3=A0s=20Metje?= Date: Mon, 2 Mar 2026 12:48:57 +0100 Subject: [PATCH 3/4] Use mergin.client.set_trusted_certificates() instead of patching cert.pem Replace the macOS-specific cert.pem file patching with a call to mergin.client.set_trusted_certificates(cafile), a new API to be added to python-api-client. The mergin client module stores the extra CA file path and loads it alongside its default bundle (system CAs on Linux/Windows, bundled cert.pem on macOS) whenever a MerginClient is instantiated. This avoids mutating files inside the installed plugin directory and ensures CAs are always up to date (the PEM file is rewritten on every call). A hasattr guard keeps the code compatible with older py-client versions that don't have the new function yet. Remove the sys import that was only needed for the platform check. --- Mergin/utils_auth.py | 46 ++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/Mergin/utils_auth.py b/Mergin/utils_auth.py index 0d6e79a4..b11daa82 100644 --- a/Mergin/utils_auth.py +++ b/Mergin/utils_auth.py @@ -4,7 +4,6 @@ import hashlib import os import re -import sys import typing import uuid import json @@ -559,15 +558,15 @@ def url_reachable(url: str) -> bool: def setup_qgis_ssl_for_mergin_client() -> None: """ - Export QGIS trusted CA certificates so that MerginClient's SSL context - can verify servers using custom/internal CAs configured in QGIS. - - This does two things: - 1. Writes all QGIS trusted CAs to a PEM file and sets SSL_CERT_FILE - so that Python's default SSL context (used by MerginClient on - Linux/Windows) picks them up. - 2. On macOS, appends the QGIS CAs to MerginClient's bundled cert.pem - so that the macOS fallback code path also trusts them. + Register QGIS trusted CA certificates with the mergin client module so that + all subsequent MerginClient instances trust servers signed by CAs configured + in QGIS. + + Writes the QGIS trusted CAs to a PEM file, then passes that path to + mergin.client.set_trusted_certificates() so MerginClient loads those CAs + in addition to its default bundle (system CAs on Linux/Windows, bundled + cert.pem on macOS). Also sets SSL_CERT_FILE as a fallback for code paths + that use Python's default SSL context directly. """ qgis_ca_pem = QgsApplication.authManager().trustedCaCertsPemText() if hasattr(qgis_ca_pem, "data"): @@ -576,29 +575,22 @@ def setup_qgis_ssl_for_mergin_client() -> None: if not qgis_ca_pem: return - # 1. Write to a PEM file and set SSL_CERT_FILE settings_dir = QgsApplication.qgisSettingsDirPath() ca_file_path = os.path.join(settings_dir, "mergin-trusted-cas.pem") with open(ca_file_path, "w") as f: f.write(qgis_ca_pem) + + # Fallback: SSL_CERT_FILE is respected by Python's default SSL context. os.environ["SSL_CERT_FILE"] = ca_file_path - # 2. On macOS: patch MerginClient's bundled cert.pem so the fallback - # code path (which ignores SSL_CERT_FILE) also trusts QGIS CAs. - if sys.platform == "darwin": - plugin_dir = os.path.dirname(os.path.realpath(__file__)) - bundled_cert = os.path.join(plugin_dir, "mergin", "cert.pem") - if os.path.exists(bundled_cert): - marker = "# --- QGIS trusted CAs ---" - with open(bundled_cert, "r") as f: - existing = f.read() - # Always replace the QGIS section so stale CAs get refreshed - if marker in existing: - base_content = existing[: existing.index(marker)].rstrip() - else: - base_content = existing.rstrip() - with open(bundled_cert, "w") as f: - f.write(base_content + f"\n\n{marker}\n" + qgis_ca_pem) + # Primary path: register the CA file with the mergin client module so that + # every MerginClient instance (on all platforms) loads it alongside its + # default CA bundle. Requires mergin.client.set_trusted_certificates() + # from python-api-client >= . + from .mergin import client as mergin_client + + if hasattr(mergin_client, "set_trusted_certificates"): + mergin_client.set_trusted_certificates(ca_file_path) def qgis_support_sso() -> bool: From 983e74b504a6435abd98529ba977745b0968d3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lle=C3=AFr=20Borr=C3=A0s=20Metje?= Date: Mon, 2 Mar 2026 12:50:08 +0100 Subject: [PATCH 4/4] Move SSL setup to initGui() and log errors instead of silencing them - Remove the module-level setup_qgis_ssl_for_mergin_client() call; the auth manager is not guaranteed to be ready at import time - In initGui(), replace bare except Exception: pass with a logged warning so SSL setup failures are visible in the QGIS message log --- Mergin/plugin.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Mergin/plugin.py b/Mergin/plugin.py index ad2ea720..401103b4 100644 --- a/Mergin/plugin.py +++ b/Mergin/plugin.py @@ -65,14 +65,6 @@ MERGIN_CLIENT_LOG = os.path.join(QgsApplication.qgisSettingsDirPath(), "mergin-client-log.txt") os.environ["MERGIN_CLIENT_LOG"] = MERGIN_CLIENT_LOG -try: - # Best-effort early init; auth manager may not be ready yet so this may be a no-op. - # The real call happens in initGui() and before each MerginClient creation. - setup_qgis_ssl_for_mergin_client() -except Exception: - pass - - class MerginPlugin: def __init__(self, iface): self.iface = iface @@ -123,8 +115,8 @@ def initGui(self): try: setup_qgis_ssl_for_mergin_client() - except Exception: - pass + except Exception as e: + QgsApplication.messageLog().logMessage(f"Mergin Maps plugin: failed to set up SSL certificates: {e}") if self.iface is not None: self.add_action(