all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard
@ 2025-07-15  9:32 Max R. Carrara
  2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError Max R. Carrara
                   ` (2 more replies)
  0 siblings, 3 replies; 4+ messages in thread
From: Max R. Carrara @ 2025-07-15  9:32 UTC (permalink / raw)
  To: pve-devel

Provide Workaround for PyO3 ImportError regarding Ceph Dashboard - v1
=====================================================================

In short, backport PR #62951 [0] and drop a patch that was made
superfluous due to said backport.

I tested this on my local bookworm/squid cluster—generating a
self-signed cert worked without problems. The Dashboard is up and
running.

NOTE: The patches added by the first commit should also work and apply
for Ceph Squid on trixie, but I haven't tested them yet (on trixie), as
it won't build for me atm.

References
----------

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

Summary of Changes
------------------

Max R. Carrara (2):
  backport workaround for PyO3 sub-interpreter ImportError
  drop patch that disables generating self-signed certs for dashboard

 ...move-ability-to-create-and-check-TLS.patch | 126 ----
 ...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                                |  25 +-
 26 files changed, 3424 insertions(+), 127 deletions(-)
 delete mode 100644 patches/0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch
 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

-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 4+ messages in thread

* [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError
  2025-07-15  9:32 [pve-devel] [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Max R. Carrara
@ 2025-07-15  9:32 ` Max R. Carrara
  2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 2/2] drop patch that disables generating self-signed certs for dashboard Max R. Carrara
  2025-07-15 12:55 ` [pve-devel] applied: [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Thomas Lamprecht
  2 siblings, 0 replies; 4+ messages in thread
From: Max R. Carrara @ 2025-07-15  9:32 UTC (permalink / raw)
  To: pve-devel

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@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@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@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\nROi4cfQNOLbWX8g3PfIlqtdGY
 NA77UPxvy1SUimmtdopZa\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/XBmlu
 XH929PtUOk=\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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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@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



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [pve-devel] [PATCH v1 squid-stable-8 ceph 2/2] drop patch that disables generating self-signed certs for dashboard
  2025-07-15  9:32 [pve-devel] [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Max R. Carrara
  2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError Max R. Carrara
@ 2025-07-15  9:32 ` Max R. Carrara
  2025-07-15 12:55 ` [pve-devel] applied: [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Thomas Lamprecht
  2 siblings, 0 replies; 4+ messages in thread
From: Max R. Carrara @ 2025-07-15  9:32 UTC (permalink / raw)
  To: pve-devel

Due to the backported patches of the previous commit, this patch is
not necessary anymore.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 ...move-ability-to-create-and-check-TLS.patch | 126 ------------------
 patches/series                                |   1 -
 2 files changed, 127 deletions(-)
 delete mode 100644 patches/0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch

diff --git a/patches/0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch b/patches/0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch
deleted file mode 100644
index 3dce8e7a50..0000000000
--- a/patches/0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch
+++ /dev/null
@@ -1,126 +0,0 @@
-From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
-From: Max Carrara <m.carrara@proxmox.com>
-Date: Fri, 26 Jan 2024 14:04:47 +0100
-Subject: [PATCH] mgr/dashboard: remove ability to create and check TLS
- key/cert pairs
-
-In order to avoid running into PyO3-related issues [0] with PyOpenSSL,
-the ability to create self-signed certs is disabled - the command
-`ceph dashboard create-self-signed-cert` is made to always return an
-error.
-
-The command's error message contains the manual steps the user may
-follow in order to set the certificate themselves, as well as a link
-to the Ceph Dashboard documentation regarding TLS support. [1]
-
-Furthermore, the check on start-up, that verifies that the configured
-key/cert pair actually match, is also removed. This means that users
-need to ensure themselves that the correct pair is supplied -
-otherwise their browser will complain.
-
-Other checks unrelated to the verification of keypairs are preserved,
-such as checking for the cert's and key's existence on the filesystem.
-
-`ssl.SSLError`s that occur during startup are re-raised with the
-additional information they contain as `ServerConfigException`s, as
-the dashboard handles these in its startup loop. Other exceptions are
-re-raised as well. Otherwise, the dashboard will irrecoverably crash,
-which also causes the `ceph dashboard` subcommand to stop working
-altogether, even if one of its sub-subcommands are unrelated to the
-dashboard itself.
-
-These changes allow the dashboard to launch with TLS enabled again.
-
-[0]: https://tracker.ceph.com/issues/63529
-[1]: https://docs.ceph.com/en/reef/mgr/dashboard/#ssl-tls-support
-
-Signed-off-by: Max Carrara <m.carrara@proxmox.com>
-Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
----
- src/pybind/mgr/dashboard/module.py | 58 ++++++++++++++++++++++--------
- 1 file changed, 43 insertions(+), 15 deletions(-)
-
-diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py
-index 41160b698aa..8f57061abe2 100644
---- a/src/pybind/mgr/dashboard/module.py
-+++ b/src/pybind/mgr/dashboard/module.py
-@@ -23,8 +23,7 @@ if TYPE_CHECKING:
- 
- from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \
-     MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key
--from mgr_util import ServerConfigException, build_url, \
--    create_self_signed_cert, get_default_addr, verify_tls_files
-+from mgr_util import ServerConfigException, build_url, get_default_addr
- 
- from . import mgr
- from .controllers import Router, json_error_page
-@@ -169,11 +168,29 @@ class CherryPyConfig(object):
-             else:
-                 pkey_fname = self.get_localized_module_option('key_file')  # type: ignore
- 
--            verify_tls_files(cert_fname, pkey_fname)
-+            if not cert_fname or not pkey_fname:
-+                raise ServerConfigException('no certificate configured')
-+
-+            if not os.path.isfile(cert_fname):
-+                raise ServerConfigException(f"Certificate {cert_fname} does not exist")
-+
-+            if not os.path.isfile(pkey_fname):
-+                raise ServerConfigException(f"private key {pkey_fname} does not exist")
-+
-+            try:
-+                # Create custom SSL context to disable TLS 1.0 and 1.1.
-+                context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
-+                context.load_cert_chain(cert_fname, pkey_fname)
-+            except ssl.SSLError as e:
-+                raise ServerConfigException(
-+                    "Encountered unexpected error while creating SSL context"
-+                    f" - library: {e.library}, reason: {e.reason}"
-+                )
-+            except Exception as e:
-+                raise ServerConfigException(
-+                    f"Encountered unexpected error while creating SSL context: {e}"
-+                )
- 
--            # Create custom SSL context to disable TLS 1.0 and 1.1.
--            context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
--            context.load_cert_chain(cert_fname, pkey_fname)
-             if sys.version_info >= (3, 7):
-                 context.minimum_version = ssl.TLSVersion.TLSv1_3
-             else:
-@@ -464,15 +481,26 @@ class Module(MgrModule, CherryPyConfig):
- 
-     @CLIWriteCommand("dashboard create-self-signed-cert")
-     def set_mgr_created_self_signed_cert(self):
--        cert, pkey = create_self_signed_cert('IT', 'ceph-dashboard')
--        result = HandleCommandResult(*self.set_ssl_certificate(inbuf=cert))
--        if result.retval != 0:
--            return result
--
--        result = HandleCommandResult(*self.set_ssl_certificate_key(inbuf=pkey))
--        if result.retval != 0:
--            return result
--        return 0, 'Self-signed certificate created', ''
-+        from textwrap import dedent
-+
-+        err = """
-+        Creating self-signed certificates is currently not available.
-+        However, you can still set a key and certificate pair manually:
-+
-+        1. Generate a private key and self-signed certificate:
-+          # openssl req -newkey rsa:2048 -nodes -x509 \\
-+          -keyout /root/dashboard-key.pem -out /root/dashboard-crt.pem -sha512 \\
-+          -days 3650 -subj "/CN=IT/O=ceph-mgr-dashboard" -utf8
-+
-+        2. Set the corresponding config keys for the key/cert pair:
-+          # ceph config-key set mgr/dashboard/key -i /root/dashboard-key.pem
-+          # ceph config-key set mgr/dashboard/crt -i /root/dashboard-crt.pem
-+
-+        For more information on how to configure TLS for the dashboard, visit:
-+        https://docs.ceph.com/en/reef/mgr/dashboard/#ssl-tls-support
-+        """
-+
-+        return -errno.ENOTSUP, '', dedent(err).strip()
- 
-     @CLIWriteCommand("dashboard set-rgw-credentials")
-     def set_rgw_credentials(self):
diff --git a/patches/series b/patches/series
index ce1d9725d0..9adb8681e2 100644
--- a/patches/series
+++ b/patches/series
@@ -5,7 +5,6 @@
 0008-fix-service-ordering-avoid-Before-remote-fs-pre.targ.patch
 0009-fix-4759-run-ceph-crash-daemon-with-www-data-group-f.patch
 0010-fix-compatibility-with-CPUs-not-supporting-SSE-4.1-i.patch
-0011-mgr-dashboard-remove-ability-to-create-and-check-TLS.patch
 0012-ceph-osd-postinst-do-not-always-reload-all-sysctl-se.patch
 0013-debian-recursively-adjust-permissions-of-var-lib-cep.patch
 0014-ceph-crash-change-order-of-client-names.patch
-- 
2.39.5



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


^ permalink raw reply	[flat|nested] 4+ messages in thread

* [pve-devel] applied: [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard
  2025-07-15  9:32 [pve-devel] [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Max R. Carrara
  2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError Max R. Carrara
  2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 2/2] drop patch that disables generating self-signed certs for dashboard Max R. Carrara
@ 2025-07-15 12:55 ` Thomas Lamprecht
  2 siblings, 0 replies; 4+ messages in thread
