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