[pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError

Max R. Carrara m.carrara at proxmox.com
Tue Jul 15 11:32:36 CEST 2025


These patches were taken from PR #62951 [0] and backported onto
upstream tag "19.2.2" (0eceb0defba).

This provides a workaround for the PyO3 ImportError regarding
sub-interpreters for the Ceph Dashboard.

[0]: https://github.com/ceph/ceph/pull/62951

Signed-off-by: Max R. Carrara <m.carrara at proxmox.com>
---
 ...around-the-ImportError-PyO3-modules-.patch | 553 ++++++++++++++++
 ...yptotools-use-json-for-structured-ou.patch |  81 +++
 ...yptotools-create-CrytpoCaller-interf.patch | 197 ++++++
 ...yptotools-use-one-single-dir-for-cry.patch |  30 +
 ...0038-python-common-remove-unused-dir.patch |  19 +
 ...e-mgr_util-to-use-cryptotools-Crypto.patch | 165 +++++
 ...rrect-typo-in-private_key-naming-fie.patch |  24 +
 ...yptotools-Always-encode-Err-via-stde.patch |  62 ++
 ...ct-code-to-ensure-cephadm-tests-test.patch |  38 ++
 ...yptotools-fix-error-path-in-verify-t.patch |  62 ++
 ...yptotools-Remove-ascii-and-utf-8-ref.patch |  94 +++
 ...nd-mgr-Appropriately-rename-function.patch | 623 ++++++++++++++++++
 ...yptotools-give-the-parsers-more-sens.patch |  58 ++
 ...place-direct-use-of-bcrypt-in-dashbo.patch | 104 +++
 ...ind-mgr-fix-test-case-in-test_tls.py.patch |  27 +
 ...yptotools-move-actual-crypto-opts-in.patch | 314 +++++++++
 ...mmon-cryptotools-use-a-main-function.patch |  49 ++
 ...yptotools-unify-and-organize-all-end.patch | 185 ++++++
 ...yptotools-add-caller-module-for-base.patch |  66 ++
 ...yptotools-move-internal-crypto-calle.patch | 301 +++++++++
 ...yptotools-create-module-for-selectin.patch | 187 ++++++
 ...yptotools-catch-all-failures-to-read.patch |  44 ++
 ...always-use-the-internal-cryptocaller.patch |  39 ++
 ...d-an-option-to-control-the-dashboard.patch |  78 +++
 patches/series                                |  24 +
 25 files changed, 3424 insertions(+)
 create mode 100644 patches/0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch
 create mode 100644 patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch
 create mode 100644 patches/0036-python-common-cryptotools-create-CrytpoCaller-interf.patch
 create mode 100644 patches/0037-python-common-cryptotools-use-one-single-dir-for-cry.patch
 create mode 100644 patches/0038-python-common-remove-unused-dir.patch
 create mode 100644 patches/0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch
 create mode 100644 patches/0040-python-common-Correct-typo-in-private_key-naming-fie.patch
 create mode 100644 patches/0041-python-common-cryptotools-Always-encode-Err-via-stde.patch
 create mode 100644 patches/0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch
 create mode 100644 patches/0043-python-common-cryptotools-fix-error-path-in-verify-t.patch
 create mode 100644 patches/0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch
 create mode 100644 patches/0045-pybind-mgr-Appropriately-rename-function.patch
 create mode 100644 patches/0046-python-common-cryptotools-give-the-parsers-more-sens.patch
 create mode 100644 patches/0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch
 create mode 100644 patches/0048-pybind-mgr-fix-test-case-in-test_tls.py.patch
 create mode 100644 patches/0049-python-common-cryptotools-move-actual-crypto-opts-in.patch
 create mode 100644 patches/0050-python-common-cryptotools-use-a-main-function.patch
 create mode 100644 patches/0051-python-common-cryptotools-unify-and-organize-all-end.patch
 create mode 100644 patches/0052-python-common-cryptotools-add-caller-module-for-base.patch
 create mode 100644 patches/0053-python-common-cryptotools-move-internal-crypto-calle.patch
 create mode 100644 patches/0054-python-common-cryptotools-create-module-for-selectin.patch
 create mode 100644 patches/0055-python-common-cryptotools-catch-all-failures-to-read.patch
 create mode 100644 patches/0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch
 create mode 100644 patches/0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch

diff --git a/patches/0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch b/patches/0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch
new file mode 100644
index 0000000000..a463c60022
--- /dev/null
+++ b/patches/0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch
@@ -0,0 +1,553 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Sat, 5 Apr 2025 21:47:55 +0100
+Subject: [PATCH 34/57] pybind/mgr: Hack around the 'ImportError: PyO3 modules
+ may only be initialized once per interpreter process' issue.
+
+Fixes: https://tracker.ceph.com/issues/64213
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/pybind/mgr/cephadm/tests/test_cephadm.py  |   2 -
+ src/pybind/mgr/mgr_util.py                    | 209 ++++++++++--------
+ src/pybind/mgr/tests/test_tls.py              |  10 +-
+ src/python-common/ceph/pybind/__init__.py     |   0
+ src/python-common/ceph/pybind/mgr/__init__.py |   0
+ .../ceph/pybind/mgr/cryptotools.py            | 197 +++++++++++++++++
+ 6 files changed, 319 insertions(+), 99 deletions(-)
+ create mode 100644 src/python-common/ceph/pybind/__init__.py
+ create mode 100644 src/python-common/ceph/pybind/mgr/__init__.py
+ create mode 100644 src/python-common/ceph/pybind/mgr/cryptotools.py
+
+diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py
+index b2e36ec5bd6..9d5c50cb8c5 100644
+--- a/src/pybind/mgr/cephadm/tests/test_cephadm.py
++++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py
+@@ -2088,12 +2088,10 @@ class TestCephadm(object):
+             ), CephadmOrchestrator.apply_container),
+         ]
+     )
+-    @mock.patch("subprocess.run", None)
+     @mock.patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}'))
+     @mock.patch("cephadm.services.nfs.NFSService.run_grace_tool", mock.MagicMock())
+     @mock.patch("cephadm.services.nfs.NFSService.create_rados_config_obj", mock.MagicMock())
+     @mock.patch("cephadm.services.nfs.NFSService.purge", mock.MagicMock())
+-    @mock.patch("subprocess.run", mock.MagicMock())
+     def test_apply_save(self, spec: ServiceSpec, meth, cephadm_module: CephadmOrchestrator):
+         with with_host(cephadm_module, 'test'):
+             with with_service(cephadm_module, spec, meth, 'test'):
+diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py
+index 05ec6496682..9625937ed74 100644
+--- a/src/pybind/mgr/mgr_util.py
++++ b/src/pybind/mgr/mgr_util.py
+@@ -3,7 +3,6 @@ import os
+ if 'UNITTEST' in os.environ:
+     import tests
+ 
+-import bcrypt
+ import cephfs
+ import contextlib
+ import datetime
+@@ -524,20 +523,9 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+ 
+     """
+ 
+-    from OpenSSL import crypto
+-    from uuid import uuid4
+-
+     # RDN = Relative Distinguished Name
+     valid_RDN_list = ['C', 'ST', 'L', 'O', 'OU', 'CN', 'emailAddress']
+ 
+-    # create a key pair
+-    pkey = crypto.PKey()
+-    pkey.generate_key(crypto.TYPE_RSA, 2048)
+-
+-    # Create a "subject" object
+-    req = crypto.X509Req()
+-    subj = req.get_subject()
+-
+     if dname:
+         # dname received, so check it contains valid RDNs
+         if not all(field in valid_RDN_list for field in dname):
+@@ -545,44 +533,49 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+     else:
+         dname = {"O": organisation, "CN": common_name}
+ 
+-    # populate the subject with the dname settings
+-    for k, v in dname.items():
+-        setattr(subj, k, v)
++    import json
++    import subprocess
++
++    private_key = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--private_key"],
++                                 capture_output=True)
++
++    pkey = private_key.stdout.strip().decode('utf-8')
+ 
+-    # create a self-signed cert
+-    cert = crypto.X509()
+-    cert.set_subject(req.get_subject())
+-    cert.set_serial_number(int(uuid4()))
+-    cert.gmtime_adj_notBefore(0)
+-    cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
+-    cert.set_issuer(cert.get_subject())
+-    cert.set_pubkey(pkey)
+-    cert.sign(pkey, 'sha512')
++    data = {"dname": dname, "private_key": pkey}
+ 
+-    cert = crypto.dump_certificate(crypto.FILETYPE_PEM, cert)
+-    pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
++    result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--certificate"],
++                            input=json.dumps(data).encode("utf-8"),
++                            capture_output=True)
+ 
+-    return cert.decode('utf-8'), pkey.decode('utf-8')
++    # Check result with a CompletedProcess
++    if result.returncode != 0 or result.stderr != b'':
++        raise ValueError(result.stderr)
++
++    cert = result.stdout.strip().decode('utf-8')
++    return cert, pkey
+ 
+ 
+ def verify_cacrt_content(crt):
+-    # type: (str) -> None
+-    from OpenSSL import crypto
++    # type: (str) -> int
++
+     try:
+-        crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
+-        x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-        if x509.has_expired():
+-            org, cn = get_cert_issuer_info(crt)
+-            no_after = x509.get_notAfter()
+-            end_date = None
+-            if no_after is not None:
+-                end_date = datetime.datetime.strptime(no_after.decode('ascii'), '%Y%m%d%H%M%SZ')
+-            msg = f'Certificate issued by "{org}/{cn}" expired on {end_date}'
+-            logger.warning(msg)
+-            raise ServerConfigException(msg)
+-    except (ValueError, crypto.Error) as e:
++        import subprocess
++        result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_cacrt_content"],
++                                input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
++                                capture_output=True)
++        # The above script will only produce stdout output.
++        # The only scenarios that produce stderr output are failures to import modules
++        # or syntax errors which test_tls.py will catch
++
++        # Check result of CompletedProcess
++        if result.returncode != 0 or result.stderr != b'':
++            logger.warning(result.stderr)
++            raise ValueError(result.stderr)
++    except (ValueError) as e:
+         raise ServerConfigException(f'Invalid certificate: {e}')
+ 
++    return int(result.stdout.strip().decode('utf-8'))
++
+ 
+ def verify_cacrt(cert_fname):
+     # type: (str) -> None
+@@ -603,49 +596,52 @@ def verify_cacrt(cert_fname):
+ def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]:
+     """Basic validation of a ca cert"""
+ 
+-    from OpenSSL import crypto, SSL
+     try:
+-        crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
+-        (org_name, cn) = (None, None)
+-        cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-        components = cert.get_issuer().get_components()
+-        for c in components:
+-            if c[0].decode() == 'O':  # org comp
+-                org_name = c[1].decode()
+-            elif c[0].decode() == 'CN':  # common name comp
+-                cn = c[1].decode()
+-        return (org_name, cn)
+-    except (ValueError, crypto.Error) as e:
++        import subprocess
++        org_name_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--org_name"],
++                                       input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
++                                       capture_output=True)
++
++        # Check result with a CompletedProcess
++        if org_name_proc.returncode != 0 or org_name_proc.stderr != b'':
++            raise ValueError(org_name_proc.stderr)
++
++        cn_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--cn"],
++                                 input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
++                                 capture_output=True)
++
++        # Check result with a CompletedProcess
++        if cn_proc.returncode != 0 or cn_proc.stderr != b'':
++            raise ValueError(cn_proc.stderr)
++
++        org_name, cn = org_name_proc.stdout.strip().decode('utf-8'), cn_proc.stdout.strip().decode('utf-8')
++
++    except (ValueError) as e:
+         raise ServerConfigException(f'Invalid certificate key: {e}')
++    return (org_name, cn)
+ 
+ def verify_tls(crt, key):
+     # type: (str, str) -> None
+     verify_cacrt_content(crt)
+ 
+-    from OpenSSL import crypto, SSL
+     try:
+-        _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
+-        _key.check()
+-    except (ValueError, crypto.Error) as e:
+-        raise ServerConfigException(
+-            'Invalid private key: {}'.format(str(e)))
+-    try:
+-        crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
+-        _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-    except ValueError as e:
+-        raise ServerConfigException(
+-            'Invalid certificate key: {}'.format(str(e))
+-        )
+-
+-    try:
+-        context = SSL.Context(SSL.TLSv1_METHOD)
+-        context.use_certificate(_crt)
+-        context.use_privatekey(_key)
+-        context.check_privatekey()
+-    except crypto.Error as e:
+-        logger.warning('Private key and certificate do not match up: {}'.format(str(e)))
+-    except SSL.Error as e:
+-        raise ServerConfigException(f'Invalid cert/key pair: {e}')
++        import subprocess
++        import json
++
++        data = {
++            "crt": crt.decode("utf-8") if isinstance(crt, bytes) else crt,  # type: ignore[attr-defined]
++            "key": key.decode("utf-8") if isinstance(key, bytes) else key   # type: ignore[attr-defined]
++        }
++        result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_tls"],
++                                input=json.dumps(data).encode("utf-8"),
++                                capture_output=True)
++
++        # Check result of CompletedProcess
++        if result.returncode != 0 or result.stdout != b'':
++            logger.warning(result.stdout)
++            raise ServerConfigException(result.stdout)
++    except (ServerConfigException) as e:
++        raise ServerConfigException(f'Invalid certificate: {e}')
+ 
+ 
+ 
+@@ -674,24 +670,14 @@ def verify_tls_files(cert_fname, pkey_fname):
+     if not os.path.isfile(pkey_fname):
+         raise ServerConfigException('private key %s does not exist' % pkey_fname)
+ 
+-    from OpenSSL import crypto, SSL
++    if not os.path.isfile(cert_fname):
++        raise ServerConfigException('certificate %s does not exist' % cert_fname)
+ 
+     try:
+-        with open(pkey_fname) as f:
+-            pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, f.read())
+-            pkey.check()
+-    except (ValueError, crypto.Error) as e:
+-        raise ServerConfigException(
+-            'Invalid private key {}: {}'.format(pkey_fname, str(e)))
+-    try:
+-        context = SSL.Context(SSL.TLSv1_METHOD)
+-        context.use_certificate_file(cert_fname, crypto.FILETYPE_PEM)
+-        context.use_privatekey_file(pkey_fname, crypto.FILETYPE_PEM)
+-        context.check_privatekey()
+-    except crypto.Error as e:
+-        logger.warning(
+-            'Private key {} and certificate {} do not match up: {}'.format(
+-                pkey_fname, cert_fname, str(e)))
++        with open(pkey_fname) as key_file, open(cert_fname) as cert_file:
++            verify_tls(cert_file.read(), key_file.read())
++    except (ServerConfigException) as e:
++        raise ServerConfigException({e})
+ 
+ 
+ def get_most_recent_rate(rates: Optional[List[Tuple[float, float]]]) -> float:
+@@ -869,11 +855,42 @@ def profile_method(skip_attribute: bool = False) -> Callable[[Callable[..., T]],
+     return outer
+ 
+ 
++def parse_combined_pem_file(pem_data: str) -> Tuple[Optional[str], Optional[str]]:
++
++    # Extract the certificate
++    cert_start = "-----BEGIN CERTIFICATE-----"
++    cert_end = "-----END CERTIFICATE-----"
++    cert = None
++    if cert_start in pem_data and cert_end in pem_data:
++        cert = pem_data[pem_data.index(cert_start):pem_data.index(cert_end) + len(cert_end)]
++
++    # Extract the private key
++    key_start = "-----BEGIN PRIVATE KEY-----"
++    key_end = "-----END PRIVATE KEY-----"
++    private_key = None
++    if key_start in pem_data and key_end in pem_data:
++        private_key = pem_data[pem_data.index(key_start):pem_data.index(key_end) + len(key_end)]
++
++    return cert, private_key
++
++
+ def password_hash(password: Optional[str], salt_password: Optional[str] = None) -> Optional[str]:
+     if not password:
+         return None
++
+     if not salt_password:
+-        salt = bcrypt.gensalt()
+-    else:
+-        salt = salt_password.encode('utf8')
+-    return bcrypt.hashpw(password.encode('utf8'), salt).decode('utf8')
++        salt_password = ''
++
++    import subprocess
++    import json
++
++    data = {"password": password, "salt_password": salt_password}
++    result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "password_hash"],
++                            input=json.dumps(data).encode("utf-8"),
++                            capture_output=True)
++
++    # Check result with a CompletedProcess
++    if result.returncode != 0 or result.stderr != b'':
++        raise ValueError(result.stderr)
++
++    return result.stdout.strip().decode('utf-8')
+diff --git a/src/pybind/mgr/tests/test_tls.py b/src/pybind/mgr/tests/test_tls.py
+index 19ce46a93fd..39ba5ae0a03 100644
+--- a/src/pybind/mgr/tests/test_tls.py
++++ b/src/pybind/mgr/tests/test_tls.py
+@@ -1,4 +1,4 @@
+-from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info
++from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info, verify_cacrt_content
+ from OpenSSL import crypto, SSL
+ 
+ import unittest
+@@ -10,6 +10,9 @@ valid_ceph_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQCpHIQuSYhCII1J0S
+ invalid_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQCpHIQuSYhCII1J0SVGYnT1MA0GCSqGSIb3DQEBDQUAMCExDTAL\nBgNVBAoMBENlcGgxEDAOBgNVBAMMB2NlcGhhZG0wHhcNMjIwNzA2MTE1MjUyWhcN\nMzIwNzAzMTE1MjUyWjAhMQ0wCwYDVQQKDARDZXBoMRAwDgYDVQQDDAdjZXBoYWRt\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEBn2ApFna2CVYE7RDtjJVk\ncJTcJQrjzDOlCoZtxb1QMCQZMXjx/7d6bseQP+dkkeA0hZxnjJZWeu6c/YnQ1JiT\n2aDuDpWoJAaiinHRJyZuY5tqG+ggn95RdToZVbeC+0uALzYi4UFacC3sfpkyIKBR\nic43+2fQNz0PZ+8INSTtm75Y53gbWuGF7Dv95200AmAN2/u8LKWZIvdhbRborxOF\nlK2T40qbj9eH3ewIN/6Eibxrvg4va3pIoOaq0XdJHAL/MjDGJAtahPIenwcjuega\n4PSlB0h3qiyFXz7BG8P0QsPP6slyD58ZJtCGtJiWPOhlq47DlnWlJzRGDEFLLryf\n8wIDAQABMA0GCSqGSIb3DQEBDQUAA4IBAQBixd7RZawlYiTZaCmv3Vy7X/hhabac\nE/YiuFt1YMe0C9+D8IcCQN/IRww/Bi7Af6tm+ncHT9GsOGWX6hahXDKTw3b9nSDi\nETvjkUTYOayZGfhYpRA6m6e/2ypcUYsiXRDY9zneDKCdPREIA1D6L2fROHetFX9r\nX9rSry01xrYwNlYA1e6GLMXm2NaGsLT3JJlRBtT3P7f1jtRGXcwkc7ns0AtW0uNj\nGqRLHfJazdgWJFsj8vBdMs7Ci0C/b5/f7J/DLpPCvUA3Fqwn9MzHl01UwlDsKy1a\nROi4cfQNOLbWX8g3PfIlqtdGYNA77UPxvy1SUimmtdopZa\n-----END CERTIFICATE-----\n
+ """
+ 
++expired_cert = """-----BEGIN CERTIFICATE-----\nMIICtjCCAZ4CAQAwDQYJKoZIhvcNAQENBQAwITEQMA4GA1UEAwwHY2VwaGFkbTEN\nMAsGA1UECgwEQ2VwaDAeFw0xNTAyMTYxOTQ4MTdaFw0yMDAyMTUxOTQ4MTdaMCEx\nEDAOBgNVBAMMB2NlcGhhZG0xDTALBgNVBAoMBENlcGgwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQCxYHJ6RlPeuhZJyAMR1ru01BEGbwhI7vMga8pwyTX8\nNn1ow2asbj7lad+jO5j5Gon8GFwsrKM0S8vmITxd5QkshnHPQRQF8hz4aieNOQiL\nnVRBTHgLihEBJCpyuTmHLn1G374ZObuFqyHcnIrKNdeKb0JxNKbx26/2NrWwFGAe\nAj5KuizMHJMZYVLfYelP4g2hSRPe2JJWI4429LeLWuBQBL9t/IPY0IlmFDP4eL+S\nB2Py8Ig2XY5oyaaxpwI8H/cAY92lsoHPI3ldDn1JEiH5Gwzxf+9fF29cesp8BYcm\naav1jT8ONvsfn7AxKDKcfZIpRNKlOqFIC03gG5R3O1iHAgMBAAEwDQYJKoZIhvcN\nAQENBQADggEBADh9bAMR7RIK3M3u6LoTQQrcoxJ0pEXBrFQGQk2uz2krlDTKRS+2\nubwD8bLNd3dl5RzvVJ1hui8y9JMnqYwgMrjR9B0EDUM/ihYU2zO3ZN9nhhnTN2qT\n+UtFtyilg3U4nQdWGw2jFPu08JPoF/g+7iBH+/o5WOfzOovjLg4BsVlKUP4ND8Dv\nXr8gxZTlaoZvZlhMCdhiT2aKstCA9R3RYBbEo/FtcsHOkO0EFuxCLiVd/eo3F56C\njfVWnvqyz3r2f1G1VafvhhdlMJ4p35Hw1ms6nFTLx5dKwJW+Xve+qBU3Q5I5iV02\nAIXXBaqId/YqKXZd+Ge/XBmluXH929PtUOk=\n-----END CERTIFICATE-----\n
++"""
++
+ class TLSchecks(unittest.TestCase):
+ 
+     def test_defaults(self):
+@@ -53,3 +56,8 @@ class TLSchecks(unittest.TestCase):
+ 
+         # invalid certificate
+         self.assertRaises(ServerConfigException, get_cert_issuer_info, invalid_cert)
++
++        # expired certificate
++        self.assertRaisesRegex(ServerConfigException,
++                               'Certificate issued by "Ceph/cephadm" expired',
++                               verify_cacrt_content, expired_cert)
+diff --git a/src/python-common/ceph/pybind/__init__.py b/src/python-common/ceph/pybind/__init__.py
+new file mode 100644
+index 00000000000..e69de29bb2d
+diff --git a/src/python-common/ceph/pybind/mgr/__init__.py b/src/python-common/ceph/pybind/mgr/__init__.py
+new file mode 100644
+index 00000000000..e69de29bb2d
+diff --git a/src/python-common/ceph/pybind/mgr/cryptotools.py b/src/python-common/ceph/pybind/mgr/cryptotools.py
+new file mode 100644
+index 00000000000..c14f9b2a453
+--- /dev/null
++++ b/src/python-common/ceph/pybind/mgr/cryptotools.py
+@@ -0,0 +1,197 @@
++"""
++This file has been isolated into a module so that it can be run
++in a subprocess therefore sidestepping the
++`PyO3 modules may only be initialized once per interpreter process` problem.
++"""
++
++import argparse
++import bcrypt
++import datetime
++import json
++import sys
++import warnings
++
++from argparse import Namespace
++from OpenSSL import crypto, SSL
++from uuid import uuid4
++from typing import Tuple, Optional
++
++
++# subcommand functions
++def password_hash(args: Namespace) -> None:
++    data = json.loads(sys.stdin.read())
++
++    password = data['password']
++    salt_password = data['salt_password']
++
++    if not salt_password:
++        salt = bcrypt.gensalt()
++    else:
++        salt = salt_password.encode('utf8')
++
++    print(bcrypt.hashpw(password.encode('utf8'), salt).decode())
++
++
++def create_self_signed_cert(args: Namespace) -> None:
++
++    # Generate private key
++    if args.private_key:
++        # create a key pair
++        pkey = crypto.PKey()
++        pkey.generate_key(crypto.TYPE_RSA, 2048)
++        print(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode())
++        return
++
++    data = json.loads(sys.stdin.read())
++
++    dname = data['dname']
++    pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, data['private_key'])
++
++    # Create a "subject" object
++    with warnings.catch_warnings():
++        warnings.simplefilter("ignore")
++        req = crypto.X509Req()
++    subj = req.get_subject()
++
++    # populate the subject with the dname settings
++    for k, v in dname.items():
++        setattr(subj, k, v)
++
++    # create a self-signed cert
++    cert = crypto.X509()
++    cert.set_subject(req.get_subject())
++    cert.set_serial_number(int(uuid4()))
++    cert.gmtime_adj_notBefore(0)
++    cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
++    cert.set_issuer(cert.get_subject())
++    cert.set_pubkey(pkey)
++    cert.sign(pkey, 'sha512')
++
++    print(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode())
++
++
++def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]:
++    """Basic validation of a CA cert
++    """
++
++    crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
++    (org_name, cn) = (None, None)
++    cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++    components = cert.get_issuer().get_components()
++    for c in components:
++        if c[0].decode() == 'O':  # org comp
++            org_name = c[1].decode()
++        elif c[0].decode() == 'CN':  # common name comp
++            cn = c[1].decode()
++
++    return (org_name, cn)
++
++
++def verify_cacrt_content(args: Namespace) -> None:
++    crt = sys.stdin.read()
++
++    crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt
++    x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++    no_after = x509.get_notAfter()
++    if not no_after:
++        print("Certificate does not have an expiration date.", file=sys.stderr)
++        sys.exit(1)
++
++    end_date = datetime.datetime.strptime(no_after.decode('ascii'), '%Y%m%d%H%M%SZ')
++
++    if x509.has_expired():
++        org, cn = _get_cert_issuer_info(crt)
++        msg = 'Certificate issued by "%s/%s" expired on %s' % (org, cn, end_date)
++        print(msg, file=sys.stderr)
++        sys.exit(1)
++
++    # Certificate still valid, calculate and return days until expiration
++    with warnings.catch_warnings():
++        warnings.simplefilter("ignore")
++        print((end_date - datetime.datetime.utcnow()).days)
++
++
++def get_cert_issuer_info(args: Namespace) -> None:
++    crt = sys.stdin.read()
++
++    crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt
++    (org_name, cn) = (None, None)
++    cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++    components = cert.get_issuer().get_components()
++    for c in components:
++        if c[0].decode() == 'O':  # org comp
++            org_name = c[1].decode()
++        elif c[0].decode() == 'CN':  # common name comp
++            cn = c[1].decode()
++
++    if args.org_name:
++        print(org_name)
++
++    if args.cn:
++        print(cn)
++
++
++def verify_tls(args: Namespace) -> None:
++
++    data = json.loads(sys.stdin.read())
++
++    crt = data['crt']
++    key = data['key']
++
++    try:
++        _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
++        _key.check()
++    except (ValueError, crypto.Error) as e:
++        print('Invalid private key: %s' % str(e))
++    try:
++        crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
++        _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++    except ValueError as e:
++        print('Invalid certificate key: %s' % str(e))
++
++    try:
++        context = SSL.Context(SSL.TLSv1_METHOD)
++        with warnings.catch_warnings():
++            warnings.simplefilter("ignore")
++            context.use_certificate(_crt)
++            context.use_privatekey(_key)
++
++        context.check_privatekey()
++    except crypto.Error as e:
++        print('Private key and certificate do not match up: %s' % str(e))
++    except SSL.Error as e:
++        print(f'Invalid cert/key pair: {e}')
++
++
++if __name__ == "__main__":
++    # create the top-level parser
++    parser = argparse.ArgumentParser(prog='cryptotools.py')
++    subparsers = parser.add_subparsers(required=True)
++
++    # create the parser for the "password_hash" command
++    parser_foo = subparsers.add_parser('password_hash')
++    parser_foo.set_defaults(func=password_hash)
++
++    # create the parser for the "create_self_signed_cert" command
++    parser_bar = subparsers.add_parser('create_self_signed_cert')
++    parser_bar.add_argument('--private_key', required=False, action='store_true')
++    parser_bar.add_argument('--certificate', required=False, action='store_true')
++    parser_bar.set_defaults(func=create_self_signed_cert)
++
++    # create the parser for the "verify_cacrt_content" command
++    parser_bar = subparsers.add_parser('verify_cacrt_content')
++    parser_bar.set_defaults(func=verify_cacrt_content)
++
++    # create the parser for the "get_cert_issuer_info" command
++    parser_bar = subparsers.add_parser('get_cert_issuer_info')
++    parser_bar.add_argument('--org_name', required=False, action='store_true')
++    parser_bar.add_argument('--cn', required=False, action='store_true')
++    parser_bar.set_defaults(func=get_cert_issuer_info)
++
++    # create the parser for the "verify_tls" command
++    parser_bar = subparsers.add_parser('verify_tls')
++    parser_bar.set_defaults(func=verify_tls)
++
++    # parse the args and call whatever function was selected
++    args = parser.parse_args()
++    args.func(args)
diff --git a/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch b/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch
new file mode 100644
index 0000000000..5d6a3ee1dc
--- /dev/null
+++ b/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch
@@ -0,0 +1,81 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Wed, 16 Apr 2025 14:55:47 -0400
+Subject: [PATCH 35/57] python-common/cryptotools: use json for structured
+ output
+
+Where possible try to use structured output in JSON for easier parsing
+and interaction with the parent process.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/pybind/mgr/cryptotools.py            | 21 ++++++++++---------
+ 1 file changed, 11 insertions(+), 10 deletions(-)
+
+diff --git a/src/python-common/ceph/pybind/mgr/cryptotools.py b/src/python-common/ceph/pybind/mgr/cryptotools.py
+index c14f9b2a453..dd9f5367b6a 100644
+--- a/src/python-common/ceph/pybind/mgr/cryptotools.py
++++ b/src/python-common/ceph/pybind/mgr/cryptotools.py
+@@ -29,7 +29,8 @@ def password_hash(args: Namespace) -> None:
+     else:
+         salt = salt_password.encode('utf8')
+ 
+-    print(bcrypt.hashpw(password.encode('utf8'), salt).decode())
++    hash_str = bcrypt.hashpw(password.encode('utf8'), salt).decode('utf-8')
++    json.dump({'hash': hash_str}, sys.stdout)
+ 
+ 
+ def create_self_signed_cert(args: Namespace) -> None:
+@@ -108,7 +109,8 @@ def verify_cacrt_content(args: Namespace) -> None:
+     # Certificate still valid, calculate and return days until expiration
+     with warnings.catch_warnings():
+         warnings.simplefilter("ignore")
+-        print((end_date - datetime.datetime.utcnow()).days)
++        days_until_exp = (end_date - datetime.datetime.utcnow()).days
++        json.dump({'days_until_expiration': int(days_until_exp)}, sys.stdout)
+ 
+ 
+ def get_cert_issuer_info(args: Namespace) -> None:
+@@ -123,12 +125,11 @@ def get_cert_issuer_info(args: Namespace) -> None:
+             org_name = c[1].decode()
+         elif c[0].decode() == 'CN':  # common name comp
+             cn = c[1].decode()
++    json.dump({'org_name': org_name, 'cn': cn}, sys.stdout)
+ 
+-    if args.org_name:
+-        print(org_name)
+ 
+-    if args.cn:
+-        print(cn)
++def _fail_message(msg: str) -> None:
++    json.dump({'error': msg}, sys.stdout)
+ 
+ 
+ def verify_tls(args: Namespace) -> None:
+@@ -142,12 +143,12 @@ def verify_tls(args: Namespace) -> None:
+         _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
+         _key.check()
+     except (ValueError, crypto.Error) as e:
+-        print('Invalid private key: %s' % str(e))
++        _fail_message('Invalid private key: %s' % str(e))
+     try:
+         crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
+         _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+     except ValueError as e:
+-        print('Invalid certificate key: %s' % str(e))
++        _fail_message('Invalid certificate key: %s' % str(e))
+ 
+     try:
+         context = SSL.Context(SSL.TLSv1_METHOD)
+@@ -158,9 +159,9 @@ def verify_tls(args: Namespace) -> None:
+ 
+         context.check_privatekey()
+     except crypto.Error as e:
+-        print('Private key and certificate do not match up: %s' % str(e))
++        _fail_message('Private key and certificate do not match up: %s' % str(e))
+     except SSL.Error as e:
+-        print(f'Invalid cert/key pair: {e}')
++        _fail_message(f'Invalid cert/key pair: {e}')
+ 
+ 
+ if __name__ == "__main__":
diff --git a/patches/0036-python-common-cryptotools-create-CrytpoCaller-interf.patch b/patches/0036-python-common-cryptotools-create-CrytpoCaller-interf.patch
new file mode 100644
index 0000000000..cf268ff8f5
--- /dev/null
+++ b/patches/0036-python-common-cryptotools-create-CrytpoCaller-interf.patch
@@ -0,0 +1,197 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Wed, 16 Apr 2025 14:56:28 -0400
+Subject: [PATCH 36/57] python-common/cryptotools: create CrytpoCaller
+ interface class
+
+Create a class to act as a common shim between the cryptotools external
+functions and the mgr. It provides common conversion mechanisms and
+could possibly act as an abstraction in case we decide to make
+the external function calls in different ways in the future.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/cryptotools/__init__.py              |   0
+ src/python-common/ceph/cryptotools/remote.py  | 169 ++++++++++++++++++
+ 2 files changed, 169 insertions(+)
+ create mode 100644 src/python-common/ceph/cryptotools/__init__.py
+ create mode 100644 src/python-common/ceph/cryptotools/remote.py
+
+diff --git a/src/python-common/ceph/cryptotools/__init__.py b/src/python-common/ceph/cryptotools/__init__.py
+new file mode 100644
+index 00000000000..e69de29bb2d
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+new file mode 100644
+index 00000000000..2edc9fa43f1
+--- /dev/null
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -0,0 +1,169 @@
++"""Remote execution of cryptographic functions for the ceph mgr
++"""
++# NB. This module exists to enapsulate the logic around running
++# the cryptotools module that are forked off of the parent process
++# to avoid the pyo3 subintepreters problem.
++#
++# The current implementation is simple using the command line and either raw
++# blobs or JSON as stdin inputs and raw blobs or JSON as outputs. It is important
++# that we avoid putting the sensitive data on the command line as that
++# is visible in /proc.
++#
++# This simple implementation incurs the cost of starting a python process
++# for every function call. CryptoCaller is written as a class so that if
++# we choose to we can have multiple implementations of the CryptoCaller
++# sharing the same protocol.
++# For instance we could have a persistent process listening on a unix
++# socket accepting the crypto functions as RPCs. For now, we keep it
++# simple though :-)
++
++from typing import List, Union, Dict, Any, Optional, Tuple
++
++import json
++import logging
++import subprocess
++
++
++_ctmodule = 'ceph.pybind.mgr.cryptotools'
++
++logger = logging.getLogger('ceph.cryptotools.remote')
++
++
++class CryptoCallError(ValueError):
++    pass
++
++
++class CryptoCaller:
++    """CryptoCaller encapsulates cryptographic functions used by the
++    ceph mgr into a suite of functions that can be executed in a
++    different process.
++    Running the crypto functions in a separate process avoids conflicts
++    between the mgr's use of subintepreters and the cryptography module's
++    use of PyO3 rust bindings.
++
++    If you want to raise different error types set the json_error_cls
++    attribute and/or subclass and override the map_error method.
++    """
++
++    def __init__(
++        self, errors_from_json: bool = True, module: str = _ctmodule
++    ):
++        self._module = module
++        self.errors_from_json = errors_from_json
++        self.json_error_cls = ValueError
++
++    def _run(
++        self,
++        args: List[str],
++        input_data: Union[str, bytes, None] = None,
++        capture_output: bool = False,
++        check: bool = False,
++    ) -> subprocess.CompletedProcess:
++        if input_data is None:
++            _input = None
++        elif isinstance(input_data, str):
++            _input = input_data.encode('utf-8')
++        else:
++            _input = input_data
++        cmd = ['python3', '-m', _ctmodule] + list(args)
++        logger.warning('CryptoCaller will run: %r', cmd)
++        try:
++            return subprocess.run(
++                cmd, capture_output=capture_output, input=_input, check=check
++            )
++        except Exception as err:
++            mapped_err = self.map_error(err)
++            if mapped_err:
++                raise mapped_err from err
++            raise
++
++    def _result_json(self, result: subprocess.CompletedProcess) -> Any:
++        result_obj = json.loads(result.stdout)
++        if self.errors_from_json and 'error' in result_obj:
++            raise self.json_error_cls(str(result_obj['error']))
++        return result_obj
++
++    def _result_str(self, result: subprocess.CompletedProcess) -> str:
++        return result.stdout.decode('utf-8')
++
++    def map_error(self, err: Exception) -> Optional[Exception]:
++        """Convert between error types raised by the subprocesses
++        running the crypto functions and what the mgr caller expects.
++        """
++        if isinstance(err, subprocess.CalledProcessError):
++            return CryptoCallError(
++                f'failed crypto call: {err.cmd}: {err.stderr}'
++            )
++        return None
++
++    def create_private_key(self) -> str:
++        """Create a new TLS private key, returning it as a string."""
++        result = self._run(
++            ['create_self_signed_cert', '--private_key'],
++            capture_output=True,
++            check=True,
++        )
++        return self._result_str(result).strip()
++
++    def create_self_signed_cert(
++        self, dname: Dict[str, str], pkey: str
++    ) -> str:
++        """Given TLS certificate subject parameters and a private key,
++        create a new self signed certificate - returned as a string.
++        """
++        result = self._run(
++            ['create_self_signed_cert'],
++            input_data=json.dumps({'dname': dname, 'pkey': pkey}),
++            capture_output=True,
++            check=True,
++        )
++        return self._result_str(result).strip()
++
++    def verify_tls(self, crt: str, key: str) -> None:
++        """Given a TLS certificate and a private key raise an error
++        if the combination is not valid.
++        """
++        self._run(
++            ['verify_tls'],
++            input_data=json.dumps({'crt': crt, 'key': key}),
++            check=True,
++        )
++
++    def verify_cacrt_content(self, crt: str) -> int:
++        """Verify a CA Certificate return the number of days until expiration."""
++        result = self._run(
++            ["verify_cacrt_content"],
++            input_data=crt,
++            capture_output=True,
++            check=True,
++        )
++        result_obj = self._result_json(result)
++        return int(result_obj['days_until_expiration'])
++
++    def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]:
++        """Basic validation of a ca cert"""
++        result = self._run(
++            ["get_cert_issuer_info"],
++            input_data=crt,
++            capture_output=True,
++            check=True,
++        )
++        result_obj = self._result_json(result)
++        org_name = str(result_obj.get('org_name', ''))
++        cn = str(result_obj.get('cn', ''))
++        return org_name, cn
++
++    def password_hash(self, password: str, salt_password: str) -> str:
++        """Hash a password. Returns the hashed password as a string."""
++        pwdata = {"password": password, "salt_password": salt_password}
++        result = self._run(
++            ["password_hash"],
++            input_data=json.dumps(pwdata),
++            capture_output=True,
++            check=True,
++        )
++        result_obj = self._result_json(result)
++        pw_hash = result_obj.get("hash")
++        if not pw_hash:
++            raise CryptoCallError('no password hash')
++        return pw_hash
diff --git a/patches/0037-python-common-cryptotools-use-one-single-dir-for-cry.patch b/patches/0037-python-common-cryptotools-use-one-single-dir-for-cry.patch
new file mode 100644
index 0000000000..8c41f227c4
--- /dev/null
+++ b/patches/0037-python-common-cryptotools-use-one-single-dir-for-cry.patch
@@ -0,0 +1,30 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 17 Apr 2025 13:23:09 -0400
+Subject: [PATCH 37/57] python-common/cryptotools: use one single dir for
+ cryptotools
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/{pybind/mgr => cryptotools}/cryptotools.py             | 0
+ src/python-common/ceph/cryptotools/remote.py                    | 2 +-
+ 2 files changed, 1 insertion(+), 1 deletion(-)
+ rename src/python-common/ceph/{pybind/mgr => cryptotools}/cryptotools.py (100%)
+
+diff --git a/src/python-common/ceph/pybind/mgr/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+similarity index 100%
+rename from src/python-common/ceph/pybind/mgr/cryptotools.py
+rename to src/python-common/ceph/cryptotools/cryptotools.py
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 2edc9fa43f1..9a00a310627 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -24,7 +24,7 @@ import logging
+ import subprocess
+ 
+ 
+-_ctmodule = 'ceph.pybind.mgr.cryptotools'
++_ctmodule = 'ceph.cryptotools.cryptotools'
+ 
+ logger = logging.getLogger('ceph.cryptotools.remote')
+ 
diff --git a/patches/0038-python-common-remove-unused-dir.patch b/patches/0038-python-common-remove-unused-dir.patch
new file mode 100644
index 0000000000..232535ab2a
--- /dev/null
+++ b/patches/0038-python-common-remove-unused-dir.patch
@@ -0,0 +1,19 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 17 Apr 2025 13:24:48 -0400
+Subject: [PATCH 38/57] python-common: remove unused dir
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/python-common/ceph/pybind/__init__.py     | 0
+ src/python-common/ceph/pybind/mgr/__init__.py | 0
+ 2 files changed, 0 insertions(+), 0 deletions(-)
+ delete mode 100644 src/python-common/ceph/pybind/__init__.py
+ delete mode 100644 src/python-common/ceph/pybind/mgr/__init__.py
+
+diff --git a/src/python-common/ceph/pybind/__init__.py b/src/python-common/ceph/pybind/__init__.py
+deleted file mode 100644
+index e69de29bb2d..00000000000
+diff --git a/src/python-common/ceph/pybind/mgr/__init__.py b/src/python-common/ceph/pybind/mgr/__init__.py
+deleted file mode 100644
+index e69de29bb2d..00000000000
diff --git a/patches/0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch b/patches/0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch
new file mode 100644
index 0000000000..549dd7af9c
--- /dev/null
+++ b/patches/0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch
@@ -0,0 +1,165 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 17 Apr 2025 17:12:50 -0400
+Subject: [PATCH 39/57] pybind/mgr: update mgr_util to use cryptotools
+ CryptoCaller class
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/pybind/mgr/mgr_util.py | 117 +++++++------------------------------
+ 1 file changed, 22 insertions(+), 95 deletions(-)
+
+diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py
+index 9625937ed74..fa26b6f6a9b 100644
+--- a/src/pybind/mgr/mgr_util.py
++++ b/src/pybind/mgr/mgr_util.py
+@@ -24,6 +24,7 @@ else:
+ from typing import Tuple, Any, Callable, Optional, Dict, TYPE_CHECKING, TypeVar, List, Iterable, Generator, Generic, Iterator
+ 
+ from ceph.deployment.utils import wrap_ipv6
++import ceph.cryptotools.remote
+ 
+ T = TypeVar('T')
+ 
+@@ -533,48 +534,18 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+     else:
+         dname = {"O": organisation, "CN": common_name}
+ 
+-    import json
+-    import subprocess
+-
+-    private_key = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--private_key"],
+-                                 capture_output=True)
+-
+-    pkey = private_key.stdout.strip().decode('utf-8')
+-
+-    data = {"dname": dname, "private_key": pkey}
+-
+-    result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--certificate"],
+-                            input=json.dumps(data).encode("utf-8"),
+-                            capture_output=True)
+-
+-    # Check result with a CompletedProcess
+-    if result.returncode != 0 or result.stderr != b'':
+-        raise ValueError(result.stderr)
+-
+-    cert = result.stdout.strip().decode('utf-8')
++    cc = ceph.cryptotools.remote.CryptoCaller()
++    pkey = cc.create_private_key()
++    cert = cc.create_self_signed_cert(dname, pkey)
+     return cert, pkey
+ 
+ 
+-def verify_cacrt_content(crt):
+-    # type: (str) -> int
+-
++def verify_cacrt_content(crt: str) -> int:
+     try:
+-        import subprocess
+-        result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_cacrt_content"],
+-                                input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
+-                                capture_output=True)
+-        # The above script will only produce stdout output.
+-        # The only scenarios that produce stderr output are failures to import modules
+-        # or syntax errors which test_tls.py will catch
+-
+-        # Check result of CompletedProcess
+-        if result.returncode != 0 or result.stderr != b'':
+-            logger.warning(result.stderr)
+-            raise ValueError(result.stderr)
+-    except (ValueError) as e:
+-        raise ServerConfigException(f'Invalid certificate: {e}')
+-
+-    return int(result.stdout.strip().decode('utf-8'))
++        cc = ceph.cryptotools.remote.CryptoCaller()
++        return cc.verify_cacrt_content(crt)
++    except ValueError as err:
++        raise ServerConfigException(f'Invalid certificate: {err}')
+ 
+ 
+ def verify_cacrt(cert_fname):
+@@ -595,54 +566,21 @@ def verify_cacrt(cert_fname):
+ 
+ def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]:
+     """Basic validation of a ca cert"""
+-
++    cc = ceph.cryptotools.remote.CryptoCaller()
+     try:
+-        import subprocess
+-        org_name_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--org_name"],
+-                                       input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
+-                                       capture_output=True)
+-
+-        # Check result with a CompletedProcess
+-        if org_name_proc.returncode != 0 or org_name_proc.stderr != b'':
+-            raise ValueError(org_name_proc.stderr)
+-
+-        cn_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--cn"],
+-                                 input=crt if isinstance(crt, bytes) else crt.encode('utf-8'),
+-                                 capture_output=True)
+-
+-        # Check result with a CompletedProcess
+-        if cn_proc.returncode != 0 or cn_proc.stderr != b'':
+-            raise ValueError(cn_proc.stderr)
+-
+-        org_name, cn = org_name_proc.stdout.strip().decode('utf-8'), cn_proc.stdout.strip().decode('utf-8')
+-
+-    except (ValueError) as e:
+-        raise ServerConfigException(f'Invalid certificate key: {e}')
+-    return (org_name, cn)
++        return cc.get_cert_issuer_info(crt)
++    except ValueError as err:
++        raise ServerConfigException(f'Invalid certificate key: {err}')
+ 
+ def verify_tls(crt, key):
+-    # type: (str, str) -> None
+-    verify_cacrt_content(crt)
+-
++    # type: (str, str) -> int
++    cc = ceph.cryptotools.remote.CryptoCaller()
++    days_to_expiration = cc.verify_cacrt_content(crt)
+     try:
+-        import subprocess
+-        import json
+-
+-        data = {
+-            "crt": crt.decode("utf-8") if isinstance(crt, bytes) else crt,  # type: ignore[attr-defined]
+-            "key": key.decode("utf-8") if isinstance(key, bytes) else key   # type: ignore[attr-defined]
+-        }
+-        result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_tls"],
+-                                input=json.dumps(data).encode("utf-8"),
+-                                capture_output=True)
+-
+-        # Check result of CompletedProcess
+-        if result.returncode != 0 or result.stdout != b'':
+-            logger.warning(result.stdout)
+-            raise ServerConfigException(result.stdout)
+-    except (ServerConfigException) as e:
+-        raise ServerConfigException(f'Invalid certificate: {e}')
+-
++        cc.verify_tls(crt, key)
++    except ValueError as err:
++        raise ServerConfigException(str(err))
++    return days_to_expiration
+ 
+ 
+ def verify_tls_files(cert_fname, pkey_fname):
+@@ -881,16 +819,5 @@ def password_hash(password: Optional[str], salt_password: Optional[str] = None)
+     if not salt_password:
+         salt_password = ''
+ 
+-    import subprocess
+-    import json
+-
+-    data = {"password": password, "salt_password": salt_password}
+-    result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "password_hash"],
+-                            input=json.dumps(data).encode("utf-8"),
+-                            capture_output=True)
+-
+-    # Check result with a CompletedProcess
+-    if result.returncode != 0 or result.stderr != b'':
+-        raise ValueError(result.stderr)
+-
+-    return result.stdout.strip().decode('utf-8')
++    cc = ceph.cryptotools.remote.CryptoCaller()
++    return cc.password_hash(password, salt_password)
diff --git a/patches/0040-python-common-Correct-typo-in-private_key-naming-fie.patch b/patches/0040-python-common-Correct-typo-in-private_key-naming-fie.patch
new file mode 100644
index 0000000000..a8f404f5ca
--- /dev/null
+++ b/patches/0040-python-common-Correct-typo-in-private_key-naming-fie.patch
@@ -0,0 +1,24 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Mon, 21 Apr 2025 22:13:28 +0100
+Subject: [PATCH 40/57] python-common: Correct typo in private_key naming
+ field.
+
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/python-common/ceph/cryptotools/remote.py | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 9a00a310627..1ad41081445 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -113,7 +113,7 @@ class CryptoCaller:
+         """
+         result = self._run(
+             ['create_self_signed_cert'],
+-            input_data=json.dumps({'dname': dname, 'pkey': pkey}),
++            input_data=json.dumps({'dname': dname, 'private_key': pkey}),
+             capture_output=True,
+             check=True,
+         )
diff --git a/patches/0041-python-common-cryptotools-Always-encode-Err-via-stde.patch b/patches/0041-python-common-cryptotools-Always-encode-Err-via-stde.patch
new file mode 100644
index 0000000000..058e8cc4b3
--- /dev/null
+++ b/patches/0041-python-common-cryptotools-Always-encode-Err-via-stde.patch
@@ -0,0 +1,62 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Wed, 23 Apr 2025 00:07:01 +0100
+Subject: [PATCH 41/57] python-common/cryptotools: Always encode, Err via
+ stderr and signal the exit.
+
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/pybind/mgr/tests/test_tls.py                  | 1 -
+ src/python-common/ceph/cryptotools/cryptotools.py | 3 ++-
+ src/python-common/ceph/cryptotools/remote.py      | 6 ++----
+ 3 files changed, 4 insertions(+), 6 deletions(-)
+
+diff --git a/src/pybind/mgr/tests/test_tls.py b/src/pybind/mgr/tests/test_tls.py
+index 39ba5ae0a03..7cba929fe43 100644
+--- a/src/pybind/mgr/tests/test_tls.py
++++ b/src/pybind/mgr/tests/test_tls.py
+@@ -41,7 +41,6 @@ class TLSchecks(unittest.TestCase):
+         new_key = crypto.PKey()
+         new_key.generate_key(crypto.TYPE_RSA, 2048)
+         new_key = crypto.dump_privatekey(crypto.FILETYPE_PEM, new_key).decode('utf-8')
+-
+         self.assertRaises(ServerConfigException, verify_tls, crt, new_key)
+ 
+     def test_get_cert_issuer_info(self):
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index dd9f5367b6a..e021cf82ad6 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -129,7 +129,8 @@ def get_cert_issuer_info(args: Namespace) -> None:
+ 
+ 
+ def _fail_message(msg: str) -> None:
+-    json.dump({'error': msg}, sys.stdout)
++    json.dump({'error': msg}, sys.stderr)
++    sys.exit(1)
+ 
+ 
+ def verify_tls(args: Namespace) -> None:
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 1ad41081445..a83399828e1 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -55,16 +55,14 @@ class CryptoCaller:
+     def _run(
+         self,
+         args: List[str],
+-        input_data: Union[str, bytes, None] = None,
++        input_data: Union[str, None] = None,
+         capture_output: bool = False,
+         check: bool = False,
+     ) -> subprocess.CompletedProcess:
+         if input_data is None:
+             _input = None
+-        elif isinstance(input_data, str):
+-            _input = input_data.encode('utf-8')
+         else:
+-            _input = input_data
++            _input = input_data.encode('utf-8')
+         cmd = ['python3', '-m', _ctmodule] + list(args)
+         logger.warning('CryptoCaller will run: %r', cmd)
+         try:
diff --git a/patches/0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch b/patches/0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch
new file mode 100644
index 0000000000..eef4a7c2d4
--- /dev/null
+++ b/patches/0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch
@@ -0,0 +1,38 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Wed, 23 Apr 2025 22:16:12 +0100
+Subject: [PATCH 42/57] pybind/mgr: Correct code to ensure
+ cephadm/tests/test_certmgr.py passes.
+
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/pybind/mgr/mgr_util.py                   | 2 +-
+ src/python-common/ceph/cryptotools/remote.py | 1 +
+ 2 files changed, 2 insertions(+), 1 deletion(-)
+
+diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py
+index fa26b6f6a9b..d95002bff7a 100644
+--- a/src/pybind/mgr/mgr_util.py
++++ b/src/pybind/mgr/mgr_util.py
+@@ -575,8 +575,8 @@ def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]:
+ def verify_tls(crt, key):
+     # type: (str, str) -> int
+     cc = ceph.cryptotools.remote.CryptoCaller()
+-    days_to_expiration = cc.verify_cacrt_content(crt)
+     try:
++        days_to_expiration = cc.verify_cacrt_content(crt)
+         cc.verify_tls(crt, key)
+     except ValueError as err:
+         raise ServerConfigException(str(err))
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index a83399828e1..9a668ca4bfa 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -124,6 +124,7 @@ class CryptoCaller:
+         self._run(
+             ['verify_tls'],
+             input_data=json.dumps({'crt': crt, 'key': key}),
++            capture_output=True,
+             check=True,
+         )
+ 
diff --git a/patches/0043-python-common-cryptotools-fix-error-path-in-verify-t.patch b/patches/0043-python-common-cryptotools-fix-error-path-in-verify-t.patch
new file mode 100644
index 0000000000..0906ee807c
--- /dev/null
+++ b/patches/0043-python-common-cryptotools-fix-error-path-in-verify-t.patch
@@ -0,0 +1,62 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Wed, 23 Apr 2025 11:25:07 -0400
+Subject: [PATCH 43/57] python-common/cryptotools: fix error path in verify tls
+ function
+
+The remote verify_tls function was not raising errors when it should.
+Fix the function so that it always returns an object when it succeeds or
+fails gracefully. Always parse that function in the crypto caller class.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/python-common/ceph/cryptotools/cryptotools.py | 6 +++---
+ src/python-common/ceph/cryptotools/remote.py      | 3 ++-
+ 2 files changed, 5 insertions(+), 4 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index e021cf82ad6..c38ee44fec4 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -129,12 +129,11 @@ def get_cert_issuer_info(args: Namespace) -> None:
+ 
+ 
+ def _fail_message(msg: str) -> None:
+-    json.dump({'error': msg}, sys.stderr)
+-    sys.exit(1)
++    json.dump({'error': msg}, sys.stdout)
++    sys.exit(0)
+ 
+ 
+ def verify_tls(args: Namespace) -> None:
+-
+     data = json.loads(sys.stdin.read())
+ 
+     crt = data['crt']
+@@ -163,6 +162,7 @@ def verify_tls(args: Namespace) -> None:
+         _fail_message('Private key and certificate do not match up: %s' % str(e))
+     except SSL.Error as e:
+         _fail_message(f'Invalid cert/key pair: {e}')
++    json.dump({'ok': True}, sys.stdout)  # need to emit something on success
+ 
+ 
+ if __name__ == "__main__":
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 9a668ca4bfa..3271ac847a8 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -121,12 +121,13 @@ class CryptoCaller:
+         """Given a TLS certificate and a private key raise an error
+         if the combination is not valid.
+         """
+-        self._run(
++        result = self._run(
+             ['verify_tls'],
+             input_data=json.dumps({'crt': crt, 'key': key}),
+             capture_output=True,
+             check=True,
+         )
++        self._result_json(result)  # for errors only
+ 
+     def verify_cacrt_content(self, crt: str) -> int:
+         """Verify a CA Certificate return the number of days until expiration."""
diff --git a/patches/0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch b/patches/0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch
new file mode 100644
index 0000000000..adf1c01a2f
--- /dev/null
+++ b/patches/0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch
@@ -0,0 +1,94 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Wed, 23 Apr 2025 23:38:03 +0100
+Subject: [PATCH 44/57] python-common/cryptotools: Remove ascii and utf-8
+ references from encode/decode.
+
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/python-common/ceph/cryptotools/cryptotools.py | 14 +++++++-------
+ src/python-common/ceph/cryptotools/remote.py      |  4 ++--
+ 2 files changed, 9 insertions(+), 9 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index c38ee44fec4..15284276ff8 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -27,9 +27,9 @@ def password_hash(args: Namespace) -> None:
+     if not salt_password:
+         salt = bcrypt.gensalt()
+     else:
+-        salt = salt_password.encode('utf8')
++        salt = salt_password.encode()
+ 
+-    hash_str = bcrypt.hashpw(password.encode('utf8'), salt).decode('utf-8')
++    hash_str = bcrypt.hashpw(password.encode(), salt).decode()
+     json.dump({'hash': hash_str}, sys.stdout)
+ 
+ 
+@@ -75,7 +75,7 @@ def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]:
+     """Basic validation of a CA cert
+     """
+ 
+-    crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
++    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+     (org_name, cn) = (None, None)
+     cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+     components = cert.get_issuer().get_components()
+@@ -91,14 +91,14 @@ def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]:
+ def verify_cacrt_content(args: Namespace) -> None:
+     crt = sys.stdin.read()
+ 
+-    crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt
++    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+     x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+     no_after = x509.get_notAfter()
+     if not no_after:
+         print("Certificate does not have an expiration date.", file=sys.stderr)
+         sys.exit(1)
+ 
+-    end_date = datetime.datetime.strptime(no_after.decode('ascii'), '%Y%m%d%H%M%SZ')
++    end_date = datetime.datetime.strptime(no_after.decode(), '%Y%m%d%H%M%SZ')
+ 
+     if x509.has_expired():
+         org, cn = _get_cert_issuer_info(crt)
+@@ -116,7 +116,7 @@ def verify_cacrt_content(args: Namespace) -> None:
+ def get_cert_issuer_info(args: Namespace) -> None:
+     crt = sys.stdin.read()
+ 
+-    crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt
++    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+     (org_name, cn) = (None, None)
+     cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+     components = cert.get_issuer().get_components()
+@@ -145,7 +145,7 @@ def verify_tls(args: Namespace) -> None:
+     except (ValueError, crypto.Error) as e:
+         _fail_message('Invalid private key: %s' % str(e))
+     try:
+-        crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt
++        crt_buffer = crt.encode() if isinstance(crt, str) else crt
+         _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+     except ValueError as e:
+         _fail_message('Invalid certificate key: %s' % str(e))
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 3271ac847a8..04d015382a1 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -62,7 +62,7 @@ class CryptoCaller:
+         if input_data is None:
+             _input = None
+         else:
+-            _input = input_data.encode('utf-8')
++            _input = input_data.encode()
+         cmd = ['python3', '-m', _ctmodule] + list(args)
+         logger.warning('CryptoCaller will run: %r', cmd)
+         try:
+@@ -82,7 +82,7 @@ class CryptoCaller:
+         return result_obj
+ 
+     def _result_str(self, result: subprocess.CompletedProcess) -> str:
+-        return result.stdout.decode('utf-8')
++        return result.stdout.decode()
+ 
+     def map_error(self, err: Exception) -> Optional[Exception]:
+         """Convert between error types raised by the subprocesses
diff --git a/patches/0045-pybind-mgr-Appropriately-rename-function.patch b/patches/0045-pybind-mgr-Appropriately-rename-function.patch
new file mode 100644
index 0000000000..f62f84c2ae
--- /dev/null
+++ b/patches/0045-pybind-mgr-Appropriately-rename-function.patch
@@ -0,0 +1,623 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: "Paulo E. Castro" <pecastro at wormholenet.com>
+Date: Fri, 25 Apr 2025 23:52:39 +0100
+Subject: [PATCH 45/57] pybind/mgr: Appropriately rename function.
+
+Signed-off-by: Paulo E. Castro <pecastro at wormholenet.com>
+---
+ src/pybind/mgr/cephadm/cert_mgr.py            | 508 ++++++++++++++++++
+ src/pybind/mgr/mgr_util.py                    |   8 +-
+ src/pybind/mgr/tests/test_tls.py              |   4 +-
+ .../ceph/cryptotools/cryptotools.py           |   8 +-
+ src/python-common/ceph/cryptotools/remote.py  |   4 +-
+ 5 files changed, 520 insertions(+), 12 deletions(-)
+ create mode 100644 src/pybind/mgr/cephadm/cert_mgr.py
+
+diff --git a/src/pybind/mgr/cephadm/cert_mgr.py b/src/pybind/mgr/cephadm/cert_mgr.py
+new file mode 100644
+index 00000000000..b0514b0695b
+--- /dev/null
++++ b/src/pybind/mgr/cephadm/cert_mgr.py
+@@ -0,0 +1,508 @@
++from typing import TYPE_CHECKING, Tuple, Union, List, Dict, Optional, cast, Any
++import logging
++
++from cephadm.ssl_cert_utils import SSLCerts, SSLConfigException
++from mgr_util import verify_tls, certificate_days_to_expire, ServerConfigException
++from cephadm.ssl_cert_utils import get_certificate_info, get_private_key_info
++from cephadm.tlsobject_types import Cert, PrivKey
++from cephadm.tlsobject_store import TLSObjectStore, TLSObjectScope, TLSObjectException
++
++if TYPE_CHECKING:
++    from cephadm.module import CephadmOrchestrator
++
++logger = logging.getLogger(__name__)
++
++
++class CertInfo:
++    """
++      - is_valid: True if the certificate is valid.
++      - is_close_to_expiration: True if the certificate is close to expiration.
++      - days_to_expiration: Number of days until expiration.
++      - error_info: Details of any exception encountered during validation.
++    """
++    def __init__(self, cert_name: str,
++                 target: Optional[str],
++                 user_made: bool = False,
++                 is_valid: bool = False,
++                 is_close_to_expiration: bool = False,
++                 days_to_expiration: int = 0,
++                 error_info: str = ''):
++        self.user_made = user_made
++        self.cert_name = cert_name
++        self.target = target or ''
++        self.is_valid = is_valid
++        self.is_close_to_expiration = is_close_to_expiration
++        self.days_to_expiration = days_to_expiration
++        self.error_info = error_info
++
++    def __str__(self) -> str:
++        return f'{self.cert_name} ({self.target})' if self.target else f'{self.cert_name}'
++
++    def is_operationally_valid(self) -> bool:
++        return self.is_valid and not self.is_close_to_expiration
++
++    def get_status_description(self) -> str:
++        cert_source = 'user-made' if self.user_made else 'cephadm-signed'
++        cert_target = f' ({self.target})' if self.target else ''
++        cert_details = f"'{self.cert_name}{cert_target}' ({cert_source})"
++        if not self.is_valid:
++            if 'expired' in self.error_info.lower():
++                return f'Certificate {cert_details} has expired'
++            else:
++                return f'Certificate {cert_details} is not valid (error: {self.error_info})'
++        elif self.is_close_to_expiration:
++            return f'Certificate {cert_details} is about to expire (remaining days: {self.days_to_expiration})'
++
++        return 'Certificate is valid'
++
++
++class CertMgr:
++    """
++    Cephadm Certificate Manager plays a crucial role in maintaining a secure and automated certificate
++    lifecycle within Cephadm deployments. CertMgr manages SSL/TLS certificates for all services
++    handled by cephadm, acting as the root Certificate Authority (CA) for all certificates.
++    This class provides mechanisms for storing, validating, renewing, and monitoring certificate status.
++
++    It tracks known certificates and private keys, associates them with services, and ensures
++    their validity. If certificates are close to expiration or invalid, depending on the configuration
++    (governed by the mgr/cephadm/certificate_automated_rotation_enabled parameter), CertMgr generates
++    warnings or attempts renewal for cephadm-signed certificates.
++
++    Additionally, CertMgr provides methods for certificate management, including retrieving, saving,
++    and removing certificates and keys, as well as reporting certificate health status in case of issues.
++
++    This class holds the following important mappings:
++      - known_certs
++      - known_keys
++      - entities
++
++    First ones holds all the known certificates and keys managed by cephadm. Each certificate/key has a
++    pre-defined scope: Global, Host, or Service.
++
++       - Global: The same certificates is used for all the service daemons (e.g mgmt-gateway).
++       - Host: Certificates specific to individual hosts within the cluster (e.g Grafana).
++       - Service: Certificates tied to specific service (e.g RGW).
++
++    The entities mapping associates each scoped entity with its certificates. This information is needed
++    to trigger the corresponding service reconfiguration when updating some certificate and also when
++    setting the cert/key pair from CLI.
++    """
++
++    CEPHADM_ROOT_CA_CERT = 'cephadm_root_ca_cert'
++    CEPHADM_ROOT_CA_KEY = 'cephadm_root_ca_key'
++    CEPHADM_CERTMGR_HEALTH_ERR = 'CEPHADM_CERT_ERROR'
++
++    def __init__(self, mgr: "CephadmOrchestrator") -> None:
++        self.mgr = mgr
++        self.certificates_health_report: List[CertInfo] = []
++        self.known_certs: Dict[TLSObjectScope, List[str]] = {
++            TLSObjectScope.SERVICE: [],
++            TLSObjectScope.HOST: [],
++            TLSObjectScope.GLOBAL: [self.CEPHADM_ROOT_CA_CERT],
++        }
++        self.known_keys: Dict[TLSObjectScope, List[str]] = {
++            TLSObjectScope.SERVICE: [],
++            TLSObjectScope.HOST: [],
++            TLSObjectScope.GLOBAL: [self.CEPHADM_ROOT_CA_KEY],
++        }
++        self.entities: Dict[TLSObjectScope, Dict[str, Dict[str, List[str]]]] = {
++            TLSObjectScope.SERVICE: {},
++            TLSObjectScope.HOST: {},
++            TLSObjectScope.GLOBAL: {},
++        }
++
++    def init_tlsobject_store(self) -> None:
++        self.cert_store = TLSObjectStore(self.mgr, Cert, self.known_certs)
++        self.cert_store.load()
++        self.key_store = TLSObjectStore(self.mgr, PrivKey, self.known_keys)
++        self.key_store.load()
++        self._initialize_root_ca(self.mgr.get_mgr_ip())
++
++    def load(self) -> None:
++        self.init_tlsobject_store()
++
++    def _initialize_root_ca(self, ip: str) -> None:
++        self.ssl_certs: SSLCerts = SSLCerts(self.mgr._cluster_fsid, self.mgr.certificate_duration_days)
++        old_cert = cast(Cert, self.cert_store.get_tlsobject(self.CEPHADM_ROOT_CA_CERT))
++        old_key = cast(PrivKey, self.key_store.get_tlsobject(self.CEPHADM_ROOT_CA_KEY))
++        if old_key and old_cert:
++            try:
++                self.ssl_certs.load_root_credentials(old_cert.cert, old_key.key)
++            except SSLConfigException as e:
++                raise SSLConfigException("Cannot load cephadm root CA certificates.") from e
++        else:
++            self.ssl_certs.generate_root_cert(addr=ip)
++            self.cert_store.save_tlsobject(self.CEPHADM_ROOT_CA_CERT, self.ssl_certs.get_root_cert())
++            self.key_store.save_tlsobject(self.CEPHADM_ROOT_CA_KEY, self.ssl_certs.get_root_key())
++
++    def get_root_ca(self) -> str:
++        return self.ssl_certs.get_root_cert()
++
++    def register_cert_key_pair(self, entity: str, cert_name: str, key_name: str, scope: TLSObjectScope) -> None:
++        """
++        Registers a certificate/key for a given entity under a specific scope.
++
++        :param entity: The entity (e.g., service, host) owning the certificate.
++        :param cert_name: The name of the certificate.
++        :param key_name: The name of the key.
++        :param scope: The TLSObjectScope (SERVICE, HOST, GLOBAL).
++        """
++        self.register_cert(entity, cert_name, scope)
++        self.register_key(entity, key_name, scope)
++
++    def register_cert(self, entity: str, cert_name: str, scope: TLSObjectScope) -> None:
++        self._register_tls_object(entity, cert_name, scope, "certs")
++
++    def register_key(self, entity: str, key_name: str, scope: TLSObjectScope) -> None:
++        self._register_tls_object(entity, key_name, scope, "keys")
++
++    def _register_tls_object(self, entity: str, obj_name: str, scope: TLSObjectScope, obj_type: str) -> None:
++        """
++        Registers a TLS-related object (certificate or key) for a given entity under a specific scope.
++
++        :param entity: The entity (service name) owning the TLS object.
++        :param obj_name: The name of the certificate or key.
++        :param scope: The TLSObjectScope (SERVICE, HOST, GLOBAL).
++        :param obj_type: either "certs" or "keys".
++        """
++        storage = self.known_certs if obj_type == "certs" else self.known_keys
++
++        if obj_name and obj_name not in storage[scope]:
++            storage[scope].append(obj_name)
++
++        if entity not in self.entities[scope]:
++            self.entities[scope][entity] = {"certs": [], "keys": []}
++
++        self.entities[scope][entity][obj_type].append(obj_name)
++
++    def cert_to_entity(self, cert_name: str) -> str:
++        """
++        Retrieves the entity that owns a given certificate or key name.
++
++        :param cert_name: The certificate or key name.
++        :return: The entity name if found, otherwise None.
++        """
++        for scope_entities in self.entities.values():
++            for entity, certs in scope_entities.items():
++                if cert_name in certs:
++                    return entity
++        return 'unkown'
++
++    def generate_cert(
++        self,
++        host_fqdn: Union[str, List[str]],
++        node_ip: Union[str, List[str]],
++        custom_san_list: Optional[List[str]] = None,
++    ) -> Tuple[str, str]:
++        return self.ssl_certs.generate_cert(host_fqdn, node_ip, custom_san_list=custom_san_list)
++
++    def get_cert(self, cert_name: str, service_name: Optional[str] = None, host: Optional[str] = None) -> Optional[str]:
++        cert_obj = cast(Cert, self.cert_store.get_tlsobject(cert_name, service_name, host))
++        return cert_obj.cert if cert_obj else None
++
++    def get_key(self, key_name: str, service_name: Optional[str] = None, host: Optional[str] = None) -> Optional[str]:
++        key_obj = cast(PrivKey, self.key_store.get_tlsobject(key_name, service_name, host))
++        return key_obj.key if key_obj else None
++
++    def save_cert(self, cert_name: str, cert: str, service_name: Optional[str] = None, host: Optional[str] = None, user_made: bool = False) -> None:
++        self.cert_store.save_tlsobject(cert_name, cert, service_name, host, user_made)
++
++    def save_key(self, key_name: str, key: str, service_name: Optional[str] = None, host: Optional[str] = None, user_made: bool = False) -> None:
++        self.key_store.save_tlsobject(key_name, key, service_name, host, user_made)
++
++    def rm_cert(self, cert_name: str, service_name: Optional[str] = None, host: Optional[str] = None) -> None:
++        self.cert_store.rm_tlsobject(cert_name, service_name, host)
++
++    def rm_key(self, key_name: str, service_name: Optional[str] = None, host: Optional[str] = None) -> None:
++        self.key_store.rm_tlsobject(key_name, service_name, host)
++
++    def cert_ls(self, include_datails: bool = False) -> Dict:
++        cert_objects: List = self.cert_store.list_tlsobjects()
++        ls: Dict = {}
++        for cert_name, cert_obj, target in cert_objects:
++            cert_extended_info = get_certificate_info(cert_obj.cert, include_datails)
++            cert_scope = self.get_cert_scope(cert_name)
++            if cert_name not in ls:
++                ls[cert_name] = {'scope': str(cert_scope), 'certificates': {}}
++            if cert_scope == TLSObjectScope.GLOBAL:
++                ls[cert_name]['certificates'] = cert_extended_info
++            else:
++                ls[cert_name]['certificates'][target] = cert_extended_info
++
++        return ls
++
++    def key_ls(self) -> Dict:
++        key_objects: List = self.key_store.list_tlsobjects()
++        ls: Dict = {}
++        for key_name, key_obj, target in key_objects:
++            priv_key_info = get_private_key_info(key_obj.key)
++            key_scope = self.get_key_scope(key_name)
++            if key_name not in ls:
++                ls[key_name] = {'scope': str(key_scope), 'keys': {}}
++            if key_scope == TLSObjectScope.GLOBAL:
++                ls[key_name]['keys'] = priv_key_info
++            else:
++                ls[key_name]['keys'].update({target: priv_key_info})
++
++        # we don't want this key to be leaked
++        del ls[self.CEPHADM_ROOT_CA_KEY]
++
++        return ls
++
++    def list_entity_known_certificates(self, entity: str) -> List[str]:
++        """
++        Retrieves all certificates associated with a given entity.
++
++        :param entity: The entity name.
++        :return: A list of certificate names, or None if the entity is not found.
++        """
++        for scope, entities in self.entities.items():
++            if entity in entities:
++                return entities[entity]['certs']  # Return certs for the entity
++        return []
++
++    def get_entities(self, get_scope: bool = False) -> Dict[str, Any]:
++        return {f'{scope}': entities for scope, entities in self.entities.items()}
++
++    def list_entities(self) -> List[str]:
++        """
++        Retrieves a list of all registered entities across all scopes.
++        :return: A list of entity names.
++        """
++        entities: List[str] = []
++        for scope_entities in self.entities.values():
++            entities.extend(scope_entities.keys())
++        return entities
++
++    def get_cert_scope(self, cert_name: str) -> TLSObjectScope:
++        for scope, certificates in self.known_certs.items():
++            if cert_name in certificates:
++                return scope
++        return TLSObjectScope.UNKNOWN
++
++    def get_key_scope(self, key_name: str) -> TLSObjectScope:
++        for scope, keys in self.known_keys.items():
++            if key_name in keys:
++                return scope
++        return TLSObjectScope.UNKNOWN
++
++    def _notify_certificates_health_status(self, problematic_certificates: List[CertInfo]) -> None:
++
++        previously_reported_issues = [(c.cert_name, c.target) for c in self.certificates_health_report]
++        for cert_info in problematic_certificates:
++            if (cert_info.cert_name, cert_info.target) not in previously_reported_issues:
++                self.certificates_health_report.append(cert_info)
++
++        if not self.certificates_health_report:
++            self.mgr.remove_health_warning(CertMgr.CEPHADM_CERTMGR_HEALTH_ERR)
++            return
++
++        detailed_error_msgs = []
++        invalid_count = 0
++        expired_count = 0
++        expiring_count = 0
++        for cert_info in self.certificates_health_report:
++            cert_status = cert_info.get_status_description()
++            detailed_error_msgs.append(cert_status)
++            if not cert_info.is_valid:
++                if "expired" in cert_info.error_info:
++                    expired_count += 1
++                else:
++                    invalid_count += 1
++            elif cert_info.is_close_to_expiration:
++                expiring_count += 1
++
++        # Generate a short description with a summery of all the detected issues
++        issues = [
++            f'{invalid_count} invalid' if invalid_count > 0 else '',
++            f'{expired_count} expired' if expired_count > 0 else '',
++            f'{expiring_count} expiring' if expiring_count > 0 else ''
++        ]
++        issues_description = ', '.join(filter(None, issues))  # collect only non-empty issues
++        total_issues = invalid_count + expired_count + expiring_count
++        short_error_msg = (f'Detected {total_issues} cephadm certificate(s) issues: {issues_description}')
++
++        if invalid_count > 0 or expired_count > 0:
++            logger.error(short_error_msg)
++            self.mgr.set_health_error(CertMgr.CEPHADM_CERTMGR_HEALTH_ERR, short_error_msg, total_issues, detailed_error_msgs)
++        else:
++            logger.warning(short_error_msg)
++            self.mgr.set_health_warning(CertMgr.CEPHADM_CERTMGR_HEALTH_ERR, short_error_msg, total_issues, detailed_error_msgs)
++
++    def check_certificate_state(self, cert_name: str, target: str, cert: str, key: Optional[str] = None) -> CertInfo:
++        """
++        Checks if a certificate is valid and close to expiration.
++
++        Returns:
++            - is_valid: True if the certificate is valid.
++            - is_close_to_expiration: True if the certificate is close to expiration.
++            - days_to_expiration: Number of days until expiration.
++            - exception_info: Details of any exception encountered during validation.
++        """
++        cert_obj = Cert(cert, True)
++        key_obj = PrivKey(key, True) if key else None
++        return self._check_certificate_state(cert_name, target, cert_obj, key_obj)
++
++    def _check_certificate_state(self, cert_name: str, target: Optional[str], cert: Cert, key: Optional[PrivKey] = None) -> CertInfo:
++        """
++        Checks if a certificate is valid and close to expiration.
++
++        Returns: CertInfo
++        """
++        try:
++            days_to_expiration = verify_tls(cert.cert, key.key) if key else certificate_days_to_expire(cert.cert)
++            is_close_to_expiration = days_to_expiration < self.mgr.certificate_renewal_threshold_days
++            return CertInfo(cert_name, target, cert.user_made, True, is_close_to_expiration, days_to_expiration, "")
++        except ServerConfigException as e:
++            return CertInfo(cert_name, target, cert.user_made, False, False, 0, str(e))
++
++    def prepare_certificate(self,
++                            cert_name: str,
++                            key_name: str,
++                            host_fqdns: Union[str, List[str]],
++                            host_ips: Union[str, List[str]],
++                            target_host: str = '',
++                            target_service: str = '',
++                            ) -> Tuple[Optional[str], Optional[str]]:
++
++        if not cert_name or not key_name:
++            logger.error("Certificate name and key name must be provided when calling prepare_certificates.")
++            return None, None
++
++        cert_obj = cast(Cert, self.cert_store.get_tlsobject(cert_name, target_service, target_host))
++        key_obj = cast(PrivKey, self.key_store.get_tlsobject(key_name, target_service, target_host))
++        if cert_obj and key_obj:
++            target = target_host or target_service
++            cert_info = self._check_certificate_state(cert_name, target, cert_obj, key_obj)
++            if cert_info.is_operationally_valid():
++                return cert_obj.cert, key_obj.key
++            elif cert_obj.user_made:
++                self._notify_certificates_health_status([cert_info])
++                return None, None
++            else:
++                logger.warning(f'Found invalid cephadm certificate/key pair {cert_name}/{key_name}, '
++                               f'status: {cert_info.get_status_description()}, '
++                               f'error: {cert_info.error_info}')
++
++        # Reaching this point means either certificates are not present or they are
++        # invalid cephadm-signed certificates. Either way, we will just generate new ones.
++        logger.info(f'Generating cephadm-signed certificates for {cert_name}/{key_name}')
++        cert, pkey = self.generate_cert(host_fqdns, host_ips)
++        self.mgr.cert_mgr.save_cert(cert_name, cert, host=target_host, service_name=target_service)
++        self.mgr.cert_mgr.save_key(key_name, pkey, host=target_host, service_name=target_service)
++        return cert, pkey
++
++    def get_problematic_certificates(self) -> List[Tuple[CertInfo, Cert]]:
++
++        def get_key(cert_name: str, key_name: str, target: Optional[str]) -> Optional[PrivKey]:
++            try:
++                service_name, host = self.cert_store.determine_tlsobject_target(cert_name, target)
++                key = cast(PrivKey, self.key_store.get_tlsobject(key_name, service_name=service_name, host=host))
++                return key
++            except TLSObjectException:
++                return None
++
++        # Filter non-empty entries skipping cephadm root CA cetificate
++        certs_tlsobjs = [c for c in self.cert_store.list_tlsobjects() if c[1] and c[0] != self.CEPHADM_ROOT_CA_CERT]
++        problematics_certs: List[Tuple[CertInfo, Cert]] = []
++        for cert_name, cert_tlsobj, target in certs_tlsobjs:
++            cert_obj = cast(Cert, cert_tlsobj)
++            if not cert_obj:
++                logger.error(f'Cannot find certificate {cert_name} in the TLSObjectStore')
++                continue
++
++            key_name = cert_name.replace('_cert', '_key')
++            key_obj = get_key(cert_name, key_name, target)
++            if key_obj:
++                # certificate has a key, let's check the cert/key pair
++                cert_info = self._check_certificate_state(cert_name, target, cert_obj, key_obj)
++            elif key_name in self.known_keys:
++                # certificate is supposed to have a key but it's missing
++                logger.error(f"Key '{key_name}' is missing for certificate '{cert_name}'.")
++                cert_info = CertInfo(cert_name, target, cert_obj.user_made, False, False, 0, "missing key")
++            else:
++                # certificate has no associated key
++                cert_info = self._check_certificate_state(cert_name, target, cert_obj)
++
++            if not cert_info.is_operationally_valid():
++                problematics_certs.append((cert_info, cert_obj))
++            else:
++                target_info = f" ({target})" if target else ""
++                logger.info(f'Certificate for "{cert_name}{target_info}" is still valid for {cert_info.days_to_expiration} days.')
++
++        return problematics_certs
++
++    def _renew_self_signed_certificate(self, cert_info: CertInfo, cert_obj: Cert) -> bool:
++        try:
++            logger.info(f'Renewing cephadm-signed certificate for {cert_info.cert_name}')
++            new_cert, new_key = self.ssl_certs.renew_cert(cert_obj.cert, self.mgr.certificate_duration_days)
++            service_name, host = self.cert_store.determine_tlsobject_target(cert_info.cert_name, cert_info.target)
++            self.cert_store.save_tlsobject(cert_info.cert_name, new_cert, service_name=service_name, host=host)
++            key_name = cert_info.cert_name.replace('_cert', '_key')
++            self.key_store.save_tlsobject(key_name, new_key, service_name=service_name, host=host)
++            return True
++        except SSLConfigException as e:
++            logger.error(f'Error while trying to renew cephadm-signed certificate for {cert_info.cert_name}: {e}')
++            return False
++
++    def check_services_certificates(self, fix_issues: bool = False) -> Tuple[List[str], List[CertInfo]]:
++        """
++        Checks services' certificates and optionally attempts to fix issues if fix_issues is True.
++
++        :param fix_issues: Whether to attempt fixing issues automatically.
++        :return: A tuple with:
++            - List of services requiring reconfiguration.
++            - List of certificates that require manual intervention.
++        """
++
++        def requires_user_intervention(cert_info: CertInfo, cert_obj: Cert) -> bool:
++            """Determines if a certificate requires manual user intervention."""
++            close_to_expiry = (not cert_info.is_operationally_valid() and not self.mgr.certificate_automated_rotation_enabled)
++            user_made_and_invalid = cert_obj.user_made and not cert_info.is_operationally_valid()
++            return close_to_expiry or user_made_and_invalid
++
++        def trigger_auto_fix(cert_info: CertInfo, cert_obj: Cert) -> bool:
++            """Attempts to automatically fix certificate issues if possible."""
++            if not self.mgr.certificate_automated_rotation_enabled or cert_obj.user_made:
++                return False
++
++            # This is a cephadm-signed certificate, let's try to fix it
++            if not cert_info.is_valid:
++                # Remove the invalid certificate to force regeneration
++                service_name, host = self.cert_store.determine_tlsobject_target(cert_info.cert_name, cert_info.target)
++                logger.info(
++                    f'Removing invalid certificate for {cert_info.cert_name} to trigger regeneration '
++                    f'(service: {service_name}, host: {host}).'
++                )
++                self.cert_store.rm_tlsobject(cert_info.cert_name, service_name, host)
++                return True
++            elif cert_info.is_close_to_expiration:
++                return self._renew_self_signed_certificate(cert_info, cert_obj)
++            else:
++                return False
++
++        # Process all problematic certificates and try to fix them in case automated certs renewal
++        # is enabled. Successfully fixed ones are collected to trigger a service reconfiguration.
++        certs_with_issues = []
++        services_to_reconfig = set()
++        for cert_info, cert_obj in self.get_problematic_certificates():
++
++            logger.warning(cert_info.get_status_description())
++
++            if requires_user_intervention(cert_info, cert_obj):
++                certs_with_issues.append(cert_info)
++                continue
++
++            if fix_issues and trigger_auto_fix(cert_info, cert_obj):
++                services_to_reconfig.add(self.cert_to_entity(cert_info.cert_name))
++
++        # Clear previously reported issues as we are newly checking all the certifiactes
++        self.certificates_health_report = []
++
++        # All problematic certificates have been processed. certs_with_issues now only
++        # contains certificates that couldn't be fixed either because they are user-made
++        # or automated rotation is disabled. In these cases, health warning or error
++        # is raised to notify the user.
++        self._notify_certificates_health_status(certs_with_issues)
++
++        return list(services_to_reconfig), certs_with_issues
+diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py
+index d95002bff7a..c58304d0de7 100644
+--- a/src/pybind/mgr/mgr_util.py
++++ b/src/pybind/mgr/mgr_util.py
+@@ -540,10 +540,10 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+     return cert, pkey
+ 
+ 
+-def verify_cacrt_content(crt: str) -> int:
++def certificate_days_to_expire(crt: str) -> int:
+     try:
+         cc = ceph.cryptotools.remote.CryptoCaller()
+-        return cc.verify_cacrt_content(crt)
++        return cc.certificate_days_to_expire(crt)
+     except ValueError as err:
+         raise ServerConfigException(f'Invalid certificate: {err}')
+ 
+@@ -559,7 +559,7 @@ def verify_cacrt(cert_fname):
+ 
+     try:
+         with open(cert_fname) as f:
+-            verify_cacrt_content(f.read())
++            certificate_days_to_expire(f.read())
+     except ValueError as e:
+         raise ServerConfigException(
+             'Invalid certificate {}: {}'.format(cert_fname, str(e)))
+@@ -576,7 +576,7 @@ def verify_tls(crt, key):
+     # type: (str, str) -> int
+     cc = ceph.cryptotools.remote.CryptoCaller()
+     try:
+-        days_to_expiration = cc.verify_cacrt_content(crt)
++        days_to_expiration = cc.certificate_days_to_expire(crt)
+         cc.verify_tls(crt, key)
+     except ValueError as err:
+         raise ServerConfigException(str(err))
+diff --git a/src/pybind/mgr/tests/test_tls.py b/src/pybind/mgr/tests/test_tls.py
+index 7cba929fe43..840869514f1 100644
+--- a/src/pybind/mgr/tests/test_tls.py
++++ b/src/pybind/mgr/tests/test_tls.py
+@@ -1,4 +1,4 @@
+-from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info, verify_cacrt_content
++from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info, certificate_days_to_expire
+ from OpenSSL import crypto, SSL
+ 
+ import unittest
+@@ -59,4 +59,4 @@ class TLSchecks(unittest.TestCase):
+         # expired certificate
+         self.assertRaisesRegex(ServerConfigException,
+                                'Certificate issued by "Ceph/cephadm" expired',
+-                               verify_cacrt_content, expired_cert)
++                               certificate_days_to_expire, expired_cert)
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 15284276ff8..9d2f6d6db04 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -88,7 +88,7 @@ def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]:
+     return (org_name, cn)
+ 
+ 
+-def verify_cacrt_content(args: Namespace) -> None:
++def certificate_days_to_expire(args: Namespace) -> None:
+     crt = sys.stdin.read()
+ 
+     crt_buffer = crt.encode() if isinstance(crt, str) else crt
+@@ -180,9 +180,9 @@ if __name__ == "__main__":
+     parser_bar.add_argument('--certificate', required=False, action='store_true')
+     parser_bar.set_defaults(func=create_self_signed_cert)
+ 
+-    # create the parser for the "verify_cacrt_content" command
+-    parser_bar = subparsers.add_parser('verify_cacrt_content')
+-    parser_bar.set_defaults(func=verify_cacrt_content)
++    # create the parser for the "certificate_days_to_expire" command
++    parser_bar = subparsers.add_parser('certificate_days_to_expire')
++    parser_bar.set_defaults(func=certificate_days_to_expire)
+ 
+     # create the parser for the "get_cert_issuer_info" command
+     parser_bar = subparsers.add_parser('get_cert_issuer_info')
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 04d015382a1..6271288e4f8 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -129,10 +129,10 @@ class CryptoCaller:
+         )
+         self._result_json(result)  # for errors only
+ 
+-    def verify_cacrt_content(self, crt: str) -> int:
++    def certificate_days_to_expire(self, crt: str) -> int:
+         """Verify a CA Certificate return the number of days until expiration."""
+         result = self._run(
+-            ["verify_cacrt_content"],
++            ["certificate_days_to_expire"],
+             input_data=crt,
+             capture_output=True,
+             check=True,
diff --git a/patches/0046-python-common-cryptotools-give-the-parsers-more-sens.patch b/patches/0046-python-common-cryptotools-give-the-parsers-more-sens.patch
new file mode 100644
index 0000000000..9776e59451
--- /dev/null
+++ b/patches/0046-python-common-cryptotools-give-the-parsers-more-sens.patch
@@ -0,0 +1,58 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Wed, 16 Apr 2025 14:55:08 -0400
+Subject: [PATCH 46/57] python-common/cryptotools: give the parsers more
+ sensible names
+
+Name the parser objects after their functions and not `foo` and `bar`.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/cryptotools/cryptotools.py           | 26 +++++++++----------
+ 1 file changed, 12 insertions(+), 14 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 9d2f6d6db04..0b2dc828b79 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -171,28 +171,26 @@ if __name__ == "__main__":
+     subparsers = parser.add_subparsers(required=True)
+ 
+     # create the parser for the "password_hash" command
+-    parser_foo = subparsers.add_parser('password_hash')
+-    parser_foo.set_defaults(func=password_hash)
++    parser_password_hash = subparsers.add_parser('password_hash')
++    parser_password_hash.set_defaults(func=password_hash)
+ 
+     # create the parser for the "create_self_signed_cert" command
+-    parser_bar = subparsers.add_parser('create_self_signed_cert')
+-    parser_bar.add_argument('--private_key', required=False, action='store_true')
+-    parser_bar.add_argument('--certificate', required=False, action='store_true')
+-    parser_bar.set_defaults(func=create_self_signed_cert)
++    parser_cssc = subparsers.add_parser('create_self_signed_cert')
++    parser_cssc.add_argument('--private_key', required=False, action='store_true')
++    parser_cssc.add_argument('--certificate', required=False, action='store_true')
++    parser_cssc.set_defaults(func=create_self_signed_cert)
+ 
+     # create the parser for the "certificate_days_to_expire" command
+-    parser_bar = subparsers.add_parser('certificate_days_to_expire')
+-    parser_bar.set_defaults(func=certificate_days_to_expire)
++    parser_dte = subparsers.add_parser('certificate_days_to_expire')
++    parser_dte.set_defaults(func=certificate_days_to_expire)
+ 
+     # create the parser for the "get_cert_issuer_info" command
+-    parser_bar = subparsers.add_parser('get_cert_issuer_info')
+-    parser_bar.add_argument('--org_name', required=False, action='store_true')
+-    parser_bar.add_argument('--cn', required=False, action='store_true')
+-    parser_bar.set_defaults(func=get_cert_issuer_info)
++    parser_gcii = subparsers.add_parser('get_cert_issuer_info')
++    parser_gcii.set_defaults(func=get_cert_issuer_info)
+ 
+     # create the parser for the "verify_tls" command
+-    parser_bar = subparsers.add_parser('verify_tls')
+-    parser_bar.set_defaults(func=verify_tls)
++    parser_verify_tls = subparsers.add_parser('verify_tls')
++    parser_verify_tls.set_defaults(func=verify_tls)
+ 
+     # parse the args and call whatever function was selected
+     args = parser.parse_args()
diff --git a/patches/0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch b/patches/0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch
new file mode 100644
index 0000000000..c4e28def45
--- /dev/null
+++ b/patches/0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch
@@ -0,0 +1,104 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Tue, 22 Apr 2025 16:31:15 -0400
+Subject: [PATCH 47/57] mgr/dashboard: replace direct use of bcrypt in
+ dashboard
+
+Replace a direct usage of bycrypt with our cryptocaller wrapper.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../mgr/dashboard/services/access_control.py      |  8 ++++++--
+ src/python-common/ceph/cryptotools/cryptotools.py | 15 +++++++++++++++
+ src/python-common/ceph/cryptotools/remote.py      | 15 +++++++++++++++
+ 3 files changed, 36 insertions(+), 2 deletions(-)
+
+diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py
+index b45f81fb9b1..73955e7c3bd 100644
+--- a/src/pybind/mgr/dashboard/services/access_control.py
++++ b/src/pybind/mgr/dashboard/services/access_control.py
+@@ -12,7 +12,6 @@ from datetime import datetime, timedelta
+ from string import ascii_lowercase, ascii_uppercase, digits, punctuation
+ from typing import List, Optional, Sequence
+ 
+-import bcrypt
+ from mgr_module import CLICheckNonemptyFileInput, CLIReadCommand, CLIWriteCommand
+ from mgr_util import password_hash
+ 
+@@ -24,6 +23,8 @@ from ..exceptions import PasswordPolicyException, PermissionNotValid, \
+ from ..security import Permission, Scope
+ from ..settings import Settings
+ 
++import ceph.cryptotools.remote
++
+ logger = logging.getLogger('access_control')
+ DEFAULT_FILE_DESC = 'password/secret'
+ 
+@@ -889,7 +890,10 @@ def ac_user_set_password_hash(_, username: str, inbuf: str):
+     hashed_password = inbuf
+     try:
+         # make sure the hashed_password is actually a bcrypt hash
+-        bcrypt.checkpw(b'', hashed_password.encode('utf-8'))
++        # catch a ValueError if hashed_password is not valid.
++        cc = ceph.cryptotools.remote.CryptoCaller()
++        cc.verify_password('', hashed_password)
++
+         user = mgr.ACCESS_CTRL_DB.get_user(username)
+         user.set_password_hash(hashed_password)
+ 
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 0b2dc828b79..26102135250 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -33,6 +33,17 @@ def password_hash(args: Namespace) -> None:
+     json.dump({'hash': hash_str}, sys.stdout)
+ 
+ 
++def verify_password(args: Namespace) -> None:
++    data = json.loads(sys.stdin.read())
++    password = data.encode('utf-8')
++    hashed_password = data.encode('utf-8')
++    try:
++        ok = bcrypt.checkpw(password, hashed_password)
++    except ValueError as err:
++        _fail_message(str(err))
++    json.dump({'ok': ok}, sys.stdout)
++
++
+ def create_self_signed_cert(args: Namespace) -> None:
+ 
+     # Generate private key
+@@ -192,6 +203,10 @@ if __name__ == "__main__":
+     parser_verify_tls = subparsers.add_parser('verify_tls')
+     parser_verify_tls.set_defaults(func=verify_tls)
+ 
++    # password verification
++    parser_verify_password = subparsers.add_parser('verify_password')
++    parser_verify_password.set_defaults(func=verify_password)
++
+     # parse the args and call whatever function was selected
+     args = parser.parse_args()
+     args.func(args)
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 6271288e4f8..40e01d19912 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -167,3 +167,18 @@ class CryptoCaller:
+         if not pw_hash:
+             raise CryptoCallError('no password hash')
+         return pw_hash
++
++    def verify_password(self, password: str, hashed_password: str) -> bool:
++        """Verify a password matches the hashed password. Returns true if
++        password and hashed_password match.
++        """
++        pwdata = {"password": password, "hashed_password": hashed_password}
++        result = self._run(
++            ["verify_password"],
++            input_data=json.dumps(pwdata),
++            capture_output=True,
++            check=True,
++        )
++        result_obj = self._result_json(result)
++        ok = result_obj.get("ok", False)
++        return ok
diff --git a/patches/0048-pybind-mgr-fix-test-case-in-test_tls.py.patch b/patches/0048-pybind-mgr-fix-test-case-in-test_tls.py.patch
new file mode 100644
index 0000000000..03f980370a
--- /dev/null
+++ b/patches/0048-pybind-mgr-fix-test-case-in-test_tls.py.patch
@@ -0,0 +1,27 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Wed, 23 Apr 2025 11:23:43 -0400
+Subject: [PATCH 48/57] pybind/mgr: fix test case in test_tls.py
+
+Why violate the typing in a test? mypy never noticed this because tests
+are not type checked but there seems to be no need to turn a str into
+bytes to pass to a function that is typed only as taking str!
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/pybind/mgr/tests/test_tls.py | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/src/pybind/mgr/tests/test_tls.py b/src/pybind/mgr/tests/test_tls.py
+index 840869514f1..bf006919e0c 100644
+--- a/src/pybind/mgr/tests/test_tls.py
++++ b/src/pybind/mgr/tests/test_tls.py
+@@ -31,7 +31,7 @@ class TLSchecks(unittest.TestCase):
+         crt, key = create_self_signed_cert()
+ 
+         # fudge the key, to force an error to be detected during verify_tls
+-        fudged = f"{key[:-35]}c0ffee==\n{key[-25:]}".encode('utf-8')
++        fudged = f"{key[:-35]}c0ffee==\n{key[-25:]}"
+         self.assertRaises(ServerConfigException, verify_tls, crt, fudged)
+ 
+     def test_mismatched_tls(self):
diff --git a/patches/0049-python-common-cryptotools-move-actual-crypto-opts-in.patch b/patches/0049-python-common-cryptotools-move-actual-crypto-opts-in.patch
new file mode 100644
index 0000000000..7f7e42765c
--- /dev/null
+++ b/patches/0049-python-common-cryptotools-move-actual-crypto-opts-in.patch
@@ -0,0 +1,314 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Mon, 21 Apr 2025 15:07:59 -0400
+Subject: [PATCH 49/57] python-common/cryptotools: move actual crypto opts into
+ a class
+
+The functions now handle the i/o but allow the crypto function class
+to centralize the functions that actually use the crypto libs.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/cryptotools/cryptotools.py           | 247 ++++++++++--------
+ 1 file changed, 140 insertions(+), 107 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 26102135250..52c28d3f6ec 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -14,7 +14,128 @@ import warnings
+ from argparse import Namespace
+ from OpenSSL import crypto, SSL
+ from uuid import uuid4
+-from typing import Tuple, Optional
++from typing import Tuple, Any, Dict, Union
++
++
++class InternalError(ValueError):
++    pass
++
++
++class InternalCryptoCaller:
++    def fail(self, msg: str) -> None:
++        raise ValueError(msg)
++
++    def password_hash(self, password: str, salt_password: str) -> str:
++        salt = salt_password.encode() if salt_password else bcrypt.gensalt()
++        return bcrypt.hashpw(password.encode(), salt).decode()
++
++    def verify_password(self, password: str, hashed_password: str) -> bool:
++        _password = password.encode()
++        _hashed_password = hashed_password.encode()
++        try:
++            ok = bcrypt.checkpw(_password, _hashed_password)
++        except ValueError as err:
++            self.fail(str(err))
++        return ok
++
++    def create_private_key(self) -> str:
++        pkey = crypto.PKey()
++        pkey.generate_key(crypto.TYPE_RSA, 2048)
++        return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode()
++
++    def create_self_signed_cert(
++        self, dname: Dict[str, str], pkey: str
++    ) -> str:
++        _pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey)
++
++        # Create a "subject" object
++        with warnings.catch_warnings():
++            warnings.simplefilter("ignore")
++            req = crypto.X509Req()
++        subj = req.get_subject()
++
++        # populate the subject with the dname settings
++        for k, v in dname.items():
++            setattr(subj, k, v)
++
++        # create a self-signed cert
++        cert = crypto.X509()
++        cert.set_subject(req.get_subject())
++        cert.set_serial_number(int(uuid4()))
++        cert.gmtime_adj_notBefore(0)
++        cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
++        cert.set_issuer(cert.get_subject())
++        cert.set_pubkey(_pkey)
++        cert.sign(_pkey, 'sha512')
++        return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
++
++    def _load_cert(self, crt: Union[str, bytes]) -> Any:
++        crt_buffer = crt.encode() if isinstance(crt, str) else crt
++        cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++        return cert
++
++    def _issuer_info(self, cert: Any) -> Tuple[str, str]:
++        components = cert.get_issuer().get_components()
++        org_name = cn = ''
++        for c in components:
++            if c[0].decode() == 'O':  # org comp
++                org_name = c[1].decode()
++            elif c[0].decode() == 'CN':  # common name comp
++                cn = c[1].decode()
++        return (org_name, cn)
++
++    def certificate_days_to_expire(self, crt: str) -> int:
++        x509 = self._load_cert(crt)
++        no_after = x509.get_notAfter()
++        if not no_after:
++            self.fail("Certificate does not have an expiration date.")
++
++        end_date = datetime.datetime.strptime(
++            no_after.decode(), '%Y%m%d%H%M%SZ'
++        )
++
++        if x509.has_expired():
++            org, cn = self._issuer_info(x509)
++            msg = 'Certificate issued by "%s/%s" expired on %s' % (
++                org,
++                cn,
++                end_date,
++            )
++            self.fail(msg)
++
++        # Certificate still valid, calculate and return days until expiration
++        with warnings.catch_warnings():
++            warnings.simplefilter("ignore")
++            days_until_exp = (end_date - datetime.datetime.utcnow()).days
++        return int(days_until_exp)
++
++    def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]:
++        return self._issuer_info(self._load_cert(crt))
++
++    def verify_tls(self, crt: str, key: str) -> None:
++        try:
++            _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
++            _key.check()
++        except (ValueError, crypto.Error) as e:
++            self.fail('Invalid private key: %s' % str(e))
++        try:
++            _crt = self._load_cert(crt)
++        except ValueError as e:
++            self.fail('Invalid certificate key: %s' % str(e))
++
++        try:
++            context = SSL.Context(SSL.TLSv1_METHOD)
++            with warnings.catch_warnings():
++                warnings.simplefilter("ignore")
++                context.use_certificate(_crt)
++                context.use_privatekey(_key)
++            context.check_privatekey()
++        except crypto.Error as e:
++            self.fail(
++                'Private key and certificate do not match up: %s' % str(e)
++            )
++        except SSL.Error as e:
++            self.fail(f'Invalid cert/key pair: {e}')
+ 
+ 
+ # subcommand functions
+@@ -24,118 +145,49 @@ def password_hash(args: Namespace) -> None:
+     password = data['password']
+     salt_password = data['salt_password']
+ 
+-    if not salt_password:
+-        salt = bcrypt.gensalt()
+-    else:
+-        salt = salt_password.encode()
+-
+-    hash_str = bcrypt.hashpw(password.encode(), salt).decode()
++    hash_str = InternalCryptoCaller().password_hash(password, salt_password)
+     json.dump({'hash': hash_str}, sys.stdout)
+ 
+ 
+ def verify_password(args: Namespace) -> None:
++    icc = InternalCryptoCaller()
+     data = json.loads(sys.stdin.read())
+-    password = data.encode('utf-8')
+-    hashed_password = data.encode('utf-8')
++    password = data.get('password', '')
++    hashed_password = data.get('hashed_password', '')
+     try:
+-        ok = bcrypt.checkpw(password, hashed_password)
++        icc.verify_password(password, hashed_password)
+     except ValueError as err:
+         _fail_message(str(err))
+     json.dump({'ok': ok}, sys.stdout)
+ 
+ 
+ def create_self_signed_cert(args: Namespace) -> None:
+-
++    icc = InternalCryptoCaller()
+     # Generate private key
+     if args.private_key:
+         # create a key pair
+-        pkey = crypto.PKey()
+-        pkey.generate_key(crypto.TYPE_RSA, 2048)
+-        print(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode())
++        print(icc.create_private_key())
+         return
+ 
+     data = json.loads(sys.stdin.read())
+-
+     dname = data['dname']
+-    pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, data['private_key'])
+-
+-    # Create a "subject" object
+-    with warnings.catch_warnings():
+-        warnings.simplefilter("ignore")
+-        req = crypto.X509Req()
+-    subj = req.get_subject()
+-
+-    # populate the subject with the dname settings
+-    for k, v in dname.items():
+-        setattr(subj, k, v)
+-
+-    # create a self-signed cert
+-    cert = crypto.X509()
+-    cert.set_subject(req.get_subject())
+-    cert.set_serial_number(int(uuid4()))
+-    cert.gmtime_adj_notBefore(0)
+-    cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
+-    cert.set_issuer(cert.get_subject())
+-    cert.set_pubkey(pkey)
+-    cert.sign(pkey, 'sha512')
+-
+-    print(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode())
+-
+-
+-def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]:
+-    """Basic validation of a CA cert
+-    """
+-
+-    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-    (org_name, cn) = (None, None)
+-    cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-    components = cert.get_issuer().get_components()
+-    for c in components:
+-        if c[0].decode() == 'O':  # org comp
+-            org_name = c[1].decode()
+-        elif c[0].decode() == 'CN':  # common name comp
+-            cn = c[1].decode()
+-
+-    return (org_name, cn)
++    print(icc.create_self_signed_cert(dname, data['private_key']))
+ 
+ 
+ def certificate_days_to_expire(args: Namespace) -> None:
++    icc = InternalCryptoCaller()
+     crt = sys.stdin.read()
+-
+-    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-    x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-    no_after = x509.get_notAfter()
+-    if not no_after:
+-        print("Certificate does not have an expiration date.", file=sys.stderr)
+-        sys.exit(1)
+-
+-    end_date = datetime.datetime.strptime(no_after.decode(), '%Y%m%d%H%M%SZ')
+-
+-    if x509.has_expired():
+-        org, cn = _get_cert_issuer_info(crt)
+-        msg = 'Certificate issued by "%s/%s" expired on %s' % (org, cn, end_date)
+-        print(msg, file=sys.stderr)
++    try:
++        days_until_exp = icc.certificate_days_to_expire(crt)
++    except InternalError as err:
++        print(str(err), file=sys.stderr)
+         sys.exit(1)
+-
+-    # Certificate still valid, calculate and return days until expiration
+-    with warnings.catch_warnings():
+-        warnings.simplefilter("ignore")
+-        days_until_exp = (end_date - datetime.datetime.utcnow()).days
+-        json.dump({'days_until_expiration': int(days_until_exp)}, sys.stdout)
++    json.dump({'days_until_expiration': days_until_exp}, sys.stdout)
+ 
+ 
+ def get_cert_issuer_info(args: Namespace) -> None:
+     crt = sys.stdin.read()
+-
+-    crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-    (org_name, cn) = (None, None)
+-    cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-    components = cert.get_issuer().get_components()
+-    for c in components:
+-        if c[0].decode() == 'O':  # org comp
+-            org_name = c[1].decode()
+-        elif c[0].decode() == 'CN':  # common name comp
+-            cn = c[1].decode()
++    org_name, cn = InternalCryptoCaller().get_cert_issuer_info(crt)
+     json.dump({'org_name': org_name, 'cn': cn}, sys.stdout)
+ 
+ 
+@@ -151,28 +203,9 @@ def verify_tls(args: Namespace) -> None:
+     key = data['key']
+ 
+     try:
+-        _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
+-        _key.check()
+-    except (ValueError, crypto.Error) as e:
+-        _fail_message('Invalid private key: %s' % str(e))
+-    try:
+-        crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-        _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-    except ValueError as e:
+-        _fail_message('Invalid certificate key: %s' % str(e))
+-
+-    try:
+-        context = SSL.Context(SSL.TLSv1_METHOD)
+-        with warnings.catch_warnings():
+-            warnings.simplefilter("ignore")
+-            context.use_certificate(_crt)
+-            context.use_privatekey(_key)
+-
+-        context.check_privatekey()
+-    except crypto.Error as e:
+-        _fail_message('Private key and certificate do not match up: %s' % str(e))
+-    except SSL.Error as e:
+-        _fail_message(f'Invalid cert/key pair: {e}')
++        InternalCryptoCaller().verify_tls(crt, key)
++    except ValueError as err:
++        json.dump({'error': str(err)}, sys.stdout)
+     json.dump({'ok': True}, sys.stdout)  # need to emit something on success
+ 
+ 
diff --git a/patches/0050-python-common-cryptotools-use-a-main-function.patch b/patches/0050-python-common-cryptotools-use-a-main-function.patch
new file mode 100644
index 0000000000..79fc5b6efa
--- /dev/null
+++ b/patches/0050-python-common-cryptotools-use-a-main-function.patch
@@ -0,0 +1,49 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Mon, 21 Apr 2025 15:50:22 -0400
+Subject: [PATCH 50/57] python-common/cryptotools: use a main function
+
+Use a main function to encapsulate the cli parsing rather than a block
+of code in module scope.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/python-common/ceph/cryptotools/cryptotools.py | 14 +++++++++++---
+ 1 file changed, 11 insertions(+), 3 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 52c28d3f6ec..979e664c1d3 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -209,7 +209,7 @@ def verify_tls(args: Namespace) -> None:
+     json.dump({'ok': True}, sys.stdout)  # need to emit something on success
+ 
+ 
+-if __name__ == "__main__":
++def main():
+     # create the top-level parser
+     parser = argparse.ArgumentParser(prog='cryptotools.py')
+     subparsers = parser.add_subparsers(required=True)
+@@ -220,8 +220,12 @@ if __name__ == "__main__":
+ 
+     # create the parser for the "create_self_signed_cert" command
+     parser_cssc = subparsers.add_parser('create_self_signed_cert')
+-    parser_cssc.add_argument('--private_key', required=False, action='store_true')
+-    parser_cssc.add_argument('--certificate', required=False, action='store_true')
++    parser_cssc.add_argument(
++        '--private_key', required=False, action='store_true'
++    )
++    parser_cssc.add_argument(
++        '--certificate', required=False, action='store_true'
++    )
+     parser_cssc.set_defaults(func=create_self_signed_cert)
+ 
+     # create the parser for the "certificate_days_to_expire" command
+@@ -243,3 +247,7 @@ if __name__ == "__main__":
+     # parse the args and call whatever function was selected
+     args = parser.parse_args()
+     args.func(args)
++
++
++if __name__ == "__main__":
++    main()
diff --git a/patches/0051-python-common-cryptotools-unify-and-organize-all-end.patch b/patches/0051-python-common-cryptotools-unify-and-organize-all-end.patch
new file mode 100644
index 0000000000..3cfa0457b4
--- /dev/null
+++ b/patches/0051-python-common-cryptotools-unify-and-organize-all-end.patch
@@ -0,0 +1,185 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 24 Apr 2025 14:36:58 -0400
+Subject: [PATCH 51/57] python-common/cryptotools: unify and organize all
+ endpoint functions
+
+Lightly reorganize and make the "endpoint" functions in cryptotools.py more
+consistent and uniform. Use small functions for input and output
+handling so that the handling is done the same way throughout. Pass a
+pre-constructed crypto caller via the args to then endpoint functions.
+Make generating the private key it's own named function rather than
+one single (and only) function with overloaded behavior controlled by
+a cli switch.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/cryptotools/cryptotools.py           | 99 ++++++++++---------
+ src/python-common/ceph/cryptotools/remote.py  |  2 +-
+ 2 files changed, 53 insertions(+), 48 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 979e664c1d3..1466d4b606d 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -138,80 +138,88 @@ class InternalCryptoCaller:
+             self.fail(f'Invalid cert/key pair: {e}')
+ 
+ 
+-# subcommand functions
+-def password_hash(args: Namespace) -> None:
+-    data = json.loads(sys.stdin.read())
++def _read() -> str:
++    return sys.stdin.read()
++
++
++def _load() -> Dict[str, Any]:
++    return json.loads(_read())
++
++
++def _respond(data: Dict[str, Any]) -> None:
++    json.dump(data, sys.stdout)
++
++
++def _write(content: str) -> None:
++    sys.stdout.write(content)
++    sys.stdout.flush()
++
++
++def _fail(msg: str, code: int = 0) -> Any:
++    json.dump({'error': msg}, sys.stdout)
++    sys.exit(code)
++
+ 
++def password_hash(args: Namespace) -> None:
++    data = _load()
+     password = data['password']
+     salt_password = data['salt_password']
+-
+-    hash_str = InternalCryptoCaller().password_hash(password, salt_password)
+-    json.dump({'hash': hash_str}, sys.stdout)
++    hash_str = args.crypto.password_hash(password, salt_password)
++    _respond({'hash': hash_str})
+ 
+ 
+ def verify_password(args: Namespace) -> None:
+-    icc = InternalCryptoCaller()
+-    data = json.loads(sys.stdin.read())
++    data = _load()
+     password = data.get('password', '')
+     hashed_password = data.get('hashed_password', '')
+     try:
+-        icc.verify_password(password, hashed_password)
++        ok = args.crypto.verify_password(password, hashed_password)
+     except ValueError as err:
+-        _fail_message(str(err))
+-    json.dump({'ok': ok}, sys.stdout)
++        _fail(str(err))
++    _respond({'ok': ok})
++
++
++def create_private_key(args: Namespace) -> None:
++    _write(args.crypto.create_private_key())
+ 
+ 
+ def create_self_signed_cert(args: Namespace) -> None:
+-    icc = InternalCryptoCaller()
+-    # Generate private key
+-    if args.private_key:
+-        # create a key pair
+-        print(icc.create_private_key())
+-        return
+-
+-    data = json.loads(sys.stdin.read())
++    data = _load()
+     dname = data['dname']
+-    print(icc.create_self_signed_cert(dname, data['private_key']))
++    private_key = data['private_key']
++    _write(args.crypto.create_self_signed_cert(dname, private_key))
+ 
+ 
+ def certificate_days_to_expire(args: Namespace) -> None:
+-    icc = InternalCryptoCaller()
+-    crt = sys.stdin.read()
++    crt = _read()
+     try:
+-        days_until_exp = icc.certificate_days_to_expire(crt)
++        days_until_exp = args.crypto.certificate_days_to_expire(crt)
+     except InternalError as err:
+-        print(str(err), file=sys.stderr)
+-        sys.exit(1)
+-    json.dump({'days_until_expiration': days_until_exp}, sys.stdout)
++        _fail(str(err))
++    _respond({'days_until_expiration': days_until_exp})
+ 
+ 
+ def get_cert_issuer_info(args: Namespace) -> None:
+-    crt = sys.stdin.read()
+-    org_name, cn = InternalCryptoCaller().get_cert_issuer_info(crt)
+-    json.dump({'org_name': org_name, 'cn': cn}, sys.stdout)
+-
+-
+-def _fail_message(msg: str) -> None:
+-    json.dump({'error': msg}, sys.stdout)
+-    sys.exit(0)
++    crt = _read()
++    org_name, cn = args.crypto.get_cert_issuer_info(crt)
++    _respond({'org_name': org_name, 'cn': cn})
+ 
+ 
+ def verify_tls(args: Namespace) -> None:
+-    data = json.loads(sys.stdin.read())
+-
++    data = _load()
+     crt = data['crt']
+     key = data['key']
+-
+     try:
+-        InternalCryptoCaller().verify_tls(crt, key)
++        args.crypto.verify_tls(crt, key)
+     except ValueError as err:
+-        json.dump({'error': str(err)}, sys.stdout)
+-    json.dump({'ok': True}, sys.stdout)  # need to emit something on success
++        _fail(str(err))
++    _respond({'ok': True})  # need to emit something on success
+ 
+ 
+-def main():
++def main() -> None:
+     # create the top-level parser
+     parser = argparse.ArgumentParser(prog='cryptotools.py')
++    parser.set_defaults(crypto=InternalCryptoCaller())
+     subparsers = parser.add_subparsers(required=True)
+ 
+     # create the parser for the "password_hash" command
+@@ -220,14 +228,11 @@ def main():
+ 
+     # create the parser for the "create_self_signed_cert" command
+     parser_cssc = subparsers.add_parser('create_self_signed_cert')
+-    parser_cssc.add_argument(
+-        '--private_key', required=False, action='store_true'
+-    )
+-    parser_cssc.add_argument(
+-        '--certificate', required=False, action='store_true'
+-    )
+     parser_cssc.set_defaults(func=create_self_signed_cert)
+ 
++    parser_cpk = subparsers.add_parser('create_private_key')
++    parser_cpk.set_defaults(func=create_private_key)
++
+     # create the parser for the "certificate_days_to_expire" command
+     parser_dte = subparsers.add_parser('certificate_days_to_expire')
+     parser_dte.set_defaults(func=certificate_days_to_expire)
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 40e01d19912..76438b3d132 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -97,7 +97,7 @@ class CryptoCaller:
+     def create_private_key(self) -> str:
+         """Create a new TLS private key, returning it as a string."""
+         result = self._run(
+-            ['create_self_signed_cert', '--private_key'],
++            ['create_private_key'],
+             capture_output=True,
+             check=True,
+         )
diff --git a/patches/0052-python-common-cryptotools-add-caller-module-for-base.patch b/patches/0052-python-common-cryptotools-add-caller-module-for-base.patch
new file mode 100644
index 0000000000..e6c8398259
--- /dev/null
+++ b/patches/0052-python-common-cryptotools-add-caller-module-for-base.patch
@@ -0,0 +1,66 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 24 Apr 2025 14:55:38 -0400
+Subject: [PATCH 52/57] python-common/cryptotools: add caller module for base
+ class
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/python-common/ceph/cryptotools/caller.py | 48 ++++++++++++++++++++
+ 1 file changed, 48 insertions(+)
+ create mode 100644 src/python-common/ceph/cryptotools/caller.py
+
+diff --git a/src/python-common/ceph/cryptotools/caller.py b/src/python-common/ceph/cryptotools/caller.py
+new file mode 100644
+index 00000000000..42147e5573b
+--- /dev/null
++++ b/src/python-common/ceph/cryptotools/caller.py
+@@ -0,0 +1,48 @@
++from typing import Dict, Tuple
++
++import abc
++
++
++class CryptoCallError(ValueError):
++    pass
++
++
++class CryptoCaller(abc.ABC):
++    """Abstract base class for `CryptoCaller`s - an interface that
++    encapsulates basic password and TLS cert related functions
++    needed by the Ceph MGR.
++    """
++
++    @abc.abstractmethod
++    def create_private_key(self) -> str:
++        """Create a new TLS private key, returning it as a string."""
++
++    @abc.abstractmethod
++    def create_self_signed_cert(
++        self, dname: Dict[str, str], pkey: str
++    ) -> str:
++        """Given TLS certificate subject parameters and a private key,
++        create a new self signed certificate - returned as a string.
++        """
++
++    @abc.abstractmethod
++    def verify_tls(self, crt: str, key: str) -> None:
++        """Given a TLS certificate and a private key raise an error
++        if the combination is not valid.
++        """
++
++    @abc.abstractmethod
++    def certificate_days_to_expire(self, crt: str) -> int:
++        """Return the number of days until the given TLS certificate expires."""
++
++    @abc.abstractmethod
++    def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]:
++        """Basic validation of a ca cert"""
++
++    @abc.abstractmethod
++    def password_hash(self, password: str, salt_password: str) -> str:
++        """Hash a password. Returns the hashed password as a string."""
++
++    @abc.abstractmethod
++    def verify_password(self, password: str, hashed_password: str) -> bool:
++        """Return true if a password and hash match."""
diff --git a/patches/0053-python-common-cryptotools-move-internal-crypto-calle.patch b/patches/0053-python-common-cryptotools-move-internal-crypto-calle.patch
new file mode 100644
index 0000000000..b79ee92c00
--- /dev/null
+++ b/patches/0053-python-common-cryptotools-move-internal-crypto-calle.patch
@@ -0,0 +1,301 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 24 Apr 2025 14:56:58 -0400
+Subject: [PATCH 53/57] python-common/cryptotools: move internal crypto caller
+ to new file
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../ceph/cryptotools/cryptotools.py           | 131 +----------------
+ .../ceph/cryptotools/internal.py              | 135 ++++++++++++++++++
+ 2 files changed, 139 insertions(+), 127 deletions(-)
+ create mode 100644 src/python-common/ceph/cryptotools/internal.py
+
+diff --git a/src/python-common/ceph/cryptotools/cryptotools.py b/src/python-common/ceph/cryptotools/cryptotools.py
+index 1466d4b606d..4aae0d8c933 100644
+--- a/src/python-common/ceph/cryptotools/cryptotools.py
++++ b/src/python-common/ceph/cryptotools/cryptotools.py
+@@ -4,138 +4,15 @@ in a subprocess therefore sidestepping the
+ `PyO3 modules may only be initialized once per interpreter process` problem.
+ """
+ 
++from typing import Any, Dict
++
+ import argparse
+-import bcrypt
+-import datetime
+ import json
+ import sys
+-import warnings
+ 
+ from argparse import Namespace
+-from OpenSSL import crypto, SSL
+-from uuid import uuid4
+-from typing import Tuple, Any, Dict, Union
+-
+-
+-class InternalError(ValueError):
+-    pass
+-
+-
+-class InternalCryptoCaller:
+-    def fail(self, msg: str) -> None:
+-        raise ValueError(msg)
+-
+-    def password_hash(self, password: str, salt_password: str) -> str:
+-        salt = salt_password.encode() if salt_password else bcrypt.gensalt()
+-        return bcrypt.hashpw(password.encode(), salt).decode()
+-
+-    def verify_password(self, password: str, hashed_password: str) -> bool:
+-        _password = password.encode()
+-        _hashed_password = hashed_password.encode()
+-        try:
+-            ok = bcrypt.checkpw(_password, _hashed_password)
+-        except ValueError as err:
+-            self.fail(str(err))
+-        return ok
+-
+-    def create_private_key(self) -> str:
+-        pkey = crypto.PKey()
+-        pkey.generate_key(crypto.TYPE_RSA, 2048)
+-        return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode()
+-
+-    def create_self_signed_cert(
+-        self, dname: Dict[str, str], pkey: str
+-    ) -> str:
+-        _pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey)
+-
+-        # Create a "subject" object
+-        with warnings.catch_warnings():
+-            warnings.simplefilter("ignore")
+-            req = crypto.X509Req()
+-        subj = req.get_subject()
+-
+-        # populate the subject with the dname settings
+-        for k, v in dname.items():
+-            setattr(subj, k, v)
+-
+-        # create a self-signed cert
+-        cert = crypto.X509()
+-        cert.set_subject(req.get_subject())
+-        cert.set_serial_number(int(uuid4()))
+-        cert.gmtime_adj_notBefore(0)
+-        cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
+-        cert.set_issuer(cert.get_subject())
+-        cert.set_pubkey(_pkey)
+-        cert.sign(_pkey, 'sha512')
+-        return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
+-
+-    def _load_cert(self, crt: Union[str, bytes]) -> Any:
+-        crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-        cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
+-        return cert
+-
+-    def _issuer_info(self, cert: Any) -> Tuple[str, str]:
+-        components = cert.get_issuer().get_components()
+-        org_name = cn = ''
+-        for c in components:
+-            if c[0].decode() == 'O':  # org comp
+-                org_name = c[1].decode()
+-            elif c[0].decode() == 'CN':  # common name comp
+-                cn = c[1].decode()
+-        return (org_name, cn)
+-
+-    def certificate_days_to_expire(self, crt: str) -> int:
+-        x509 = self._load_cert(crt)
+-        no_after = x509.get_notAfter()
+-        if not no_after:
+-            self.fail("Certificate does not have an expiration date.")
+-
+-        end_date = datetime.datetime.strptime(
+-            no_after.decode(), '%Y%m%d%H%M%SZ'
+-        )
+-
+-        if x509.has_expired():
+-            org, cn = self._issuer_info(x509)
+-            msg = 'Certificate issued by "%s/%s" expired on %s' % (
+-                org,
+-                cn,
+-                end_date,
+-            )
+-            self.fail(msg)
+-
+-        # Certificate still valid, calculate and return days until expiration
+-        with warnings.catch_warnings():
+-            warnings.simplefilter("ignore")
+-            days_until_exp = (end_date - datetime.datetime.utcnow()).days
+-        return int(days_until_exp)
+-
+-    def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]:
+-        return self._issuer_info(self._load_cert(crt))
+-
+-    def verify_tls(self, crt: str, key: str) -> None:
+-        try:
+-            _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
+-            _key.check()
+-        except (ValueError, crypto.Error) as e:
+-            self.fail('Invalid private key: %s' % str(e))
+-        try:
+-            _crt = self._load_cert(crt)
+-        except ValueError as e:
+-            self.fail('Invalid certificate key: %s' % str(e))
+-
+-        try:
+-            context = SSL.Context(SSL.TLSv1_METHOD)
+-            with warnings.catch_warnings():
+-                warnings.simplefilter("ignore")
+-                context.use_certificate(_crt)
+-                context.use_privatekey(_key)
+-            context.check_privatekey()
+-        except crypto.Error as e:
+-            self.fail(
+-                'Private key and certificate do not match up: %s' % str(e)
+-            )
+-        except SSL.Error as e:
+-            self.fail(f'Invalid cert/key pair: {e}')
++
++from .internal import InternalCryptoCaller, InternalError
+ 
+ 
+ def _read() -> str:
+diff --git a/src/python-common/ceph/cryptotools/internal.py b/src/python-common/ceph/cryptotools/internal.py
+new file mode 100644
+index 00000000000..2de8d742ced
+--- /dev/null
++++ b/src/python-common/ceph/cryptotools/internal.py
+@@ -0,0 +1,135 @@
++"""Internal execution of cryptographic functions for the ceph mgr
++"""
++
++from typing import Dict, Any, Tuple, Union
++
++from uuid import uuid4
++import datetime
++import warnings
++
++from OpenSSL import crypto, SSL
++import bcrypt
++
++
++from .caller import CryptoCaller, CryptoCallError
++
++
++class InternalError(CryptoCallError):
++    pass
++
++
++class InternalCryptoCaller(CryptoCaller):
++    def fail(self, msg: str) -> None:
++        raise InternalError(msg)
++
++    def password_hash(self, password: str, salt_password: str) -> str:
++        salt = salt_password.encode() if salt_password else bcrypt.gensalt()
++        return bcrypt.hashpw(password.encode(), salt).decode()
++
++    def verify_password(self, password: str, hashed_password: str) -> bool:
++        _password = password.encode()
++        _hashed_password = hashed_password.encode()
++        try:
++            ok = bcrypt.checkpw(_password, _hashed_password)
++        except ValueError as err:
++            self.fail(str(err))
++        return ok
++
++    def create_private_key(self) -> str:
++        pkey = crypto.PKey()
++        pkey.generate_key(crypto.TYPE_RSA, 2048)
++        return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode()
++
++    def create_self_signed_cert(
++        self, dname: Dict[str, str], pkey: str
++    ) -> str:
++        _pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, pkey)
++
++        # Create a "subject" object
++        with warnings.catch_warnings():
++            warnings.simplefilter("ignore")
++            req = crypto.X509Req()
++        subj = req.get_subject()
++
++        # populate the subject with the dname settings
++        for k, v in dname.items():
++            setattr(subj, k, v)
++
++        # create a self-signed cert
++        cert = crypto.X509()
++        cert.set_subject(req.get_subject())
++        cert.set_serial_number(int(uuid4()))
++        cert.gmtime_adj_notBefore(0)
++        cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60)  # 10 years
++        cert.set_issuer(cert.get_subject())
++        cert.set_pubkey(_pkey)
++        cert.sign(_pkey, 'sha512')
++        return crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
++
++    def _load_cert(self, crt: Union[str, bytes]) -> Any:
++        crt_buffer = crt.encode() if isinstance(crt, str) else crt
++        cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++        return cert
++
++    def _issuer_info(self, cert: Any) -> Tuple[str, str]:
++        components = cert.get_issuer().get_components()
++        org_name = cn = ''
++        for c in components:
++            if c[0].decode() == 'O':  # org comp
++                org_name = c[1].decode()
++            elif c[0].decode() == 'CN':  # common name comp
++                cn = c[1].decode()
++        return (org_name, cn)
++
++    def certificate_days_to_expire(self, crt: str) -> int:
++        x509 = self._load_cert(crt)
++        no_after = x509.get_notAfter()
++        if not no_after:
++            self.fail("Certificate does not have an expiration date.")
++
++        end_date = datetime.datetime.strptime(
++            no_after.decode(), '%Y%m%d%H%M%SZ'
++        )
++
++        if x509.has_expired():
++            org, cn = self._issuer_info(x509)
++            msg = 'Certificate issued by "%s/%s" expired on %s' % (
++                org,
++                cn,
++                end_date,
++            )
++            self.fail(msg)
++
++        # Certificate still valid, calculate and return days until expiration
++        with warnings.catch_warnings():
++            warnings.simplefilter("ignore")
++            days_until_exp = (end_date - datetime.datetime.utcnow()).days
++        return int(days_until_exp)
++
++    def get_cert_issuer_info(self, crt: str) -> Tuple[str, str]:
++        return self._issuer_info(self._load_cert(crt))
++
++    def verify_tls(self, crt: str, key: str) -> None:
++        try:
++            _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
++            _key.check()
++        except (ValueError, crypto.Error) as e:
++            self.fail('Invalid private key: %s' % str(e))
++        try:
++            _crt = self._load_cert(crt)
++        except ValueError as e:
++            self.fail('Invalid certificate key: %s' % str(e))
++
++        try:
++            context = SSL.Context(SSL.TLSv1_METHOD)
++            with warnings.catch_warnings():
++                warnings.simplefilter("ignore")
++                context.use_certificate(_crt)
++                context.use_privatekey(_key)
++            context.check_privatekey()
++        except crypto.Error as e:
++            self.fail(
++                'Private key and certificate do not match up: %s' % str(e)
++            )
++        except SSL.Error as e:
++            self.fail(f'Invalid cert/key pair: {e}')
diff --git a/patches/0054-python-common-cryptotools-create-module-for-selectin.patch b/patches/0054-python-common-cryptotools-create-module-for-selectin.patch
new file mode 100644
index 0000000000..ba0fac3ea1
--- /dev/null
+++ b/patches/0054-python-common-cryptotools-create-module-for-selectin.patch
@@ -0,0 +1,187 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Thu, 24 Apr 2025 15:17:50 -0400
+Subject: [PATCH 54/57] python-common/cryptotools: create module for selecting
+ crypto caller
+
+Add a module to select a desired crypto caller. Update the callers
+to use the crypto caller interface.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ .../mgr/dashboard/services/access_control.py  |  4 +-
+ src/pybind/mgr/mgr_util.py                    | 12 ++---
+ src/python-common/ceph/cryptotools/remote.py  | 11 ++--
+ src/python-common/ceph/cryptotools/select.py  | 51 +++++++++++++++++++
+ 4 files changed, 64 insertions(+), 14 deletions(-)
+ create mode 100644 src/python-common/ceph/cryptotools/select.py
+
+diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py
+index 73955e7c3bd..282742d84b5 100644
+--- a/src/pybind/mgr/dashboard/services/access_control.py
++++ b/src/pybind/mgr/dashboard/services/access_control.py
+@@ -23,7 +23,7 @@ from ..exceptions import PasswordPolicyException, PermissionNotValid, \
+ from ..security import Permission, Scope
+ from ..settings import Settings
+ 
+-import ceph.cryptotools.remote
++from ceph.cryptotools.select import get_crypto_caller
+ 
+ logger = logging.getLogger('access_control')
+ DEFAULT_FILE_DESC = 'password/secret'
+@@ -891,7 +891,7 @@ def ac_user_set_password_hash(_, username: str, inbuf: str):
+     try:
+         # make sure the hashed_password is actually a bcrypt hash
+         # catch a ValueError if hashed_password is not valid.
+-        cc = ceph.cryptotools.remote.CryptoCaller()
++        cc = get_crypto_caller()
+         cc.verify_password('', hashed_password)
+ 
+         user = mgr.ACCESS_CTRL_DB.get_user(username)
+diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py
+index c58304d0de7..748859682b8 100644
+--- a/src/pybind/mgr/mgr_util.py
++++ b/src/pybind/mgr/mgr_util.py
+@@ -24,7 +24,7 @@ else:
+ from typing import Tuple, Any, Callable, Optional, Dict, TYPE_CHECKING, TypeVar, List, Iterable, Generator, Generic, Iterator
+ 
+ from ceph.deployment.utils import wrap_ipv6
+-import ceph.cryptotools.remote
++from ceph.cryptotools.select import get_crypto_caller
+ 
+ T = TypeVar('T')
+ 
+@@ -534,7 +534,7 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+     else:
+         dname = {"O": organisation, "CN": common_name}
+ 
+-    cc = ceph.cryptotools.remote.CryptoCaller()
++    cc = get_crypto_caller()
+     pkey = cc.create_private_key()
+     cert = cc.create_self_signed_cert(dname, pkey)
+     return cert, pkey
+@@ -542,7 +542,7 @@ def create_self_signed_cert(organisation: str = 'Ceph',
+ 
+ def certificate_days_to_expire(crt: str) -> int:
+     try:
+-        cc = ceph.cryptotools.remote.CryptoCaller()
++        cc = get_crypto_caller()
+         return cc.certificate_days_to_expire(crt)
+     except ValueError as err:
+         raise ServerConfigException(f'Invalid certificate: {err}')
+@@ -566,7 +566,7 @@ def verify_cacrt(cert_fname):
+ 
+ def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]:
+     """Basic validation of a ca cert"""
+-    cc = ceph.cryptotools.remote.CryptoCaller()
++    cc = get_crypto_caller()
+     try:
+         return cc.get_cert_issuer_info(crt)
+     except ValueError as err:
+@@ -574,7 +574,7 @@ def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]:
+ 
+ def verify_tls(crt, key):
+     # type: (str, str) -> int
+-    cc = ceph.cryptotools.remote.CryptoCaller()
++    cc = get_crypto_caller()
+     try:
+         days_to_expiration = cc.certificate_days_to_expire(crt)
+         cc.verify_tls(crt, key)
+@@ -819,5 +819,5 @@ def password_hash(password: Optional[str], salt_password: Optional[str] = None)
+     if not salt_password:
+         salt_password = ''
+ 
+-    cc = ceph.cryptotools.remote.CryptoCaller()
++    cc = get_crypto_caller()
+     return cc.password_hash(password, salt_password)
+diff --git a/src/python-common/ceph/cryptotools/remote.py b/src/python-common/ceph/cryptotools/remote.py
+index 76438b3d132..2574b4ecdac 100644
+--- a/src/python-common/ceph/cryptotools/remote.py
++++ b/src/python-common/ceph/cryptotools/remote.py
+@@ -1,5 +1,6 @@
+ """Remote execution of cryptographic functions for the ceph mgr
+ """
++
+ # NB. This module exists to enapsulate the logic around running
+ # the cryptotools module that are forked off of the parent process
+ # to avoid the pyo3 subintepreters problem.
+@@ -23,18 +24,16 @@ import json
+ import logging
+ import subprocess
+ 
++from .caller import CryptoCaller, CryptoCallError
++
+ 
+ _ctmodule = 'ceph.cryptotools.cryptotools'
+ 
+ logger = logging.getLogger('ceph.cryptotools.remote')
+ 
+ 
+-class CryptoCallError(ValueError):
+-    pass
+-
+-
+-class CryptoCaller:
+-    """CryptoCaller encapsulates cryptographic functions used by the
++class ProcessCryptoCaller(CryptoCaller):
++    """ProcessCryptoCaller encapsulates cryptographic functions used by the
+     ceph mgr into a suite of functions that can be executed in a
+     different process.
+     Running the crypto functions in a separate process avoids conflicts
+diff --git a/src/python-common/ceph/cryptotools/select.py b/src/python-common/ceph/cryptotools/select.py
+new file mode 100644
+index 00000000000..989382ce983
+--- /dev/null
++++ b/src/python-common/ceph/cryptotools/select.py
+@@ -0,0 +1,51 @@
++from typing import Dict
++
++import os
++
++from .caller import CryptoCaller
++
++
++_CC_ENV = 'CEPH_CRYPTOCALLER'
++_CC_KEY = 'crypto_caller'
++_CC_REMOTE = 'remote'
++_CC_INTERNAL = 'internal'
++
++_CACHE: Dict[str, CryptoCaller] = {}
++
++
++def _check_name(name: str) -> None:
++    if name and name not in (_CC_REMOTE, _CC_INTERNAL):
++        raise ValueError(f'unexpected crypto caller name: {name}')
++
++
++def choose_crypto_caller(name: str = '') -> None:
++    _check_name(name)
++    if not name:
++        name = os.environ.get(_CC_ENV, '')
++        _check_name(name)
++    if not name:
++        name = _CC_REMOTE
++
++    if name == _CC_REMOTE:
++        import ceph.cryptotools.remote
++
++        _CACHE[_CC_KEY] = ceph.cryptotools.remote.ProcessCryptoCaller()
++        return
++    if name == _CC_INTERNAL:
++        import ceph.cryptotools.internal
++
++        _CACHE[_CC_KEY] = ceph.cryptotools.internal.InternalCryptoCaller()
++        return
++    # should be unreachable
++    raise RuntimeError('failed to setup a valid crypto caller')
++
++
++def get_crypto_caller() -> CryptoCaller:
++    """Return the currently selected crypto caller object."""
++    caller = _CACHE.get(_CC_KEY)
++    if not caller:
++        choose_crypto_caller()
++        caller = _CACHE.get(_CC_KEY)
++        if caller is None:
++            raise RuntimeError('failed to select crypto caller')
++    return caller
diff --git a/patches/0055-python-common-cryptotools-catch-all-failures-to-read.patch b/patches/0055-python-common-cryptotools-catch-all-failures-to-read.patch
new file mode 100644
index 0000000000..51e4ab1e2e
--- /dev/null
+++ b/patches/0055-python-common-cryptotools-catch-all-failures-to-read.patch
@@ -0,0 +1,44 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Fri, 25 Apr 2025 11:05:46 -0400
+Subject: [PATCH 55/57] python-common/cryptotools: catch all failures to read
+ cert
+
+Previously, the internal crypto caller would catch (and convert) some
+errors when reading the cert but not all cases. Move the logic to catch
+the errors to a common location and do it once consistently.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/python-common/ceph/cryptotools/internal.py | 11 +++++------
+ 1 file changed, 5 insertions(+), 6 deletions(-)
+
+diff --git a/src/python-common/ceph/cryptotools/internal.py b/src/python-common/ceph/cryptotools/internal.py
+index 2de8d742ced..7d6e0a487ec 100644
+--- a/src/python-common/ceph/cryptotools/internal.py
++++ b/src/python-common/ceph/cryptotools/internal.py
+@@ -68,7 +68,10 @@ class InternalCryptoCaller(CryptoCaller):
+ 
+     def _load_cert(self, crt: Union[str, bytes]) -> Any:
+         crt_buffer = crt.encode() if isinstance(crt, str) else crt
+-        cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++        try:
++            cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer)
++        except (ValueError, crypto.Error) as e:
++            self.fail('Invalid certificate: %s' % str(e))
+         return cert
+ 
+     def _issuer_info(self, cert: Any) -> Tuple[str, str]:
+@@ -115,11 +118,7 @@ class InternalCryptoCaller(CryptoCaller):
+             _key.check()
+         except (ValueError, crypto.Error) as e:
+             self.fail('Invalid private key: %s' % str(e))
+-        try:
+-            _crt = self._load_cert(crt)
+-        except ValueError as e:
+-            self.fail('Invalid certificate key: %s' % str(e))
+-
++        _crt = self._load_cert(crt)
+         try:
+             context = SSL.Context(SSL.TLSv1_METHOD)
+             with warnings.catch_warnings():
diff --git a/patches/0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch b/patches/0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch
new file mode 100644
index 0000000000..ec3886c5dc
--- /dev/null
+++ b/patches/0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch
@@ -0,0 +1,39 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Fri, 25 Apr 2025 11:06:41 -0400
+Subject: [PATCH 56/57] mgr/cephadm: always use the internal cryptocaller
+
+The cephadm modules needs to use python cryptography module for ssh (via
+asyncssh) and thus there's no need to use the remote crypto caller in
+cephadm. Configure cephadm to always use the internal cryptocaller.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/pybind/mgr/cephadm/module.py | 7 ++++++-
+ 1 file changed, 6 insertions(+), 1 deletion(-)
+
+diff --git a/src/pybind/mgr/cephadm/module.py b/src/pybind/mgr/cephadm/module.py
+index a8b71a1081e..c8319da8cd0 100644
+--- a/src/pybind/mgr/cephadm/module.py
++++ b/src/pybind/mgr/cephadm/module.py
+@@ -35,7 +35,8 @@ from ceph.deployment.service_spec import \
+     HostPlacementSpec, IngressSpec, \
+     TunedProfileSpec, IscsiServiceSpec
+ from ceph.utils import str_to_datetime, datetime_to_str, datetime_now
+-from cephadm.serve import CephadmServe
++from ceph.cryptotools.select import choose_crypto_caller
++from cephadm.serve import CephadmServe, REQUIRES_POST_ACTIONS
+ from cephadm.services.cephadmservice import CephadmDaemonDeploySpec
+ from cephadm.http_server import CephadmHttpServer
+ from cephadm.agent import CephadmAgentHelpers
+@@ -545,6 +546,10 @@ class CephadmOrchestrator(orchestrator.Orchestrator, MgrModule,
+         super(CephadmOrchestrator, self).__init__(*args, **kwargs)
+         self._cluster_fsid: str = self.get('mon_map')['fsid']
+         self.last_monmap: Optional[datetime.datetime] = None
++        # cephadm module always needs access to the real cryptography module
++        # for asyncssh. It is always permitted to use the internal
++        # cryptocaller.
++        choose_crypto_caller('internal')
+ 
+         # for serve()
+         self.run = True
diff --git a/patches/0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch b/patches/0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch
new file mode 100644
index 0000000000..519b151778
--- /dev/null
+++ b/patches/0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch
@@ -0,0 +1,78 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: John Mulligan <jmulligan at redhat.com>
+Date: Fri, 25 Apr 2025 11:22:26 -0400
+Subject: [PATCH 57/57] mgr/dashboard: add an option to control the dashboard
+ crypto caller
+
+Add a mgr config option `crypto_caller` that lets a ceph user override
+the default behavior of using the remote crypto caller. Supported
+values are `internal` and `remote`.
+
+Signed-off-by: John Mulligan <jmulligan at redhat.com>
+---
+ src/pybind/mgr/dashboard/module.py                  | 9 +++++++++
+ src/pybind/mgr/dashboard/services/access_control.py | 3 +--
+ 2 files changed, 10 insertions(+), 2 deletions(-)
+
+diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py
+index 677d88fb678..fdc072edfbb 100644
+--- a/src/pybind/mgr/dashboard/module.py
++++ b/src/pybind/mgr/dashboard/module.py
+@@ -21,6 +21,7 @@ if TYPE_CHECKING:
+     else:
+         from typing_extensions import Literal
+ 
++from ceph.cryptotools.select import choose_crypto_caller
+ from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \
+     MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key
+ from mgr_util import ServerConfigException, build_url, \
+@@ -335,6 +336,8 @@ class Module(MgrModule, CherryPyConfig):
+                min=400, max=599),
+         Option(name='redirect_resolve_ip_addr', type='bool', default=False),
+         Option(name='cross_origin_url', type='str', default=''),
++        Option(name='sso_oauth2', type='bool', default=False),
++        Option(name='crypto_caller', type='str', default=''),
+     ]
+     MODULE_OPTIONS.extend(options_schema_list())
+     for options in PLUGIN_MANAGER.hook.get_options() or []:
+@@ -348,6 +351,9 @@ class Module(MgrModule, CherryPyConfig):
+     def __init__(self, *args, **kwargs):
+         super(Module, self).__init__(*args, **kwargs)
+         CherryPyConfig.__init__(self)
++        # configure the dashboard's crypto caller. by default it will
++        # use the remote caller to avoid pyo3 conflicts
++        choose_crypto_caller(str(self.get_module_option('crypto_caller', '')))
+ 
+         mgr.init(self)
+ 
+@@ -565,6 +571,9 @@ class StandbyModule(MgrStandbyModule, CherryPyConfig):
+         super(StandbyModule, self).__init__(*args, **kwargs)
+         CherryPyConfig.__init__(self)
+         self.shutdown_event = threading.Event()
++        # configure the dashboard's crypto caller. by default it will
++        # use the remote caller to avoid pyo3 conflicts
++        choose_crypto_caller(str(self.get_module_option('crypto_caller', '')))
+ 
+         # We can set the global mgr instance to ourselves even though
+         # we're just a standby, because it's enough for logging.
+diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py
+index 282742d84b5..68b17b61344 100644
+--- a/src/pybind/mgr/dashboard/services/access_control.py
++++ b/src/pybind/mgr/dashboard/services/access_control.py
+@@ -12,6 +12,7 @@ from datetime import datetime, timedelta
+ from string import ascii_lowercase, ascii_uppercase, digits, punctuation
+ from typing import List, Optional, Sequence
+ 
++from ceph.cryptotools.select import get_crypto_caller
+ from mgr_module import CLICheckNonemptyFileInput, CLIReadCommand, CLIWriteCommand
+ from mgr_util import password_hash
+ 
+@@ -23,8 +24,6 @@ from ..exceptions import PasswordPolicyException, PermissionNotValid, \
+ from ..security import Permission, Scope
+ from ..settings import Settings
+ 
+-from ceph.cryptotools.select import get_crypto_caller
+-
+ logger = logging.getLogger('access_control')
+ DEFAULT_FILE_DESC = 'password/secret'
+ 
diff --git a/patches/series b/patches/series
index 220a6f1c02..ce1d9725d0 100644
--- a/patches/series
+++ b/patches/series
@@ -19,3 +19,27 @@
 0030-debian-radosgw-add-media-types-packages-as-alternati.patch
 0031-ceph-volume-fix-importlib.metadata-compat.patch
 0032-client-disallow-unprivileged-users-to-escalate-root-.patch
+0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch
+0035-python-common-cryptotools-use-json-for-structured-ou.patch
+0036-python-common-cryptotools-create-CrytpoCaller-interf.patch
+0037-python-common-cryptotools-use-one-single-dir-for-cry.patch
+0038-python-common-remove-unused-dir.patch
+0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch
+0040-python-common-Correct-typo-in-private_key-naming-fie.patch
+0041-python-common-cryptotools-Always-encode-Err-via-stde.patch
+0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch
+0043-python-common-cryptotools-fix-error-path-in-verify-t.patch
+0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch
+0045-pybind-mgr-Appropriately-rename-function.patch
+0046-python-common-cryptotools-give-the-parsers-more-sens.patch
+0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch
+0048-pybind-mgr-fix-test-case-in-test_tls.py.patch
+0049-python-common-cryptotools-move-actual-crypto-opts-in.patch
+0050-python-common-cryptotools-use-a-main-function.patch
+0051-python-common-cryptotools-unify-and-organize-all-end.patch
+0052-python-common-cryptotools-add-caller-module-for-base.patch
+0053-python-common-cryptotools-move-internal-crypto-calle.patch
+0054-python-common-cryptotools-create-module-for-selectin.patch
+0055-python-common-cryptotools-catch-all-failures-to-read.patch
+0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch
+0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch
-- 
2.39.5





More information about the pve-devel mailing list