From 2a1550192df2414fe8339cb9ffe56f681b59a07a Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Mon, 8 Apr 2024 12:46:22 +0200 Subject: [PATCH 1/6] Add new state to manage certificates files with conditions and minimal info in the comment --- doc/ref/states/all/salt.states.pem.rst | 5 + salt/states/pem.py | 173 +++++++++++++++++++++++++ tests/pytests/unit/states/test_pem.py | 160 +++++++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 doc/ref/states/all/salt.states.pem.rst create mode 100644 salt/states/pem.py create mode 100644 tests/pytests/unit/states/test_pem.py diff --git a/doc/ref/states/all/salt.states.pem.rst b/doc/ref/states/all/salt.states.pem.rst new file mode 100644 index 000000000000..f7fe686e4324 --- /dev/null +++ b/doc/ref/states/all/salt.states.pem.rst @@ -0,0 +1,5 @@ +salt.states.pem +================ + +.. automodule:: salt.states.pem + :members: diff --git a/salt/states/pem.py b/salt/states/pem.py new file mode 100644 index 000000000000..741f43579f1d --- /dev/null +++ b/salt/states/pem.py @@ -0,0 +1,173 @@ +""" +Provide easier operations on certificate files +===================================================================== + +Main purpose for this is to provide easier and human readable info +regarding changes if files contains certificates. + +Requirement is `cryptography` python package. + +.. code-block:: yaml + +/etc/ssl/postfix/mydomain/mydomain.pem: + pem.managed: + - source: salt://files/etc/ssl/mydomain.pem + - user: root + - group: postfix + - dir_mode: 711 + - makedirs: True + - template: jinja +""" + +import logging + +import salt.utils.files + +try: + from cryptography import x509 + + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +__virtualname__ = "pem" + + +def __virtual__(): + if not HAS_CRYPTOGRAPHY: + return (False, "Could not load cryptography") + return __virtualname__ + + +log = logging.getLogger(__name__) + + +def managed( + name, + source=None, + source_hash=None, + source_hash_name=None, + user=None, + group=None, + mode=None, + skip_verify=None, + defaults=None, + attrs=None, + context=None, + saltenv="base", + skip_conditions=False, + **kwargs, +): + """ + State manage certificates as files and provide minimal overview of + expiration and common name in comment. + + It can handle one certificate or full privkey->cert->chain files. + + Conditions can provide easy way how to match agains Common name, + or make sure that only newer certificates will be salted. + If conditions are not required use this as argument to state: + `skip_conditions=True` + Or set pillar in same manner on command line: + `salt-call state.apply some_other_state pillar="{skip_conditions: True}"` + + State can handle everything that file.managed can handle, + because it is used underneat to process changes to files. + """ + + skip_conditions = __pillar__.get("skip_conditions", skip_conditions) + ret = {"name": name, "changes": {}, "result": False, "comment": ""} + existing_cert_info = "" + new_cert_info = "" + + # Load existing certificate + try: + with salt.utils.files.fopen(name, "rb") as existing_cert_file: + existing_cert = x509.load_pem_x509_certificate(existing_cert_file.read()) + existing_cert_info = f"- Subject: {existing_cert.subject.rfc4514_string()}\n- Not valid after: {existing_cert.not_valid_after}" + except FileNotFoundError: + # Old certificate initialy does not need to exist if it is a first time state is running + skip_conditions = True + + try: + tmp_local_file, source_sum, comment_ = __salt__["file.get_managed"]( + name, + source=source, + source_hash=source_hash, + source_hash_name=source_hash_name, + user=user, + group=group, + mode=mode, + attrs=attrs, + saltenv=saltenv, + defaults=defaults, + skip_verify=skip_verify, + context=context, + **kwargs, + ) + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Unable to manage file: {exc}" + return ret + + if not tmp_local_file: + return _error(ret, f"Source file {source} not found") + + try: + with salt.utils.files.fopen(tmp_local_file, "rb") as new_cert_file: + new_cert = x509.load_pem_x509_certificate(new_cert_file.read()) + new_cert_info = f"+ Subject: {new_cert.subject.rfc4514_string()}\n+ Not valid after: {new_cert.not_valid_after}" + except FileNotFoundError: + return _error(ret, f"New cached file {tmp_local_file} not found") + + ret["comment"] = ( + f"Existing cert info:\n{existing_cert_info}\nNew cert info:\n{new_cert_info}\n" + ) + + # Conditions when certificates are salted + if skip_conditions: + log.debug("pem: Certificate conditions are skipped") + else: + failed_conditions = False + + if new_cert.not_valid_after < existing_cert.not_valid_after: + ret[ + "comment" + ] += "New certificate expires sooner than existing one (skip with pillar='{skip_conditions: True}')." + failed_conditions = True + if new_cert.subject.rfc4514_string() != existing_cert.subject.rfc4514_string(): + ret[ + "comment" + ] += "Certificates CN does not match (skip with pillar='{skip_conditions: True}')." + failed_conditions = True + + if failed_conditions: + ret["result"] = False + return ret + + result = __states__["file.managed"]( + name=name, + source=source, + source_hash=source_hash, + source_hash_name=source_hash_name, + user=user, + group=group, + mode=mode, + skip_verify=skip_verify, + defaults=defaults, + attrs=attrs, + context=context, + **kwargs, + ) + + ret["changes"] = result["changes"] + ret["result"] = result["result"] + ret["comment"] += result["comment"] + + return ret + + +def _error(ret, err_msg): + ret["result"] = False + ret["comment"] = err_msg + return ret diff --git a/tests/pytests/unit/states/test_pem.py b/tests/pytests/unit/states/test_pem.py new file mode 100644 index 000000000000..aff521b57458 --- /dev/null +++ b/tests/pytests/unit/states/test_pem.py @@ -0,0 +1,160 @@ +# tests/pytests/unit/states/test_pem.py + +from datetime import datetime + +import pytest + +import salt.states.pem as pem +from tests.support.mock import MagicMock, mock_open, patch + + +@pytest.fixture +def configure_loader_modules(): + return { + pem: { + "__opts__": {"test": False}, + "__salt__": {}, + "__states__": { + "file.managed": MagicMock( + return_value={"result": True, "changes": {}, "comment": ""} + ) + }, + } + } + + +def test_managed(): + """ + Test pem.managed state + """ + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + + good_cert = """ + -----BEGIN CERTIFICATE----- + MIIG6zCCBdOgAwIBAgIQD5enH9rVhbYSQrtpPBDjsDANBgkqhkiG9w0BAQsFADBZ + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE + aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMx + MDA2MDAwMDAwWhcNMjQxMTA1MjM1OTU5WjBsMQswCQYDVQQGEwJVUzEVMBMGA1UE + CBMMUGVubnN5bHZhbmlhMQ4wDAYDVQQHEwVQYW9saTEbMBkGA1UEChMSRHVjayBE + dWNrIEdvLCBJbmMuMRkwFwYDVQQDDBAqLmR1Y2tkdWNrZ28uY29tMIIBIjANBgkq + hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqZKa5gE0IO5367vb9MGypqNzDdicz3y + RAF938TbQMUA0ZCyEbAFnngUkJYLR+YmSazWHKt0kW9c2SOsu230E/YQtsdzayYu + dlHnwOuXVBeFzUCFSICMNhrRW5gw4kIh9Lw3PS8RBxafzvO4gRcTtk+odRxElH+I + BcXF4DA22mKdkA9p8Nh7HYBpsuRKniyjiUVTDC2u1uNJ3YRcWsug3Dkq/M7tw57o + dp59JNpIWOybtTfwppTrUyQGOqMzW+zt/uJYRk2ZB1Hd98hIBAqy8Q7EkEGw/V1f + CRpM+byhYgq/otBMd9XAGP9zpHD3F4qiYCQOyK+RKd1iMNXs1+CtowIDAQABo4ID + mjCCA5YwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYE + FIaIVeEXZBu4TesZG6lpf+zmOty9MCsGA1UdEQQkMCKCECouZHVja2R1Y2tnby5j + b22CDmR1Y2tkdWNrZ28uY29tMD4GA1UdIAQ3MDUwMwYGZ4EMAQICMCkwJwYIKwYB + BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC + BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMIGfBgNVHR8EgZcwgZQw + SKBGoESGQmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcy + VExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDBIoEagRIZCaHR0cDovL2NybDQuZGln + aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEu + Y3JsMIGHBggrBgEFBQcBAQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp + Z2ljZXJ0LmNvbTBRBggrBgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQu + Y29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAwG + A1UdEwEB/wQCMAAwggF8BgorBgEEAdZ5AgQCBIIBbASCAWgBZgB1AO7N0GTV2xrO + xVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABiwRZRLwAAAQDAEYwRAIgQSl6sqy2 + uIt1vG+7EHKLkToASFvYY5NV9Np8runSdAYCIBQ0qDazjm9FwrGunk8C9rEaw3QV + +hb8juCsd90A+9DXAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA + AAGLBFlEbwAABAMARjBEAiANubHLJUmCBphlEmTf4PR5TYBHnNLDTDGTEKsXDF+N + LQIgNwPp1iM7kwUT8g+nxkrPyNhNh/kQVmrfuMrBaLxwr3UAdgDatr9rP7W2Ip+b + wrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYsEWURZAAAEAwBHMEUCIGE1BSUI8i/1 + apqkN6hdrlvo0le3RYCu36BLbb9qqzn8AiEAkib0gR04diUH4Rta1EY2nyrXoTxZ + XuaT9SL5tW5aw+YwDQYJKoZIhvcNAQELBQADggEBADLm6XLJ1/uPtSDFB0rtHYVK + tKjSYqmjP2m/7xUFsc05qxmd7xBuD17wArDRZSMfnfSb4ZL1kyMmGHtUmaPLUEh1 + r9jioPvdHI09afqhLSzbGCaP9bN9hCz++m0vKVT1jyo91NuDfubYjF5IYwFpCanw + ccNUo9RHaJ78Umd697/4z5lIgNTy/EUoyOMLM77JNoYnRsgZwYuy/OmsZDLagyEy + YX4VHgyZ0mbjZ3wLhxLaR7bpXm3xaXhkT+aYhxAz41VLnTbrrd8tWndpUBZxZIOo + QzrWHN1s5ktSh2ThhyA4d3hanaxrohNFFWPqpk0WX1PZwJeNPAL8P8d8B6VPzMs= + -----END CERTIFICATE----- + """ + + # Mock file.get_managed to return local tmp file + with patch.dict( + pem.__salt__, + {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + ): + + fopen_mock = mock_open(read_data=good_cert.encode()) + + # Cache file not found test + with patch( + "salt.utils.files.fopen", MagicMock(side_effect=FileNotFoundError()) + ): + ret = pem.managed( + name=name, source=source, user=user, group=group, mode=mode + ) + assert ret["result"] is False + assert "New cached file /tmp/example.crt not found" == ret["comment"] + + # Test mode + with patch.dict(pem.__opts__, {"test": True}), patch( + "salt.utils.files.fopen", fopen_mock + ): + ret = pem.managed( + name=name, source=source, user=user, group=group, mode=mode + ) + assert ret["result"] is True + assert ( + ret["comment"] + == "Existing cert info:\n- Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n- Not valid after: 2024-11-05 23:59:59\nNew cert info:\n+ Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n+ Not valid after: 2024-11-05 23:59:59\n" + ) + assert ret["changes"] == {} + + +def test_managed_failed_conditions(): + """ + Test failure conditions in pem.managed state + """ + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + + existing_cert = MagicMock() + existing_cert.subject.rfc4514_string.return_value = "CN=existing.com" + # existing_cert.not_valid_after.isoformat.return_value = "2023-01-01T00:00:00" + existing_cert.not_valid_after = datetime(2023, 1, 1) + new_cert = MagicMock() + new_cert.subject.rfc4514_string.return_value = "CN=new.com" + # new_cert.not_valid_after.isoformat.return_value = "2022-01-01T00:00:00" + new_cert.not_valid_after = datetime(2022, 1, 1) + + with patch.dict( + pem.__salt__, + {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + ), patch("salt.utils.files.fopen", MagicMock()), patch( + "cryptography.x509.load_pem_x509_certificate", + MagicMock(side_effect=[existing_cert, new_cert]), + ): + ret = pem.managed(name=name, source=source, user=user, group=group, mode=mode) + assert ret["result"] is False + assert "New certificate expires sooner than existing one" in ret["comment"] + assert "Certificates CN does not match" in ret["comment"] + assert ret["changes"] == {} + + # Skip coditions + with patch.dict( + pem.__salt__, + {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + ), patch("salt.utils.files.fopen", MagicMock()), patch( + "cryptography.x509.load_pem_x509_certificate", + MagicMock(side_effect=[existing_cert, new_cert]), + ): + ret = pem.managed( + name=name, + source=source, + user=user, + group=group, + mode=mode, + skip_conditions=True, + ) + assert ret["result"] is True + assert ret["changes"] == {} From cf97d460472af6bc6aaf45f54269da561a871dd4 Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Mon, 8 Apr 2024 13:31:26 +0200 Subject: [PATCH 2/6] Add new lines for comments --- salt/states/pem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/salt/states/pem.py b/salt/states/pem.py index 741f43579f1d..b298a40d9f3b 100644 --- a/salt/states/pem.py +++ b/salt/states/pem.py @@ -133,12 +133,12 @@ def managed( if new_cert.not_valid_after < existing_cert.not_valid_after: ret[ "comment" - ] += "New certificate expires sooner than existing one (skip with pillar='{skip_conditions: True}')." + ] += "New certificate expires sooner than existing one (skip with pillar='{skip_conditions: True}')\n" failed_conditions = True if new_cert.subject.rfc4514_string() != existing_cert.subject.rfc4514_string(): ret[ "comment" - ] += "Certificates CN does not match (skip with pillar='{skip_conditions: True}')." + ] += "Certificates CN does not match (skip with pillar='{skip_conditions: True}')\n" failed_conditions = True if failed_conditions: From 650afd37711746f6b4eef1a2cecd1c2465ea00b7 Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Tue, 9 Apr 2024 09:52:22 +0200 Subject: [PATCH 3/6] Update comments --- salt/states/pem.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/salt/states/pem.py b/salt/states/pem.py index b298a40d9f3b..ca5f6b13db90 100644 --- a/salt/states/pem.py +++ b/salt/states/pem.py @@ -2,10 +2,9 @@ Provide easier operations on certificate files ===================================================================== -Main purpose for this is to provide easier and human readable info -regarding changes if files contains certificates. +Provides human-readable information regarding changes of files containing certificates. -Requirement is `cryptography` python package. +Requires `cryptography` python package. .. code-block:: yaml @@ -59,20 +58,23 @@ def managed( **kwargs, ): """ - State manage certificates as files and provide minimal overview of - expiration and common name in comment. + Manage certificates as files and provide certificate expiration and common name in the comment. It can handle one certificate or full privkey->cert->chain files. - Conditions can provide easy way how to match agains Common name, - or make sure that only newer certificates will be salted. - If conditions are not required use this as argument to state: - `skip_conditions=True` - Or set pillar in same manner on command line: - `salt-call state.apply some_other_state pillar="{skip_conditions: True}"` + Conditions provides an easy way to match against the certificate's Common Name + or to make sure that only newer certificates are copied down. State can handle everything that file.managed can handle, - because it is used underneat to process changes to files. + because it is used underneath to process changes to files. + + For all parameters refer to file.managed documentation: + https://docs.saltproject.io/en/master/ref/states/all/salt.states.file.html#salt.states.file.managed + + Args: + + skip_conditions (bool): Do not check expiration or Common name match (default: False) + Also pillar can be used: pillar="{skip_conditions: True}" """ skip_conditions = __pillar__.get("skip_conditions", skip_conditions) From d7f58f6cdbdc0ec1eba8be95568a937daa5f9e97 Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Thu, 11 Apr 2024 08:38:20 +0200 Subject: [PATCH 4/6] Add missing index all entry --- doc/ref/states/all/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/ref/states/all/index.rst b/doc/ref/states/all/index.rst index 924979985fa2..fcf7b0c946d2 100644 --- a/doc/ref/states/all/index.rst +++ b/doc/ref/states/all/index.rst @@ -238,6 +238,7 @@ state modules pcs pdbedit pecl + pem pip_state pkg pkgbuild From 4c2f895c883bbcc920779adc10feb4735acafb96 Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Fri, 12 Apr 2024 17:01:01 +0200 Subject: [PATCH 5/6] Add changelog --- changelog/66322.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/66322.added.md diff --git a/changelog/66322.added.md b/changelog/66322.added.md new file mode 100644 index 000000000000..21bf06ff2130 --- /dev/null +++ b/changelog/66322.added.md @@ -0,0 +1 @@ +New state to provide human-readable info regarding changes of files containing certificates From 47b3c46d36c1c8fc63d9adc33207a75247e574a2 Mon Sep 17 00:00:00 2001 From: Marek Stefanka Date: Tue, 16 Apr 2024 08:49:51 +0200 Subject: [PATCH 6/6] Implement contents and mimic file.managed state when no certs are found --- salt/states/pem.py | 228 ++++++++++++++++++++++---- tests/pytests/unit/states/test_pem.py | 178 +++++++++++++------- 2 files changed, 319 insertions(+), 87 deletions(-) diff --git a/salt/states/pem.py b/salt/states/pem.py index ca5f6b13db90..e3ac6865d7be 100644 --- a/salt/states/pem.py +++ b/salt/states/pem.py @@ -19,6 +19,8 @@ """ import logging +import os +from collections.abc import Iterable, Mapping import salt.utils.files @@ -29,6 +31,8 @@ except ImportError: HAS_CRYPTOGRAPHY = False +__NOT_FOUND = object() + __virtualname__ = "pem" @@ -41,6 +45,26 @@ def __virtual__(): log = logging.getLogger(__name__) +def _validate_str_list(arg, encoding=None): + """ + ensure ``arg`` is a list of strings + """ + if isinstance(arg, bytes): + ret = [salt.utils.stringutils.to_unicode(arg, encoding=encoding)] + elif isinstance(arg, str): + ret = [arg] + elif isinstance(arg, Iterable) and not isinstance(arg, Mapping): + ret = [] + for item in arg: + if isinstance(item, str): + ret.append(item) + else: + ret.append(str(item)) + else: + ret = [str(arg)] + return ret + + def managed( name, source=None, @@ -54,6 +78,14 @@ def managed( attrs=None, context=None, saltenv="base", + template=None, + allow_empty=False, + contents=None, + contents_pillar=None, + contents_grains=None, + contents_delimiter=":", + contents_newline=True, + encoding=None, skip_conditions=False, **kwargs, ): @@ -81,6 +113,142 @@ def managed( ret = {"name": name, "changes": {}, "result": False, "comment": ""} existing_cert_info = "" new_cert_info = "" + source_content = None + + # contents, contents_pillar and content_grains management + if contents_pillar is not None: + if isinstance(contents_pillar, list): + list_contents = [] + for nextp in contents_pillar: + nextc = __salt__["pillar.get"]( + nextp, __NOT_FOUND, delimiter=contents_delimiter + ) + if nextc is __NOT_FOUND: + return _error(ret, f"Pillar {nextp} does not exist") + list_contents.append(nextc) + use_contents = os.linesep.join(list_contents) + else: + use_contents = __salt__["pillar.get"]( + contents_pillar, __NOT_FOUND, delimiter=contents_delimiter + ) + if use_contents is __NOT_FOUND: + return _error(ret, f"Pillar {contents_pillar} does not exist") + + elif contents_grains is not None: + if isinstance(contents_grains, list): + list_contents = [] + for nextg in contents_grains: + nextc = __salt__["grains.get"]( + nextg, __NOT_FOUND, delimiter=contents_delimiter + ) + if nextc is __NOT_FOUND: + return _error(ret, f"Grain {nextc} does not exist") + list_contents.append(nextc) + use_contents = os.linesep.join(list_contents) + else: + use_contents = __salt__["grains.get"]( + contents_grains, __NOT_FOUND, delimiter=contents_delimiter + ) + if use_contents is __NOT_FOUND: + return _error(ret, f"Grain {contents_grains} does not exist") + + elif contents is not None: + use_contents = contents + + else: + use_contents = None + + if use_contents is not None: + if not allow_empty and not use_contents: + if contents_pillar: + contents_id = f"contents_pillar {contents_pillar}" + elif contents_grains: + contents_id = f"contents_grains {contents_grains}" + else: + contents_id = "'contents'" + return _error( + ret, + "{} value would result in empty contents. Set allow_empty " + "to True to allow the managed file to be empty.".format(contents_id), + ) + + try: + validated_contents = _validate_str_list(use_contents, encoding=encoding) + if not validated_contents: + return _error( + ret, + "Contents specified by contents/contents_pillar/" + "contents_grains is not a string or list of strings, and " + "is not binary data. SLS is likely malformed.", + ) + source_content = "" + for part in validated_contents: + for line in part.splitlines(): + source_content += line.rstrip("\n").rstrip("\r") + os.linesep + if not contents_newline: + # If source_content newline is set to False, strip out the newline + # character and carriage return character + source_content = source_content.rstrip("\n").rstrip("\r") + + except UnicodeDecodeError: + # Either something terrible happened, or we have binary data. + if template: + return _error( + ret, + "Contents specified by source_content/contents_pillar/" + "contents_grains appears to be binary data, and" + " as will not be able to be treated as a Jinja" + " template.", + ) + source_content = use_contents + + # If no contents specified, get content from salt + if source_content is None: + try: + source_content = __salt__["cp.get_file_str"]( + path=source, + saltenv=saltenv, + ) + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Unable to get file str: {exc}" + return ret + + # Apply template + if template: + source_content = __salt__["file.apply_template_on_contents"]( + source_content, + template=template, + context=context, + defaults=defaults, + saltenv=saltenv, + ) + if not isinstance(source_content, str): + if "result" in source_content: + ret["result"] = source_content["result"] + else: + ret["result"] = False + if "comment" in source_content: + ret["comment"] = source_content["comment"] + else: + ret["comment"] = "Error while applying template on source_content" + return ret + + if source_content is None: + return _error(ret, "source_content is empty") + + try: + new_cert = x509.load_pem_x509_certificate(source_content.encode()) + new_cert_info = f"+ Subject: {new_cert.subject.rfc4514_string()}\n+ Not valid after: {new_cert.not_valid_after}" + except ValueError as val_err: + # This is not a certificate, but we can still continue with file.managed backend + log.debug("pem: %s", val_err) + log.debug("pem: Value error found, continue normally as file.managed state") + skip_conditions = True + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Problem with source file: {exc}" + return ret # Load existing certificate try: @@ -90,41 +258,32 @@ def managed( except FileNotFoundError: # Old certificate initialy does not need to exist if it is a first time state is running skip_conditions = True - - try: - tmp_local_file, source_sum, comment_ = __salt__["file.get_managed"]( - name, - source=source, - source_hash=source_hash, - source_hash_name=source_hash_name, - user=user, - group=group, - mode=mode, - attrs=attrs, - saltenv=saltenv, - defaults=defaults, - skip_verify=skip_verify, - context=context, - **kwargs, - ) + except ValueError as val_err: + # This is not a certificate, but we can still continue with file.managed backend + log.debug("pem: %s", val_err) + log.debug("pem: Value error found, continue normally as file.managed state") + skip_conditions = True except Exception as exc: # pylint: disable=broad-except ret["result"] = False - ret["comment"] = f"Unable to manage file: {exc}" + ret["comment"] = f"Unable to determine existing file: {exc}" return ret - if not tmp_local_file: - return _error(ret, f"Source file {source} not found") - - try: - with salt.utils.files.fopen(tmp_local_file, "rb") as new_cert_file: - new_cert = x509.load_pem_x509_certificate(new_cert_file.read()) - new_cert_info = f"+ Subject: {new_cert.subject.rfc4514_string()}\n+ Not valid after: {new_cert.not_valid_after}" - except FileNotFoundError: - return _error(ret, f"New cached file {tmp_local_file} not found") - - ret["comment"] = ( - f"Existing cert info:\n{existing_cert_info}\nNew cert info:\n{new_cert_info}\n" - ) + if existing_cert_info == "" and new_cert_info == "": + log.debug( + "pem: No certificate information was found - state is running as normal file.managed state" + ) + elif existing_cert_info != "" and new_cert_info != "": + if ( + new_cert.subject.rfc4514_string() == existing_cert.subject.rfc4514_string() + and new_cert.not_valid_after == existing_cert.not_valid_after + ): + ret["comment"] = f"Certificates are the same:\n{existing_cert_info}\n" + elif existing_cert_info == "" and new_cert_info != "": + ret["comment"] = f"New cert info:\n{new_cert_info}\n" + else: + ret["comment"] = ( + f"Existing cert info:\n{existing_cert_info}\nNew cert info:\n{new_cert_info}\n" + ) # Conditions when certificates are salted if skip_conditions: @@ -159,6 +318,13 @@ def managed( defaults=defaults, attrs=attrs, context=context, + template=template, + allow_empty=allow_empty, + contents=contents, + contents_pillar=contents_pillar, + contents_grains=contents_grains, + contents_delimiter=contents_delimiter, + contents_newline=contents_newline, **kwargs, ) diff --git a/tests/pytests/unit/states/test_pem.py b/tests/pytests/unit/states/test_pem.py index aff521b57458..80da47d91236 100644 --- a/tests/pytests/unit/states/test_pem.py +++ b/tests/pytests/unit/states/test_pem.py @@ -7,6 +7,65 @@ import salt.states.pem as pem from tests.support.mock import MagicMock, mock_open, patch +GOOD_CERT = """ + -----BEGIN CERTIFICATE----- + MIIG6zCCBdOgAwIBAgIQD5enH9rVhbYSQrtpPBDjsDANBgkqhkiG9w0BAQsFADBZ + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE + aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMx + MDA2MDAwMDAwWhcNMjQxMTA1MjM1OTU5WjBsMQswCQYDVQQGEwJVUzEVMBMGA1UE + CBMMUGVubnN5bHZhbmlhMQ4wDAYDVQQHEwVQYW9saTEbMBkGA1UEChMSRHVjayBE + dWNrIEdvLCBJbmMuMRkwFwYDVQQDDBAqLmR1Y2tkdWNrZ28uY29tMIIBIjANBgkq + hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqZKa5gE0IO5367vb9MGypqNzDdicz3y + RAF938TbQMUA0ZCyEbAFnngUkJYLR+YmSazWHKt0kW9c2SOsu230E/YQtsdzayYu + dlHnwOuXVBeFzUCFSICMNhrRW5gw4kIh9Lw3PS8RBxafzvO4gRcTtk+odRxElH+I + BcXF4DA22mKdkA9p8Nh7HYBpsuRKniyjiUVTDC2u1uNJ3YRcWsug3Dkq/M7tw57o + dp59JNpIWOybtTfwppTrUyQGOqMzW+zt/uJYRk2ZB1Hd98hIBAqy8Q7EkEGw/V1f + CRpM+byhYgq/otBMd9XAGP9zpHD3F4qiYCQOyK+RKd1iMNXs1+CtowIDAQABo4ID + mjCCA5YwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYE + FIaIVeEXZBu4TesZG6lpf+zmOty9MCsGA1UdEQQkMCKCECouZHVja2R1Y2tnby5j + b22CDmR1Y2tkdWNrZ28uY29tMD4GA1UdIAQ3MDUwMwYGZ4EMAQICMCkwJwYIKwYB + BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC + BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMIGfBgNVHR8EgZcwgZQw + SKBGoESGQmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcy + VExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDBIoEagRIZCaHR0cDovL2NybDQuZGln + aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEu + Y3JsMIGHBggrBgEFBQcBAQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp + Z2ljZXJ0LmNvbTBRBggrBgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQu + Y29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAwG + A1UdEwEB/wQCMAAwggF8BgorBgEEAdZ5AgQCBIIBbASCAWgBZgB1AO7N0GTV2xrO + xVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABiwRZRLwAAAQDAEYwRAIgQSl6sqy2 + uIt1vG+7EHKLkToASFvYY5NV9Np8runSdAYCIBQ0qDazjm9FwrGunk8C9rEaw3QV + +hb8juCsd90A+9DXAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA + AAGLBFlEbwAABAMARjBEAiANubHLJUmCBphlEmTf4PR5TYBHnNLDTDGTEKsXDF+N + LQIgNwPp1iM7kwUT8g+nxkrPyNhNh/kQVmrfuMrBaLxwr3UAdgDatr9rP7W2Ip+b + wrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYsEWURZAAAEAwBHMEUCIGE1BSUI8i/1 + apqkN6hdrlvo0le3RYCu36BLbb9qqzn8AiEAkib0gR04diUH4Rta1EY2nyrXoTxZ + XuaT9SL5tW5aw+YwDQYJKoZIhvcNAQELBQADggEBADLm6XLJ1/uPtSDFB0rtHYVK + tKjSYqmjP2m/7xUFsc05qxmd7xBuD17wArDRZSMfnfSb4ZL1kyMmGHtUmaPLUEh1 + r9jioPvdHI09afqhLSzbGCaP9bN9hCz++m0vKVT1jyo91NuDfubYjF5IYwFpCanw + ccNUo9RHaJ78Umd697/4z5lIgNTy/EUoyOMLM77JNoYnRsgZwYuy/OmsZDLagyEy + YX4VHgyZ0mbjZ3wLhxLaR7bpXm3xaXhkT+aYhxAz41VLnTbrrd8tWndpUBZxZIOo + QzrWHN1s5ktSh2ThhyA4d3hanaxrohNFFWPqpk0WX1PZwJeNPAL8P8d8B6VPzMs= + -----END CERTIFICATE----- +""" + +BAD_CERT = """ + -----BEGIN CERTIFICATE----- + MIICEjCCAXsCAg36MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG + A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE + MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl + YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw + ODIyMDUyNjU0WhcNMTcwODIxMDUyNjU0WjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE + CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs + ZS5jb20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEAm/xmkHmEQrurE/0re/jeFRLl + 8ZPjBop7uLHhnia7lQG/5zDtZIUC3RVpqDSwBuw/NTweGyuP+o8AG98HxqxTBwID + AQABMA0GCSqGSIb3DQEBBQUAA4GBABS2TLuBeTPmcaTaUW/LCB2NYOy8GMdzR1mx + 8iBIu2H6/E2tiY3RIevV2OW61qY2/XRQg7YPxx3ffeUugX9F4J/iPnnu1zAxxyBy + 2VguKv4SWjRFoRkIfIlHX0qVviMhSlNy2ioFLy7JcPZb+v3ftDGywUqcBiVDoea0 + Hn+GmxZA + -----END CERTIFICATE----- +""" + @pytest.fixture def configure_loader_modules(): @@ -33,85 +92,92 @@ def test_managed(): group = "root" mode = "0600" - good_cert = """ - -----BEGIN CERTIFICATE----- - MIIG6zCCBdOgAwIBAgIQD5enH9rVhbYSQrtpPBDjsDANBgkqhkiG9w0BAQsFADBZ - MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE - aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMx - MDA2MDAwMDAwWhcNMjQxMTA1MjM1OTU5WjBsMQswCQYDVQQGEwJVUzEVMBMGA1UE - CBMMUGVubnN5bHZhbmlhMQ4wDAYDVQQHEwVQYW9saTEbMBkGA1UEChMSRHVjayBE - dWNrIEdvLCBJbmMuMRkwFwYDVQQDDBAqLmR1Y2tkdWNrZ28uY29tMIIBIjANBgkq - hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqZKa5gE0IO5367vb9MGypqNzDdicz3y - RAF938TbQMUA0ZCyEbAFnngUkJYLR+YmSazWHKt0kW9c2SOsu230E/YQtsdzayYu - dlHnwOuXVBeFzUCFSICMNhrRW5gw4kIh9Lw3PS8RBxafzvO4gRcTtk+odRxElH+I - BcXF4DA22mKdkA9p8Nh7HYBpsuRKniyjiUVTDC2u1uNJ3YRcWsug3Dkq/M7tw57o - dp59JNpIWOybtTfwppTrUyQGOqMzW+zt/uJYRk2ZB1Hd98hIBAqy8Q7EkEGw/V1f - CRpM+byhYgq/otBMd9XAGP9zpHD3F4qiYCQOyK+RKd1iMNXs1+CtowIDAQABo4ID - mjCCA5YwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYE - FIaIVeEXZBu4TesZG6lpf+zmOty9MCsGA1UdEQQkMCKCECouZHVja2R1Y2tnby5j - b22CDmR1Y2tkdWNrZ28uY29tMD4GA1UdIAQ3MDUwMwYGZ4EMAQICMCkwJwYIKwYB - BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC - BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMIGfBgNVHR8EgZcwgZQw - SKBGoESGQmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcy - VExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDBIoEagRIZCaHR0cDovL2NybDQuZGln - aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEu - Y3JsMIGHBggrBgEFBQcBAQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp - Z2ljZXJ0LmNvbTBRBggrBgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQu - Y29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAwG - A1UdEwEB/wQCMAAwggF8BgorBgEEAdZ5AgQCBIIBbASCAWgBZgB1AO7N0GTV2xrO - xVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABiwRZRLwAAAQDAEYwRAIgQSl6sqy2 - uIt1vG+7EHKLkToASFvYY5NV9Np8runSdAYCIBQ0qDazjm9FwrGunk8C9rEaw3QV - +hb8juCsd90A+9DXAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA - AAGLBFlEbwAABAMARjBEAiANubHLJUmCBphlEmTf4PR5TYBHnNLDTDGTEKsXDF+N - LQIgNwPp1iM7kwUT8g+nxkrPyNhNh/kQVmrfuMrBaLxwr3UAdgDatr9rP7W2Ip+b - wrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYsEWURZAAAEAwBHMEUCIGE1BSUI8i/1 - apqkN6hdrlvo0le3RYCu36BLbb9qqzn8AiEAkib0gR04diUH4Rta1EY2nyrXoTxZ - XuaT9SL5tW5aw+YwDQYJKoZIhvcNAQELBQADggEBADLm6XLJ1/uPtSDFB0rtHYVK - tKjSYqmjP2m/7xUFsc05qxmd7xBuD17wArDRZSMfnfSb4ZL1kyMmGHtUmaPLUEh1 - r9jioPvdHI09afqhLSzbGCaP9bN9hCz++m0vKVT1jyo91NuDfubYjF5IYwFpCanw - ccNUo9RHaJ78Umd697/4z5lIgNTy/EUoyOMLM77JNoYnRsgZwYuy/OmsZDLagyEy - YX4VHgyZ0mbjZ3wLhxLaR7bpXm3xaXhkT+aYhxAz41VLnTbrrd8tWndpUBZxZIOo - QzrWHN1s5ktSh2ThhyA4d3hanaxrohNFFWPqpk0WX1PZwJeNPAL8P8d8B6VPzMs= - -----END CERTIFICATE----- - """ - - # Mock file.get_managed to return local tmp file + # Mock cp.get_file_str to return local tmp file with patch.dict( pem.__salt__, - {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + {"cp.get_file_str": MagicMock(return_value=GOOD_CERT)}, ): - fopen_mock = mock_open(read_data=good_cert.encode()) - - # Cache file not found test + # Local existing cert was not found with patch( "salt.utils.files.fopen", MagicMock(side_effect=FileNotFoundError()) ): ret = pem.managed( name=name, source=source, user=user, group=group, mode=mode ) - assert ret["result"] is False - assert "New cached file /tmp/example.crt not found" == ret["comment"] + assert ret["result"] is True + assert ( + ret["comment"] + == "New cert info:\n+ Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n+ Not valid after: 2024-11-05 23:59:59\n" + ) # Test mode with patch.dict(pem.__opts__, {"test": True}), patch( - "salt.utils.files.fopen", fopen_mock + "salt.utils.files.fopen", mock_open(read_data=BAD_CERT.encode()) ): ret = pem.managed( name=name, source=source, user=user, group=group, mode=mode ) - assert ret["result"] is True + assert ret["result"] is False assert ( ret["comment"] - == "Existing cert info:\n- Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n- Not valid after: 2024-11-05 23:59:59\nNew cert info:\n+ Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n+ Not valid after: 2024-11-05 23:59:59\n" + == "Certificates CN does not match (skip with pillar='{skip_conditions: True}')\n" ) assert ret["changes"] == {} +def test_managed_with_templating_and_cp_get_file_str(): + """ + Test pem.managed state with templating and cp.get_file_str + """ + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + template = "jinja" + + # Mock cp.get_file_str to return the good certificate + with patch.dict( + pem.__salt__, {"cp.get_file_str": MagicMock(return_value=GOOD_CERT)} + ), patch.dict( + pem.__salt__, + {"file.apply_template_on_contents": MagicMock(return_value=GOOD_CERT)}, + ), patch( + "salt.utils.files.fopen", mock_open(read_data=GOOD_CERT.encode()) + ): + + # Call the managed function with the template argument + ret = pem.managed( + name=name, + source=source, + user=user, + group=group, + mode=mode, + template=template, + ) + + # Assertions to ensure the template was applied and cp.get_file_str was called + pem.__salt__["file.apply_template_on_contents"].assert_called_with( + GOOD_CERT, + template=template, + context=None, + defaults=None, + saltenv="base", + ) + pem.__salt__["cp.get_file_str"].assert_called_with(path=source, saltenv="base") + + # Check the result + assert ret["result"] is True + assert "Certificates are the same:" in ret["comment"] + assert ret["changes"] == {} + + def test_managed_failed_conditions(): """ Test failure conditions in pem.managed state """ + name = "/tmp/example.crt" source = "salt://example.crt" user = "root" @@ -129,10 +195,10 @@ def test_managed_failed_conditions(): with patch.dict( pem.__salt__, - {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + {"cp.get_file_str": MagicMock(return_value=new_cert)}, ), patch("salt.utils.files.fopen", MagicMock()), patch( "cryptography.x509.load_pem_x509_certificate", - MagicMock(side_effect=[existing_cert, new_cert]), + MagicMock(side_effect=[new_cert, existing_cert]), ): ret = pem.managed(name=name, source=source, user=user, group=group, mode=mode) assert ret["result"] is False @@ -143,10 +209,10 @@ def test_managed_failed_conditions(): # Skip coditions with patch.dict( pem.__salt__, - {"file.get_managed": MagicMock(return_value=("/tmp/example.crt", None, None))}, + {"cp.get_file_str": MagicMock(return_value=new_cert)}, ), patch("salt.utils.files.fopen", MagicMock()), patch( "cryptography.x509.load_pem_x509_certificate", - MagicMock(side_effect=[existing_cert, new_cert]), + MagicMock(side_effect=[new_cert, existing_cert]), ): ret = pem.managed( name=name,