From 0d23fa92788b1258005e46920505d9b768d30799 Mon Sep 17 00:00:00 2001 From: Florence Blanc-Renaud Date: Mar 14 2019 08:39:55 +0000 Subject: CRL generation master: new utility to enable|disable Implement a new command ipa-clrgen-manage to enable, disable, or check the status of CRL generation on the localhost. The command automates the manual steps described in the wiki https://www.freeipa.org/page/Howto/Promote_CA_to_Renewal_and_CRL_Master Fixes: https://pagure.io/freeipa/issue/5803 Reviewed-By: Rob Crittenden Reviewed-By: Francois Cami --- diff --git a/freeipa.spec.in b/freeipa.spec.in index 1489296..1fdb130 100644 --- a/freeipa.spec.in +++ b/freeipa.spec.in @@ -997,6 +997,7 @@ fi %{_sbindir}/ipa-cacert-manage %{_sbindir}/ipa-winsync-migrate %{_sbindir}/ipa-pkinit-manage +%{_sbindir}/ipa-crlgen-manage %{_libexecdir}/certmonger/dogtag-ipa-ca-renew-agent-submit %{_libexecdir}/certmonger/ipa-server-guard %dir %{_libexecdir}/ipa @@ -1055,6 +1056,7 @@ fi %{_mandir}/man1/ipa-cacert-manage.1* %{_mandir}/man1/ipa-winsync-migrate.1* %{_mandir}/man1/ipa-pkinit-manage.1* +%{_mandir}/man1/ipa-crlgen-manage.1* %files -n python3-ipaserver diff --git a/install/tools/Makefile.am b/install/tools/Makefile.am index d49f4e2..35e75a0 100644 --- a/install/tools/Makefile.am +++ b/install/tools/Makefile.am @@ -28,6 +28,7 @@ dist_noinst_DATA = \ ipa-cacert-manage.in \ ipa-winsync-migrate.in \ ipa-pkinit-manage.in \ + ipa-crlgen-manage.in \ ipa-custodia.in \ ipa-custodia-check.in \ ipa-httpd-kdcproxy.in \ @@ -58,6 +59,7 @@ nodist_sbin_SCRIPTS = \ ipa-cacert-manage \ ipa-winsync-migrate \ ipa-pkinit-manage \ + ipa-crlgen-manage \ $(NULL) appdir = $(libexecdir)/ipa/ diff --git a/install/tools/ipa-crlgen-manage.in b/install/tools/ipa-crlgen-manage.in new file mode 100644 index 0000000..5d895ab --- /dev/null +++ b/install/tools/ipa-crlgen-manage.in @@ -0,0 +1,8 @@ +@PYTHONSHEBANG@ +# +# Copyright (C) 2019 FreeIPA Contributors see COPYING for license +# + +from ipaserver.install.ipa_crlgen_manage import CRLGenManage + +CRLGenManage.run_cli() diff --git a/install/tools/man/Makefile.am b/install/tools/man/Makefile.am index f488aae..a31ca0c 100644 --- a/install/tools/man/Makefile.am +++ b/install/tools/man/Makefile.am @@ -27,6 +27,7 @@ dist_man1_MANS = \ ipa-cacert-manage.1 \ ipa-winsync-migrate.1 \ ipa-pkinit-manage.1 \ + ipa-crlgen-manage.1 \ $(NULL) dist_man8_MANS = \ diff --git a/install/tools/man/ipa-crlgen-manage.1 b/install/tools/man/ipa-crlgen-manage.1 new file mode 100644 index 0000000..1fa48ce --- /dev/null +++ b/install/tools/man/ipa-crlgen-manage.1 @@ -0,0 +1,47 @@ +.\" +.\" Copyright (C) 2019 FreeIPA Contributors see COPYING for license +.\" +.TH "ipa-crlgen-manage" "1" "Feb 12 2019" "FreeIPA" "FreeIPA Manual Pages" +.SH "NAME" +ipa\-crlgen\-manage \- Enables or disables CRL generation +.SH "SYNOPSIS" +ipa\-crlgen\-manage [options] +.SH "DESCRIPTION" +Run the command with the \fBenable\fR option to enable CRL generation on the +local host. This requires that the IPA server is already installed and +configured, including a CA. The command will restart Dogtag and Apache. + +Run the command with the \fBdisable\fR option to disable CRL generation on the +local host. The command will restart Dogtag and Apache. + +Run the command with the \fBstatus\fR option to determine the current status +of CRL generation. If the local host is configured for CRL generation, the +command also prints the last CRL generation date and number. + +Important: the administrator must ensure that there is only one IPA server +generating CRLs. In order to transfer the CRL generation from one server to +another, please run \fBipa-crlgen-manage disable\fR on the current CRL +generation master, followed by \fBipa-crlgen-manage enable\fR on the new +CRL generation master. +.SH "OPTIONS" +.TP +\fB\-\-version\fR +Show the program's version and exit. +.TP +\fB\-h\fR, \fB\-\-help\fR +Show the help for this program. +.TP +\fB\-v\fR, \fB\-\-verbose\fR +Print debugging information. +.TP +\fB\-q\fR, \fB\-\-quiet\fR +Output only errors. +.TP +\fB\-\-log\-file\fR=\fIFILE\fR +Log to the given file. +.SH "EXIT STATUS" +0 if the command was successful + +1 if an error occurred + +2 if the local host is not an IPA server diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py index 01c166a..6087bf0 100644 --- a/ipaserver/install/cainstance.py +++ b/ipaserver/install/cainstance.py @@ -256,6 +256,10 @@ def is_ca_installed_locally(): return os.path.exists(paths.CA_CS_CFG_PATH) +class InconsistentCRLGenConfigException(Exception): + pass + + class CAInstance(DogtagInstance): """ When using a dogtag CA the DS database contains just the @@ -278,6 +282,14 @@ class CAInstance(DogtagInstance): 'subsystemCert cert-pki-ca', 'caSigningCert cert-pki-ca') server_cert_name = 'Server-Cert cert-pki-ca' + # The following must be aligned with the RewriteRule defined in + # install/share/ipa-pki-proxy.conf.template + crl_rewrite_pattern = r"^\s*(RewriteRule\s+\^/ipa/crl/MasterCRL.bin\s.*)$" + crl_rewrite_comment = r"^#\s*RewriteRule\s+\^/ipa/crl/MasterCRL.bin\s.*$" + crl_rewriterule = "\nRewriteRule ^/ipa/crl/MasterCRL.bin " \ + "http://{}/ca/ee/ca/getCRL?" \ + "op=getCRL&crlIssuingPoint=MasterCRL " \ + "[L,R=301,NC]" def __init__(self, realm=None, host_name=None, custodia=None): super(CAInstance, self).__init__( @@ -1386,6 +1398,155 @@ class CAInstance(DogtagInstance): '50-dogtag10-migration.update')] ) + def is_crlgen_enabled(self): + """Check if the local CA instance is generating CRL + + Three conditions must be met to consider that the local CA is CRL + generation master: + - in CS.cfg ca.crl.MasterCRL.enableCRLCache=true + - in CS.cfg ca.crl.MasterCRL.enableCRLUpdates=true + - in /etc/httpd/conf.d/ipa-pki-proxy.conf the RewriteRule + ^/ipa/crl/MasterCRL.bin is disabled (commented or removed) + + If the values are inconsistent, an exception is raised + :returns: True/False + :raises: InconsistentCRLGenConfigException if the config is + inconsistent + """ + try: + cache = directivesetter.get_directive( + self.config, 'ca.crl.MasterCRL.enableCRLCache', '=') + enableCRLCache = cache.lower() == 'true' + updates = directivesetter.get_directive( + self.config, 'ca.crl.MasterCRL.enableCRLUpdates', '=') + enableCRLUpdates = updates.lower() == 'true' + + # If the values are different, the config is inconsistent + if enableCRLCache != enableCRLUpdates: + raise InconsistentCRLGenConfigException( + "Configuration is inconsistent, please check " + "ca.crl.MasterCRL.enableCRLCache and " + "ca.crl.MasterCRL.enableCRLUpdates in {} and " + "run ipa-crlgen-manage [enable|disable] to repair".format( + self.config)) + except IOError: + raise RuntimeError( + "Unable to read {}".format(self.config)) + + # At this point enableCRLCache and enableCRLUpdates have the same value + try: + rewriteRuleDisabled = True + p = re.compile(self.crl_rewrite_pattern) + with open(paths.HTTPD_IPA_PKI_PROXY_CONF) as f: + for line in f.readlines(): + if p.search(line): + rewriteRuleDisabled = False + break + except IOError: + raise RuntimeError( + "Unable to read {}".format(paths.HTTPD_IPA_PKI_PROXY_CONF)) + + # if enableCRLUpdates and rewriteRuleDisabled are different, the config + # is inconsistent + if enableCRLUpdates != rewriteRuleDisabled: + raise InconsistentCRLGenConfigException( + "Configuration is inconsistent, please check " + "ca.crl.MasterCRL.enableCRLCache in {} and the " + "RewriteRule ^/ipa/crl/MasterCRL.bin in {} and " + "run ipa-crlgen-manage [enable|disable] to repair".format( + self.config, paths.HTTPD_IPA_PKI_PROXY_CONF)) + return enableCRLUpdates + + def setup_crlgen(self, setup_crlgen): + """Configure the local host for CRL generation + + :param setup_crlgen: if True enable CRL generation, if False, disable + """ + try: + crlgen_enabled = self.is_crlgen_enabled() + if crlgen_enabled == setup_crlgen: + logger.info( + "Nothing to do, CRL generation already %s", + "enabled" if crlgen_enabled else "disabled") + return + except InconsistentCRLGenConfigException: + logger.warning("CRL generation is partially enabled, repairing...") + + # Stop PKI + logger.info("Stopping %s", self.service_name) + self.stop_instance() + logger.debug("%s successfully stopped", self.service_name) + + # Edit the CS.cfg directives + logger.info("Editing %s", self.config) + with directivesetter.DirectiveSetter( + self.config, quotes=False, separator='=') as ds: + # Convert the bool setup_crlgen to a lowercase string + str_value = str(setup_crlgen).lower() + ds.set('ca.crl.MasterCRL.enableCRLCache', str_value) + ds.set('ca.crl.MasterCRL.enableCRLUpdates', str_value) + + # Start pki-tomcat + logger.info("Starting %s", self.service_name) + self.start_instance() + logger.debug("%s successfully started", self.service_name) + + # Edit the RewriteRule + def comment_rewriterule(): + logger.info("Editing %s", paths.HTTPD_IPA_PKI_PROXY_CONF) + # look for the pattern RewriteRule ^/ipa/crl/MasterCRL.bin .. + # and comment out + p = re.compile(self.crl_rewrite_pattern, re.MULTILINE) + with open(paths.HTTPD_IPA_PKI_PROXY_CONF) as f: + content = f.read() + new_content = p.sub(r"#\1", content) + with open(paths.HTTPD_IPA_PKI_PROXY_CONF, 'w') as f: + f.write(new_content) + + def uncomment_rewriterule(): + logger.info("Editing %s", paths.HTTPD_IPA_PKI_PROXY_CONF) + # check if the pattern RewriteRule ^/ipa/crl/MasterCRL.bin .. + # is already present + present = False + p = re.compile(self.crl_rewrite_pattern, re.MULTILINE) + with open(paths.HTTPD_IPA_PKI_PROXY_CONF) as f: + content = f.read() + present = p.search(content) + # Remove the comment + p_comment = re.compile(self.crl_rewrite_comment, re.MULTILINE) + new_content = p_comment.sub("", content) + # If not already present, add RewriteRule + if not present: + new_content += self.crl_rewriterule.format(api.env.host) + # Finally write the file + with open(paths.HTTPD_IPA_PKI_PROXY_CONF, 'w') as f: + f.write(new_content) + + try: + if setup_crlgen: + comment_rewriterule() + else: + uncomment_rewriterule() + + except IOError: + raise RuntimeError( + "Unable to access {}".format(paths.HTTPD_IPA_PKI_PROXY_CONF)) + + # Restart httpd + http_service = services.knownservices.httpd + logger.info("Restarting %s", http_service.service_name) + http_service.restart() + logger.debug("%s successfully restarted", http_service.service_name) + + # make sure a CRL is generated if setup_crl is True + if setup_crlgen: + logger.info("Forcing CRL update") + api.Backend.ra.override_port = 8443 + result = api.Backend.ra.updateCRL(wait='true') + if result.get('crlUpdate', 'Failure') == 'Success': + logger.debug("Successfully updated CRL") + api.Backend.ra.override_port = None + def __update_entry_from_cert(make_filter, make_entry, cert): """ @@ -1466,7 +1627,6 @@ def __update_entry_from_cert(make_filter, make_entry, cert): return True - def update_people_entry(cert): """ Update the userCerticate for an entry in the dogtag ou=People. This diff --git a/ipaserver/install/ipa_crlgen_manage.py b/ipaserver/install/ipa_crlgen_manage.py new file mode 100644 index 0000000..b5d9a81 --- /dev/null +++ b/ipaserver/install/ipa_crlgen_manage.py @@ -0,0 +1,118 @@ +# +# Copyright (C) 2019 FreeIPA Contributors see COPYING for license +# + +from __future__ import print_function, absolute_import + +import os +import logging +from cryptography.hazmat.backends import default_backend +from cryptography import x509 + +from ipalib import api +from ipalib.errors import NetworkError +from ipaplatform.paths import paths +from ipapython.admintool import AdminTool +from ipaserver.install import cainstance +from ipaserver.install import installutils + +logger = logging.getLogger(__name__) + + +class CRLGenManage(AdminTool): + command_name = "ipa-crlgen-manage" + usage = "%prog " + description = "Manage CRL Generation Master." + + def validate_options(self): + super(CRLGenManage, self).validate_options(needs_root=True) + installutils.check_server_configuration() + + option_parser = self.option_parser + + if not self.args: + option_parser.error("action not specified") + elif len(self.args) > 1: + option_parser.error("too many arguments") + + action = self.args[0] + if action not in {'enable', 'disable', 'status'}: + option_parser.error("unrecognized action '{}'".format(action)) + + def run(self): + api.bootstrap(in_server=True, confdir=paths.ETC_IPA) + api.finalize() + + try: + api.Backend.ldap2.connect() + except NetworkError as e: + logger.debug("Unable to connect to the local instance: %s", e) + raise RuntimeError("IPA must be running, please run ipactl start") + ca = cainstance.CAInstance(api.env.realm) + + try: + action = self.args[0] + if action == 'enable': + self.enable(ca) + elif action == 'disable': + self.disable(ca) + elif action == 'status': + self.status(ca) + finally: + api.Backend.ldap2.disconnect() + + return 0 + + def check_local_ca_instance(self, raiseOnErr=False): + if not api.Command.ca_is_enabled()['result'] or \ + not cainstance.is_ca_installed_locally(): + if raiseOnErr: + raise RuntimeError("Dogtag CA is not installed. " + "Please install a CA first with the " + "`ipa-ca-install` command.") + else: + logger.warning( + "Warning: Dogtag CA is not installed on this server.") + return False + return True + + def enable(self, ca): + # When the local node is not a CA, raise an Exception + self.check_local_ca_instance(raiseOnErr=True) + ca.setup_crlgen(True) + logger.info("CRL generation enabled on the local host. " + "Please make sure to have only a single CRL generation " + "master.") + + def disable(self, ca): + # When the local node is not a CA, nothing to do + if not self.check_local_ca_instance(): + return + ca.setup_crlgen(False) + logger.info("CRL generation disabled on the local host. " + "Please make sure to configure CRL generation on another " + "master with %s enable", self.command_name) + + def status(self, ca): + # When the local node is not a CA, return "disabled" + if not self.check_local_ca_instance(): + print("CRL generation: disabled") + return + + # Local node is a CA, check its configuration + if ca.is_crlgen_enabled(): + print("CRL generation: enabled") + try: + crl_filename = os.path.join(paths.PKI_CA_PUBLISH_DIR, + 'MasterCRL.bin') + with open(crl_filename, 'rb') as f: + crl = x509.load_der_x509_crl(f.read(), default_backend()) + print("Last CRL update: {}".format(crl.last_update)) + for ext in crl.extensions: + if ext.oid == x509.oid.ExtensionOID.CRL_NUMBER: + print("Last CRL Number: {}".format( + ext.value.crl_number)) + except IOError: + logger.error("Unable to find last CRL") + else: + print("CRL generation: disabled") diff --git a/ipaserver/plugins/dogtag.py b/ipaserver/plugins/dogtag.py index 7575d1a..4de26d7 100644 --- a/ipaserver/plugins/dogtag.py +++ b/ipaserver/plugins/dogtag.py @@ -1148,6 +1148,67 @@ def parse_unrevoke_cert_xml(doc): return response +def parse_updateCRL_xml(doc): + ''' + :param doc: The root node of the xml document to parse + :returns: result dict + :except ValueError: + + After parsing the results are returned in a result dict. The following + table illustrates the mapping from the CMS data item to what may be found + in the result dict. If a CMS data item is absent it will also be absent in + the result dict. + + If the requestStatus is not SUCCESS then the response dict will have the + contents described in `parse_error_template_xml`. + + +-----------------+-------------+-----------------------+---------------+ + |cms name |cms type |result name |result type | + +=================+=============+=======================+===============+ + |crlIssuingPoint |string |crl_issuing_point |unicode | + +-----------------+-------------+-----------------------+---------------+ + |crlUpdate |string |crl_update [1] |unicode | + +-----------------+-------------+-----------------------+---------------+ + + .. [1] crlUpdate may be one of: + + - "Success" + - "Failure" + - "missingParameters" + - "testingNotEnabled" + - "testingInProgress" + - "Scheduled" + - "inProgress" + - "disabled" + - "notInitialized" + + ''' + + request_status = get_request_status_xml(doc) + + if request_status != CMS_STATUS_SUCCESS: + response = parse_error_template_xml(doc) + return response + + response = {} + response['request_status'] = request_status + + crl_issuing_point = doc.xpath('//xml/header/crlIssuingPoint[1]') + if len(crl_issuing_point) == 1: + crl_issuing_point = etree.tostring( + crl_issuing_point[0], method='text', + encoding=unicode).strip() + response['crl_issuing_point'] = crl_issuing_point + + crl_update = doc.xpath('//xml/header/crlUpdate[1]') + if len(crl_update) == 1: + crl_update = etree.tostring(crl_update[0], method='text', + encoding=unicode).strip() + response['crl_update'] = crl_update + + return response + + #------------------------------------------------------------------------------- from ipalib import Registry, errors, SkipPluginModule @@ -1923,6 +1984,47 @@ class ra(rabase.rabase, RestClient): return results + def updateCRL(self, wait='false'): + """ + Force update of the CRL + + :param wait: if true, the call will be synchronous and return only + when the CRL has been generated + """ + logger.debug('%s.updateCRL()', type(self).__name__) + # Call CMS + http_status, _http_headers, http_body = ( + self._sslget('/ca/agent/ca/updateCRL', + self.override_port or self.env.ca_agent_port, + crlIssuingPoint='MasterCRL', + waitForUpdate=wait, + xml='true') + ) + + # Parse and handle errors + if http_status != 200: + self.raise_certificate_operation_error('updateCRL', + detail=http_status) + + parse_result = self.get_parse_result_xml(http_body, + parse_updateCRL_xml) + request_status = parse_result['request_status'] + if request_status != CMS_STATUS_SUCCESS: + self.raise_certificate_operation_error( + 'updateCRL', + cms_request_status_to_string(request_status), + parse_result.get('error_string')) + + # Return command result + cmd_result = {} + + if 'crl_issuing_point' in parse_result: + cmd_result['crlIssuingPoint'] = parse_result['crl_issuing_point'] + if 'crl_update' in parse_result: + cmd_result['crlUpdate'] = parse_result['crl_update'] + + return cmd_result + # ---------------------------------------------------------------------------- @register() diff --git a/ipaserver/plugins/rabase.py b/ipaserver/plugins/rabase.py index 584dc29..03762e8 100644 --- a/ipaserver/plugins/rabase.py +++ b/ipaserver/plugins/rabase.py @@ -123,3 +123,12 @@ class rabase(Backend): :param options: dictionary of search options """ raise errors.NotImplementedError(name='%s.find' % self.name) + + def updateCRL(self, wait='false'): + """ + Force update of the CRL + + :param wait: if true, the call will be synchronous and return only + when the CRL has been generated + """ + raise errors.NotImplementedError(name='%s.updateCRL' % self.name)