From: Thomas Lamprecht @ 2025-07-15 12:55 UTC (permalink / raw)
  To: pve-devel, Max R. Carrara

On Tue, 15 Jul 2025 11:32:35 +0200, Max R. Carrara wrote:
> Provide Workaround for PyO3 ImportError regarding Ceph Dashboard - v1
> =====================================================================
> 
> In short, backport PR #62951 [0] and drop a patch that was made
> superfluous due to said backport.
> 
> I tested this on my local bookworm/squid cluster—generating a
> self-signed cert worked without problems. The Dashboard is up and
> running.
> 
> [...]

Applied to both master and squid-stable-8, thanks!

[1/2] backport workaround for PyO3 sub-interpreter ImportError
      commit: e998bc5fcee5e247eaac961716047ee141304a90
[2/2] drop patch that disables generating self-signed certs for dashboard
      commit: f9ff90eb591150177096b524b30c4f804059707b


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

^ permalink raw reply	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2025-07-15 12:55 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-07-15  9:32 [pve-devel] [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Max R. Carrara
2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError Max R. Carrara
2025-07-15  9:32 ` [pve-devel] [PATCH v1 squid-stable-8 ceph 2/2] drop patch that disables generating self-signed certs for dashboard Max R. Carrara
2025-07-15 12:55 ` [pve-devel] applied: [PATCH v1 squid-stable-8 ceph 0/2] Provide Workaround for PyO3 ImportError regarding Ceph Dashboard Thomas Lamprecht

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal