From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 9D45F1FF16B for ; Tue, 15 Jul 2025 11:52:21 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E02D93831E; Tue, 15 Jul 2025 11:53:17 +0200 (CEST) From: "Max R. Carrara" To: pve-devel@lists.proxmox.com Date: Tue, 15 Jul 2025 11:32:36 +0200 Message-Id: <20250715093237.650039-2-m.carrara@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20250715093237.650039-1-m.carrara@proxmox.com> References: <20250715093237.650039-1-m.carrara@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.047 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_LOTSOFHASH 0.25 Emails with lots of hash-like gibberish RCVD_IN_MSPIKE_H2 0.001 Average reputation (+2) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record X-Mailman-Approved-At: Tue, 15 Jul 2025 11:53:16 +0200 Subject: [pve-devel] [PATCH v1 squid-stable-8 ceph 1/2] backport workaround for PyO3 sub-interpreter ImportError X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox VE development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pve-devel-bounces@lists.proxmox.com Sender: "pve-devel" These patches were taken from PR #62951 [0] and backported onto upstream tag "19.2.2" (0eceb0defba). This provides a workaround for the PyO3 ImportError regarding sub-interpreters for the Ceph Dashboard. [0]: https://github.com/ceph/ceph/pull/62951 Signed-off-by: Max R. Carrara --- ...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" +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 +--- + src/pybind/mgr/cephadm/tests/test_cephadm.py | 2 - + src/pybind/mgr/mgr_util.py | 209 ++++++++++-------- + src/pybind/mgr/tests/test_tls.py | 10 +- + src/python-common/ceph/pybind/__init__.py | 0 + src/python-common/ceph/pybind/mgr/__init__.py | 0 + .../ceph/pybind/mgr/cryptotools.py | 197 +++++++++++++++++ + 6 files changed, 319 insertions(+), 99 deletions(-) + create mode 100644 src/python-common/ceph/pybind/__init__.py + create mode 100644 src/python-common/ceph/pybind/mgr/__init__.py + create mode 100644 src/python-common/ceph/pybind/mgr/cryptotools.py + +diff --git a/src/pybind/mgr/cephadm/tests/test_cephadm.py b/src/pybind/mgr/cephadm/tests/test_cephadm.py +index b2e36ec5bd6..9d5c50cb8c5 100644 +--- a/src/pybind/mgr/cephadm/tests/test_cephadm.py ++++ b/src/pybind/mgr/cephadm/tests/test_cephadm.py +@@ -2088,12 +2088,10 @@ class TestCephadm(object): + ), CephadmOrchestrator.apply_container), + ] + ) +- @mock.patch("subprocess.run", None) + @mock.patch("cephadm.serve.CephadmServe._run_cephadm", _run_cephadm('{}')) + @mock.patch("cephadm.services.nfs.NFSService.run_grace_tool", mock.MagicMock()) + @mock.patch("cephadm.services.nfs.NFSService.create_rados_config_obj", mock.MagicMock()) + @mock.patch("cephadm.services.nfs.NFSService.purge", mock.MagicMock()) +- @mock.patch("subprocess.run", mock.MagicMock()) + def test_apply_save(self, spec: ServiceSpec, meth, cephadm_module: CephadmOrchestrator): + with with_host(cephadm_module, 'test'): + with with_service(cephadm_module, spec, meth, 'test'): +diff --git a/src/pybind/mgr/mgr_util.py b/src/pybind/mgr/mgr_util.py +index 05ec6496682..9625937ed74 100644 +--- a/src/pybind/mgr/mgr_util.py ++++ b/src/pybind/mgr/mgr_util.py +@@ -3,7 +3,6 @@ import os + if 'UNITTEST' in os.environ: + import tests + +-import bcrypt + import cephfs + import contextlib + import datetime +@@ -524,20 +523,9 @@ def create_self_signed_cert(organisation: str = 'Ceph', + + """ + +- from OpenSSL import crypto +- from uuid import uuid4 +- + # RDN = Relative Distinguished Name + valid_RDN_list = ['C', 'ST', 'L', 'O', 'OU', 'CN', 'emailAddress'] + +- # create a key pair +- pkey = crypto.PKey() +- pkey.generate_key(crypto.TYPE_RSA, 2048) +- +- # Create a "subject" object +- req = crypto.X509Req() +- subj = req.get_subject() +- + if dname: + # dname received, so check it contains valid RDNs + if not all(field in valid_RDN_list for field in dname): +@@ -545,44 +533,49 @@ def create_self_signed_cert(organisation: str = 'Ceph', + else: + dname = {"O": organisation, "CN": common_name} + +- # populate the subject with the dname settings +- for k, v in dname.items(): +- setattr(subj, k, v) ++ import json ++ import subprocess ++ ++ private_key = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--private_key"], ++ capture_output=True) ++ ++ pkey = private_key.stdout.strip().decode('utf-8') + +- # create a self-signed cert +- cert = crypto.X509() +- cert.set_subject(req.get_subject()) +- cert.set_serial_number(int(uuid4())) +- cert.gmtime_adj_notBefore(0) +- cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) # 10 years +- cert.set_issuer(cert.get_subject()) +- cert.set_pubkey(pkey) +- cert.sign(pkey, 'sha512') ++ data = {"dname": dname, "private_key": pkey} + +- cert = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) +- pkey = crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey) ++ result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "create_self_signed_cert", "--certificate"], ++ input=json.dumps(data).encode("utf-8"), ++ capture_output=True) + +- return cert.decode('utf-8'), pkey.decode('utf-8') ++ # Check result with a CompletedProcess ++ if result.returncode != 0 or result.stderr != b'': ++ raise ValueError(result.stderr) ++ ++ cert = result.stdout.strip().decode('utf-8') ++ return cert, pkey + + + def verify_cacrt_content(crt): +- # type: (str) -> None +- from OpenSSL import crypto ++ # type: (str) -> int ++ + try: +- crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt +- x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) +- if x509.has_expired(): +- org, cn = get_cert_issuer_info(crt) +- no_after = x509.get_notAfter() +- end_date = None +- if no_after is not None: +- end_date = datetime.datetime.strptime(no_after.decode('ascii'), '%Y%m%d%H%M%SZ') +- msg = f'Certificate issued by "{org}/{cn}" expired on {end_date}' +- logger.warning(msg) +- raise ServerConfigException(msg) +- except (ValueError, crypto.Error) as e: ++ import subprocess ++ result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_cacrt_content"], ++ input=crt if isinstance(crt, bytes) else crt.encode('utf-8'), ++ capture_output=True) ++ # The above script will only produce stdout output. ++ # The only scenarios that produce stderr output are failures to import modules ++ # or syntax errors which test_tls.py will catch ++ ++ # Check result of CompletedProcess ++ if result.returncode != 0 or result.stderr != b'': ++ logger.warning(result.stderr) ++ raise ValueError(result.stderr) ++ except (ValueError) as e: + raise ServerConfigException(f'Invalid certificate: {e}') + ++ return int(result.stdout.strip().decode('utf-8')) ++ + + def verify_cacrt(cert_fname): + # type: (str) -> None +@@ -603,49 +596,52 @@ def verify_cacrt(cert_fname): + def get_cert_issuer_info(crt: str) -> Tuple[Optional[str],Optional[str]]: + """Basic validation of a ca cert""" + +- from OpenSSL import crypto, SSL + try: +- crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt +- (org_name, cn) = (None, None) +- cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) +- components = cert.get_issuer().get_components() +- for c in components: +- if c[0].decode() == 'O': # org comp +- org_name = c[1].decode() +- elif c[0].decode() == 'CN': # common name comp +- cn = c[1].decode() +- return (org_name, cn) +- except (ValueError, crypto.Error) as e: ++ import subprocess ++ org_name_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--org_name"], ++ input=crt if isinstance(crt, bytes) else crt.encode('utf-8'), ++ capture_output=True) ++ ++ # Check result with a CompletedProcess ++ if org_name_proc.returncode != 0 or org_name_proc.stderr != b'': ++ raise ValueError(org_name_proc.stderr) ++ ++ cn_proc = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "get_cert_issuer_info", "--cn"], ++ input=crt if isinstance(crt, bytes) else crt.encode('utf-8'), ++ capture_output=True) ++ ++ # Check result with a CompletedProcess ++ if cn_proc.returncode != 0 or cn_proc.stderr != b'': ++ raise ValueError(cn_proc.stderr) ++ ++ org_name, cn = org_name_proc.stdout.strip().decode('utf-8'), cn_proc.stdout.strip().decode('utf-8') ++ ++ except (ValueError) as e: + raise ServerConfigException(f'Invalid certificate key: {e}') ++ return (org_name, cn) + + def verify_tls(crt, key): + # type: (str, str) -> None + verify_cacrt_content(crt) + +- from OpenSSL import crypto, SSL + try: +- _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) +- _key.check() +- except (ValueError, crypto.Error) as e: +- raise ServerConfigException( +- 'Invalid private key: {}'.format(str(e))) +- try: +- crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt +- _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) +- except ValueError as e: +- raise ServerConfigException( +- 'Invalid certificate key: {}'.format(str(e)) +- ) +- +- try: +- context = SSL.Context(SSL.TLSv1_METHOD) +- context.use_certificate(_crt) +- context.use_privatekey(_key) +- context.check_privatekey() +- except crypto.Error as e: +- logger.warning('Private key and certificate do not match up: {}'.format(str(e))) +- except SSL.Error as e: +- raise ServerConfigException(f'Invalid cert/key pair: {e}') ++ import subprocess ++ import json ++ ++ data = { ++ "crt": crt.decode("utf-8") if isinstance(crt, bytes) else crt, # type: ignore[attr-defined] ++ "key": key.decode("utf-8") if isinstance(key, bytes) else key # type: ignore[attr-defined] ++ } ++ result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "verify_tls"], ++ input=json.dumps(data).encode("utf-8"), ++ capture_output=True) ++ ++ # Check result of CompletedProcess ++ if result.returncode != 0 or result.stdout != b'': ++ logger.warning(result.stdout) ++ raise ServerConfigException(result.stdout) ++ except (ServerConfigException) as e: ++ raise ServerConfigException(f'Invalid certificate: {e}') + + + +@@ -674,24 +670,14 @@ def verify_tls_files(cert_fname, pkey_fname): + if not os.path.isfile(pkey_fname): + raise ServerConfigException('private key %s does not exist' % pkey_fname) + +- from OpenSSL import crypto, SSL ++ if not os.path.isfile(cert_fname): ++ raise ServerConfigException('certificate %s does not exist' % cert_fname) + + try: +- with open(pkey_fname) as f: +- pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, f.read()) +- pkey.check() +- except (ValueError, crypto.Error) as e: +- raise ServerConfigException( +- 'Invalid private key {}: {}'.format(pkey_fname, str(e))) +- try: +- context = SSL.Context(SSL.TLSv1_METHOD) +- context.use_certificate_file(cert_fname, crypto.FILETYPE_PEM) +- context.use_privatekey_file(pkey_fname, crypto.FILETYPE_PEM) +- context.check_privatekey() +- except crypto.Error as e: +- logger.warning( +- 'Private key {} and certificate {} do not match up: {}'.format( +- pkey_fname, cert_fname, str(e))) ++ with open(pkey_fname) as key_file, open(cert_fname) as cert_file: ++ verify_tls(cert_file.read(), key_file.read()) ++ except (ServerConfigException) as e: ++ raise ServerConfigException({e}) + + + def get_most_recent_rate(rates: Optional[List[Tuple[float, float]]]) -> float: +@@ -869,11 +855,42 @@ def profile_method(skip_attribute: bool = False) -> Callable[[Callable[..., T]], + return outer + + ++def parse_combined_pem_file(pem_data: str) -> Tuple[Optional[str], Optional[str]]: ++ ++ # Extract the certificate ++ cert_start = "-----BEGIN CERTIFICATE-----" ++ cert_end = "-----END CERTIFICATE-----" ++ cert = None ++ if cert_start in pem_data and cert_end in pem_data: ++ cert = pem_data[pem_data.index(cert_start):pem_data.index(cert_end) + len(cert_end)] ++ ++ # Extract the private key ++ key_start = "-----BEGIN PRIVATE KEY-----" ++ key_end = "-----END PRIVATE KEY-----" ++ private_key = None ++ if key_start in pem_data and key_end in pem_data: ++ private_key = pem_data[pem_data.index(key_start):pem_data.index(key_end) + len(key_end)] ++ ++ return cert, private_key ++ ++ + def password_hash(password: Optional[str], salt_password: Optional[str] = None) -> Optional[str]: + if not password: + return None ++ + if not salt_password: +- salt = bcrypt.gensalt() +- else: +- salt = salt_password.encode('utf8') +- return bcrypt.hashpw(password.encode('utf8'), salt).decode('utf8') ++ salt_password = '' ++ ++ import subprocess ++ import json ++ ++ data = {"password": password, "salt_password": salt_password} ++ result = subprocess.run(["python3", "-m", "ceph.pybind.mgr.cryptotools", "password_hash"], ++ input=json.dumps(data).encode("utf-8"), ++ capture_output=True) ++ ++ # Check result with a CompletedProcess ++ if result.returncode != 0 or result.stderr != b'': ++ raise ValueError(result.stderr) ++ ++ return result.stdout.strip().decode('utf-8') +diff --git a/src/pybind/mgr/tests/test_tls.py b/src/pybind/mgr/tests/test_tls.py +index 19ce46a93fd..39ba5ae0a03 100644 +--- a/src/pybind/mgr/tests/test_tls.py ++++ b/src/pybind/mgr/tests/test_tls.py +@@ -1,4 +1,4 @@ +-from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info ++from mgr_util import create_self_signed_cert, verify_tls, ServerConfigException, get_cert_issuer_info, verify_cacrt_content + from OpenSSL import crypto, SSL + + import unittest +@@ -10,6 +10,9 @@ valid_ceph_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQCpHIQuSYhCII1J0S + invalid_cert = """-----BEGIN CERTIFICATE-----\nMIICxjCCAa4CEQCpHIQuSYhCII1J0SVGYnT1MA0GCSqGSIb3DQEBDQUAMCExDTAL\nBgNVBAoMBENlcGgxEDAOBgNVBAMMB2NlcGhhZG0wHhcNMjIwNzA2MTE1MjUyWhcN\nMzIwNzAzMTE1MjUyWjAhMQ0wCwYDVQQKDARDZXBoMRAwDgYDVQQDDAdjZXBoYWRt\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEBn2ApFna2CVYE7RDtjJVk\ncJTcJQrjzDOlCoZtxb1QMCQZMXjx/7d6bseQP+dkkeA0hZxnjJZWeu6c/YnQ1JiT\n2aDuDpWoJAaiinHRJyZuY5tqG+ggn95RdToZVbeC+0uALzYi4UFacC3sfpkyIKBR\nic43+2fQNz0PZ+8INSTtm75Y53gbWuGF7Dv95200AmAN2/u8LKWZIvdhbRborxOF\nlK2T40qbj9eH3ewIN/6Eibxrvg4va3pIoOaq0XdJHAL/MjDGJAtahPIenwcjuega\n4PSlB0h3qiyFXz7BG8P0QsPP6slyD58ZJtCGtJiWPOhlq47DlnWlJzRGDEFLLryf\n8wIDAQABMA0GCSqGSIb3DQEBDQUAA4IBAQBixd7RZawlYiTZaCmv3Vy7X/hhabac\nE/YiuFt1YMe0C9+D8IcCQN/IRww/Bi7Af6tm+ncHT9GsOGWX6hahXDKTw3b9nSDi\nETvjkUTYOayZGfhYpRA6m6e/2ypcUYsiXRDY9zneDKCdPREIA1D6L2fROHetFX9r\nX9rSry01xrYwNlYA1e6GLMXm2NaGsLT3JJlRBtT3P7f1jtRGXcwkc7ns0AtW0uNj\nGqRLHfJazdgWJFsj8vBdMs7Ci0C/b5/f7J/DLpPCvUA3Fqwn9MzHl01UwlDsKy1a\nROi4cfQNOLbWX8g3PfIlqtdGY NA77UPxvy1SUimmtdopZa\n-----END CERTIFICATE-----\n + """ + ++expired_cert = """-----BEGIN CERTIFICATE-----\nMIICtjCCAZ4CAQAwDQYJKoZIhvcNAQENBQAwITEQMA4GA1UEAwwHY2VwaGFkbTEN\nMAsGA1UECgwEQ2VwaDAeFw0xNTAyMTYxOTQ4MTdaFw0yMDAyMTUxOTQ4MTdaMCEx\nEDAOBgNVBAMMB2NlcGhhZG0xDTALBgNVBAoMBENlcGgwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQCxYHJ6RlPeuhZJyAMR1ru01BEGbwhI7vMga8pwyTX8\nNn1ow2asbj7lad+jO5j5Gon8GFwsrKM0S8vmITxd5QkshnHPQRQF8hz4aieNOQiL\nnVRBTHgLihEBJCpyuTmHLn1G374ZObuFqyHcnIrKNdeKb0JxNKbx26/2NrWwFGAe\nAj5KuizMHJMZYVLfYelP4g2hSRPe2JJWI4429LeLWuBQBL9t/IPY0IlmFDP4eL+S\nB2Py8Ig2XY5oyaaxpwI8H/cAY92lsoHPI3ldDn1JEiH5Gwzxf+9fF29cesp8BYcm\naav1jT8ONvsfn7AxKDKcfZIpRNKlOqFIC03gG5R3O1iHAgMBAAEwDQYJKoZIhvcN\nAQENBQADggEBADh9bAMR7RIK3M3u6LoTQQrcoxJ0pEXBrFQGQk2uz2krlDTKRS+2\nubwD8bLNd3dl5RzvVJ1hui8y9JMnqYwgMrjR9B0EDUM/ihYU2zO3ZN9nhhnTN2qT\n+UtFtyilg3U4nQdWGw2jFPu08JPoF/g+7iBH+/o5WOfzOovjLg4BsVlKUP4ND8Dv\nXr8gxZTlaoZvZlhMCdhiT2aKstCA9R3RYBbEo/FtcsHOkO0EFuxCLiVd/eo3F56C\njfVWnvqyz3r2f1G1VafvhhdlMJ4p35Hw1ms6nFTLx5dKwJW+Xve+qBU3Q5I5iV02\nAIXXBaqId/YqKXZd+Ge/XBmlu XH929PtUOk=\n-----END CERTIFICATE-----\n ++""" ++ + class TLSchecks(unittest.TestCase): + + def test_defaults(self): +@@ -53,3 +56,8 @@ class TLSchecks(unittest.TestCase): + + # invalid certificate + self.assertRaises(ServerConfigException, get_cert_issuer_info, invalid_cert) ++ ++ # expired certificate ++ self.assertRaisesRegex(ServerConfigException, ++ 'Certificate issued by "Ceph/cephadm" expired', ++ verify_cacrt_content, expired_cert) +diff --git a/src/python-common/ceph/pybind/__init__.py b/src/python-common/ceph/pybind/__init__.py +new file mode 100644 +index 00000000000..e69de29bb2d +diff --git a/src/python-common/ceph/pybind/mgr/__init__.py b/src/python-common/ceph/pybind/mgr/__init__.py +new file mode 100644 +index 00000000000..e69de29bb2d +diff --git a/src/python-common/ceph/pybind/mgr/cryptotools.py b/src/python-common/ceph/pybind/mgr/cryptotools.py +new file mode 100644 +index 00000000000..c14f9b2a453 +--- /dev/null ++++ b/src/python-common/ceph/pybind/mgr/cryptotools.py +@@ -0,0 +1,197 @@ ++""" ++This file has been isolated into a module so that it can be run ++in a subprocess therefore sidestepping the ++`PyO3 modules may only be initialized once per interpreter process` problem. ++""" ++ ++import argparse ++import bcrypt ++import datetime ++import json ++import sys ++import warnings ++ ++from argparse import Namespace ++from OpenSSL import crypto, SSL ++from uuid import uuid4 ++from typing import Tuple, Optional ++ ++ ++# subcommand functions ++def password_hash(args: Namespace) -> None: ++ data = json.loads(sys.stdin.read()) ++ ++ password = data['password'] ++ salt_password = data['salt_password'] ++ ++ if not salt_password: ++ salt = bcrypt.gensalt() ++ else: ++ salt = salt_password.encode('utf8') ++ ++ print(bcrypt.hashpw(password.encode('utf8'), salt).decode()) ++ ++ ++def create_self_signed_cert(args: Namespace) -> None: ++ ++ # Generate private key ++ if args.private_key: ++ # create a key pair ++ pkey = crypto.PKey() ++ pkey.generate_key(crypto.TYPE_RSA, 2048) ++ print(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey).decode()) ++ return ++ ++ data = json.loads(sys.stdin.read()) ++ ++ dname = data['dname'] ++ pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, data['private_key']) ++ ++ # Create a "subject" object ++ with warnings.catch_warnings(): ++ warnings.simplefilter("ignore") ++ req = crypto.X509Req() ++ subj = req.get_subject() ++ ++ # populate the subject with the dname settings ++ for k, v in dname.items(): ++ setattr(subj, k, v) ++ ++ # create a self-signed cert ++ cert = crypto.X509() ++ cert.set_subject(req.get_subject()) ++ cert.set_serial_number(int(uuid4())) ++ cert.gmtime_adj_notBefore(0) ++ cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) # 10 years ++ cert.set_issuer(cert.get_subject()) ++ cert.set_pubkey(pkey) ++ cert.sign(pkey, 'sha512') ++ ++ print(crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()) ++ ++ ++def _get_cert_issuer_info(crt: str) -> Tuple[Optional[str], Optional[str]]: ++ """Basic validation of a CA cert ++ """ ++ ++ crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt ++ (org_name, cn) = (None, None) ++ cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) ++ components = cert.get_issuer().get_components() ++ for c in components: ++ if c[0].decode() == 'O': # org comp ++ org_name = c[1].decode() ++ elif c[0].decode() == 'CN': # common name comp ++ cn = c[1].decode() ++ ++ return (org_name, cn) ++ ++ ++def verify_cacrt_content(args: Namespace) -> None: ++ crt = sys.stdin.read() ++ ++ crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt ++ x509 = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) ++ no_after = x509.get_notAfter() ++ if not no_after: ++ print("Certificate does not have an expiration date.", file=sys.stderr) ++ sys.exit(1) ++ ++ end_date = datetime.datetime.strptime(no_after.decode('ascii'), '%Y%m%d%H%M%SZ') ++ ++ if x509.has_expired(): ++ org, cn = _get_cert_issuer_info(crt) ++ msg = 'Certificate issued by "%s/%s" expired on %s' % (org, cn, end_date) ++ print(msg, file=sys.stderr) ++ sys.exit(1) ++ ++ # Certificate still valid, calculate and return days until expiration ++ with warnings.catch_warnings(): ++ warnings.simplefilter("ignore") ++ print((end_date - datetime.datetime.utcnow()).days) ++ ++ ++def get_cert_issuer_info(args: Namespace) -> None: ++ crt = sys.stdin.read() ++ ++ crt_buffer = crt.encode("utf-8") if isinstance(crt, str) else crt ++ (org_name, cn) = (None, None) ++ cert = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) ++ components = cert.get_issuer().get_components() ++ for c in components: ++ if c[0].decode() == 'O': # org comp ++ org_name = c[1].decode() ++ elif c[0].decode() == 'CN': # common name comp ++ cn = c[1].decode() ++ ++ if args.org_name: ++ print(org_name) ++ ++ if args.cn: ++ print(cn) ++ ++ ++def verify_tls(args: Namespace) -> None: ++ ++ data = json.loads(sys.stdin.read()) ++ ++ crt = data['crt'] ++ key = data['key'] ++ ++ try: ++ _key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) ++ _key.check() ++ except (ValueError, crypto.Error) as e: ++ print('Invalid private key: %s' % str(e)) ++ try: ++ crt_buffer = crt.encode("ascii") if isinstance(crt, str) else crt ++ _crt = crypto.load_certificate(crypto.FILETYPE_PEM, crt_buffer) ++ except ValueError as e: ++ print('Invalid certificate key: %s' % str(e)) ++ ++ try: ++ context = SSL.Context(SSL.TLSv1_METHOD) ++ with warnings.catch_warnings(): ++ warnings.simplefilter("ignore") ++ context.use_certificate(_crt) ++ context.use_privatekey(_key) ++ ++ context.check_privatekey() ++ except crypto.Error as e: ++ print('Private key and certificate do not match up: %s' % str(e)) ++ except SSL.Error as e: ++ print(f'Invalid cert/key pair: {e}') ++ ++ ++if __name__ == "__main__": ++ # create the top-level parser ++ parser = argparse.ArgumentParser(prog='cryptotools.py') ++ subparsers = parser.add_subparsers(required=True) ++ ++ # create the parser for the "password_hash" command ++ parser_foo = subparsers.add_parser('password_hash') ++ parser_foo.set_defaults(func=password_hash) ++ ++ # create the parser for the "create_self_signed_cert" command ++ parser_bar = subparsers.add_parser('create_self_signed_cert') ++ parser_bar.add_argument('--private_key', required=False, action='store_true') ++ parser_bar.add_argument('--certificate', required=False, action='store_true') ++ parser_bar.set_defaults(func=create_self_signed_cert) ++ ++ # create the parser for the "verify_cacrt_content" command ++ parser_bar = subparsers.add_parser('verify_cacrt_content') ++ parser_bar.set_defaults(func=verify_cacrt_content) ++ ++ # create the parser for the "get_cert_issuer_info" command ++ parser_bar = subparsers.add_parser('get_cert_issuer_info') ++ parser_bar.add_argument('--org_name', required=False, action='store_true') ++ parser_bar.add_argument('--cn', required=False, action='store_true') ++ parser_bar.set_defaults(func=get_cert_issuer_info) ++ ++ # create the parser for the "verify_tls" command ++ parser_bar = subparsers.add_parser('verify_tls') ++ parser_bar.set_defaults(func=verify_tls) ++ ++ # parse the args and call whatever function was selected ++ args = parser.parse_args() ++ args.func(args) diff --git a/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch b/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch new file mode 100644 index 0000000000..5d6a3ee1dc --- /dev/null +++ b/patches/0035-python-common-cryptotools-use-json-for-structured-ou.patch @@ -0,0 +1,81 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: John Mulligan +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 +--- + .../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 +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 +--- + .../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 +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 +--- + .../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 +Date: Thu, 17 Apr 2025 13:24:48 -0400 +Subject: [PATCH 38/57] python-common: remove unused dir + +Signed-off-by: John Mulligan +--- + 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 +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 +--- + 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" +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 +--- + 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" +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 +--- + 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" +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 +--- + 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 +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 +--- + 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" +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 +--- + 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" +Date: Fri, 25 Apr 2025 23:52:39 +0100 +Subject: [PATCH 45/57] pybind/mgr: Appropriately rename function. + +Signed-off-by: Paulo E. Castro +--- + 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 +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 +--- + .../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 +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 +--- + .../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 +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 +--- + 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 +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 +--- + .../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 +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 +--- + 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 +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 +--- + .../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 +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 +--- + 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 +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 +--- + .../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 +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 +--- + .../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 +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 +--- + 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 +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 +--- + 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 +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 +--- + src/pybind/mgr/dashboard/module.py | 9 +++++++++ + src/pybind/mgr/dashboard/services/access_control.py | 3 +-- + 2 files changed, 10 insertions(+), 2 deletions(-) + +diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py +index 677d88fb678..fdc072edfbb 100644 +--- a/src/pybind/mgr/dashboard/module.py ++++ b/src/pybind/mgr/dashboard/module.py +@@ -21,6 +21,7 @@ if TYPE_CHECKING: + else: + from typing_extensions import Literal + ++from ceph.cryptotools.select import choose_crypto_caller + from mgr_module import CLIReadCommand, CLIWriteCommand, HandleCommandResult, \ + MgrModule, MgrStandbyModule, NotifyType, Option, _get_localized_key + from mgr_util import ServerConfigException, build_url, \ +@@ -335,6 +336,8 @@ class Module(MgrModule, CherryPyConfig): + min=400, max=599), + Option(name='redirect_resolve_ip_addr', type='bool', default=False), + Option(name='cross_origin_url', type='str', default=''), ++ Option(name='sso_oauth2', type='bool', default=False), ++ Option(name='crypto_caller', type='str', default=''), + ] + MODULE_OPTIONS.extend(options_schema_list()) + for options in PLUGIN_MANAGER.hook.get_options() or []: +@@ -348,6 +351,9 @@ class Module(MgrModule, CherryPyConfig): + def __init__(self, *args, **kwargs): + super(Module, self).__init__(*args, **kwargs) + CherryPyConfig.__init__(self) ++ # configure the dashboard's crypto caller. by default it will ++ # use the remote caller to avoid pyo3 conflicts ++ choose_crypto_caller(str(self.get_module_option('crypto_caller', ''))) + + mgr.init(self) + +@@ -565,6 +571,9 @@ class StandbyModule(MgrStandbyModule, CherryPyConfig): + super(StandbyModule, self).__init__(*args, **kwargs) + CherryPyConfig.__init__(self) + self.shutdown_event = threading.Event() ++ # configure the dashboard's crypto caller. by default it will ++ # use the remote caller to avoid pyo3 conflicts ++ choose_crypto_caller(str(self.get_module_option('crypto_caller', ''))) + + # We can set the global mgr instance to ourselves even though + # we're just a standby, because it's enough for logging. +diff --git a/src/pybind/mgr/dashboard/services/access_control.py b/src/pybind/mgr/dashboard/services/access_control.py +index 282742d84b5..68b17b61344 100644 +--- a/src/pybind/mgr/dashboard/services/access_control.py ++++ b/src/pybind/mgr/dashboard/services/access_control.py +@@ -12,6 +12,7 @@ from datetime import datetime, timedelta + from string import ascii_lowercase, ascii_uppercase, digits, punctuation + from typing import List, Optional, Sequence + ++from ceph.cryptotools.select import get_crypto_caller + from mgr_module import CLICheckNonemptyFileInput, CLIReadCommand, CLIWriteCommand + from mgr_util import password_hash + +@@ -23,8 +24,6 @@ from ..exceptions import PasswordPolicyException, PermissionNotValid, \ + from ..security import Permission, Scope + from ..settings import Settings + +-from ceph.cryptotools.select import get_crypto_caller +- + logger = logging.getLogger('access_control') + DEFAULT_FILE_DESC = 'password/secret' + diff --git a/patches/series b/patches/series index 220a6f1c02..ce1d9725d0 100644 --- a/patches/series +++ b/patches/series @@ -19,3 +19,27 @@ 0030-debian-radosgw-add-media-types-packages-as-alternati.patch 0031-ceph-volume-fix-importlib.metadata-compat.patch 0032-client-disallow-unprivileged-users-to-escalate-root-.patch +0034-pybind-mgr-Hack-around-the-ImportError-PyO3-modules-.patch +0035-python-common-cryptotools-use-json-for-structured-ou.patch +0036-python-common-cryptotools-create-CrytpoCaller-interf.patch +0037-python-common-cryptotools-use-one-single-dir-for-cry.patch +0038-python-common-remove-unused-dir.patch +0039-pybind-mgr-update-mgr_util-to-use-cryptotools-Crypto.patch +0040-python-common-Correct-typo-in-private_key-naming-fie.patch +0041-python-common-cryptotools-Always-encode-Err-via-stde.patch +0042-pybind-mgr-Correct-code-to-ensure-cephadm-tests-test.patch +0043-python-common-cryptotools-fix-error-path-in-verify-t.patch +0044-python-common-cryptotools-Remove-ascii-and-utf-8-ref.patch +0045-pybind-mgr-Appropriately-rename-function.patch +0046-python-common-cryptotools-give-the-parsers-more-sens.patch +0047-mgr-dashboard-replace-direct-use-of-bcrypt-in-dashbo.patch +0048-pybind-mgr-fix-test-case-in-test_tls.py.patch +0049-python-common-cryptotools-move-actual-crypto-opts-in.patch +0050-python-common-cryptotools-use-a-main-function.patch +0051-python-common-cryptotools-unify-and-organize-all-end.patch +0052-python-common-cryptotools-add-caller-module-for-base.patch +0053-python-common-cryptotools-move-internal-crypto-calle.patch +0054-python-common-cryptotools-create-module-for-selectin.patch +0055-python-common-cryptotools-catch-all-failures-to-read.patch +0056-mgr-cephadm-always-use-the-internal-cryptocaller.patch +0057-mgr-dashboard-add-an-option-to-control-the-dashboard.patch -- 2.39.5 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel