From 78298fd4e18f45f77691914ef7b406aa08fc7776 Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Thu, 12 Jan 2023 15:06:27 -0500 Subject: [PATCH] ipa-acme-manage: add certificate/request pruning management Configures PKI to remove expired certificates and non-resolved requests on a schedule. This is geared towards ACME which can generate a lot of certificates over a short period of time but is general purpose. It lives in ipa-acme-manage because that is the primary reason for including it. Random Serial Numbers v3 must be enabled for this to work. Enabling pruning enables the job scheduler within CS and sets the job user as the IPA RA user which has full rights to certificates and requests. Disabling pruning does not disable the job scheduler because the tool is stateless. Having the scheduler enabled should not be a problem. A restart of PKI is required to apply any changes. This tool forks out to pki-server which does direct writes to CS.cfg. It might be easier to use our own tooling for this but this makes the integration tighter so we pick up any improvements in PKI. The "cron" setting is quite limited, taking only integer values and *. It does not accept ranges, either - or /. No error checking is done in PKI when setting a value, only when attempting to use it, so some rudimentary validation is done. Fixes: https://pagure.io/freeipa/issue/9294 Signed-off-by: Rob Crittenden rcritten@redhat.com Reviewed-By: Florence Blanc-Renaud --- install/tools/man/ipa-acme-manage.1 | 83 +++++++ ipaserver/install/ipa_acme_manage.py | 303 ++++++++++++++++++++++++- ipatests/test_integration/test_acme.py | 158 +++++++++++++ 3 files changed, 534 insertions(+), 10 deletions(-) diff --git a/install/tools/man/ipa-acme-manage.1 b/install/tools/man/ipa-acme-manage.1 index e15d25bd0..e6cec4e4a 100644 --- a/install/tools/man/ipa-acme-manage.1 +++ b/install/tools/man/ipa-acme-manage.1 @@ -27,6 +27,89 @@ Disable the ACME service on this host. .TP \fBstatus\fR Display the status of the ACME service. +.TP +\fBpruning\fR +Configure certificate and request pruning. + +.SH "PRUNING" +Pruning is a job that runs in the CA that can remove expired +certificates and certificate requests which have not been issued. +This is particularly important when using short-lived certificates +like those issued with the ACME protocol. Pruning requires that +the IPA server be installed with random serial numbers enabled. + +The CA needs to be restarted after modifying the pruning configuration. + +The job is a cron-like task within the CA that is controlled by a +number of options which dictate how long after the certificate or +request is considered no longer valid and removed from the LDAP +database. + +The cron time and date fields are: +.IP +.ta 1.5i +field allowed values +.br +----- -------------- +.br +minute 0-59 +.br +hour 0-23 +.br +day of month 1-31 +.br +month 1-12 +.br +day of week 0-6 (0 is Sunday) +.br +.PP + +The cron syntax is limited to * or specific numbers. Ranges are not supported. + +.TP +\fB\-\-enable\fR +Enable certificate pruning. +.TP +\fB\-\-disable\fR +Disable certificate pruning. +.TP +\fB\-\-cron=CRON\fR +Configure the pruning cron job. The syntax is similar to crontab(5) syntax. +For example, "0 0 1 * *" schedules the job to run at 12:00am on the first +day of each month. +.TP +\fB\-\-certretention=CERTRETENTION\fR +Certificate retention time. The default is 30. +.TP +\fB\-\-certretentionunit=CERTRETENTIONUNIT\fR +Certificate retention units. Valid units are: minute, hour, day, year. +The default is days. +.TP +\fB\-\-certsearchsizelimit=CERTSEARCHSIZELIMIT\fR +LDAP search size limit searching for expired certificates. The default is 1000. This is a client-side limit. There may be additional server-side limitations. +.TP +\fB\-\-certsearchtimelimit=CERTSEARCHTIMELIMIT\fR +LDAP search time limit searching for expired certificates. The default is 0, no limit. This is a client-side limit. There may be additional server-side limitations. +.TP +\fB\-\-requestretention=REQUESTRETENTION\fR +Request retention time. The default is 30. +.TP +\fB\-\-requestretentionunit=REQUESTRETENTIONUNIT\fR +Request retention units. Valid units are: minute, hour, day, year. +The default is days. +.TP +\fB\-\-requestsearchsizelimit=REQUESTSEARCHSIZELIMIT\fR +LDAP search size limit searching for unfulfilled requests. The default is 1000. There may be additional server-side limitations. +.TP +\fB\-\-requestsearchtimelimit=REQUESTSEARCHTIMELIMIT\fR +LDAP search time limit searching for unfulfilled requests. The default is 0, no limit. There may be additional server-side limitations. +.TP +\fB\-\-config\-show\fR +Show the current pruning configuration +.TP +\fB\-\-run\fR +Run the pruning job now. The IPA RA certificate is used to authenticate to the PKI REST backend. + .SH "EXIT STATUS" 0 if the command was successful diff --git a/ipaserver/install/ipa_acme_manage.py b/ipaserver/install/ipa_acme_manage.py index 0474b9f4a..b7b2111d9 100644 --- a/ipaserver/install/ipa_acme_manage.py +++ b/ipaserver/install/ipa_acme_manage.py @@ -2,7 +2,12 @@ # Copyright (C) 2020 FreeIPA Contributors see COPYING for license # + import enum +import pki.util +import logging + +from optparse import OptionGroup # pylint: disable=deprecated-module from ipalib import api, errors, x509 from ipalib import _ @@ -10,10 +15,64 @@ from ipalib.facts import is_ipa_configured from ipaplatform.paths import paths from ipapython.admintool import AdminTool from ipapython import cookie, dogtag +from ipapython.ipautil import run +from ipapython.certdb import NSSDatabase, EXTERNAL_CA_TRUST_FLAGS from ipaserver.install import cainstance +from ipaserver.install.ca import lookup_random_serial_number_version from ipaserver.plugins.dogtag import RestClient +logger = logging.getLogger(__name__) + +default_pruning_options = { + 'certRetentionTime': '30', + 'certRetentionUnit': 'day', + 'certSearchSizeLimit': '1000', + 'certSearchTimeLimit': '0', + 'requestRetentionTime': 'day', + 'requestRetentionUnit': '30', + 'requestSearchSizeLimit': '1000', + 'requestSearchTimeLimit': '0', + 'cron': '' +} + +pruning_labels = { + 'certRetentionTime': 'Certificate Retention Time', + 'certRetentionUnit': 'Certificate Retention Unit', + 'certSearchSizeLimit': 'Certificate Search Size Limit', + 'certSearchTimeLimit': 'Certificate Search Time Limit', + 'requestRetentionTime': 'Request Retention Time', + 'requestRetentionUnit': 'Request Retention Unit', + 'requestSearchSizeLimit': 'Request Search Size Limit', + 'requestSearchTimeLimit': 'Request Search Time Limit', + 'cron': 'cron Schedule' +} + + +def validate_range(val, min, max): + """dogtag appears to have no error checking in the cron + entry so do some minimum amount of validation. It is + left as an exercise for the user to do month/day + validation so requesting Feb 31 will be accepted. + + Only * and a number within a min/max range are allowed. + """ + if val == '*': + return + + if '-' in val or '/' in val: + raise ValueError(f"{val} ranges are not supported") + + try: + int(val) + except ValueError: + # raise a clearer error + raise ValueError(f"{val} is not a valid integer") + + if int(val) < min or int(val) > max: + raise ValueError(f"{val} not within the range {min}-{max}") + + # Manages the FreeIPA ACME service on a per-server basis. # # This program is a stop-gap until the deployment-wide management of @@ -66,32 +125,121 @@ class acme_state(RestClient): status, unused, _unused = self._request('/acme/disable', headers=headers) if status != 200: - raise RuntimeError('Failed to disble ACME') + raise RuntimeError('Failed to disable ACME') class Command(enum.Enum): ENABLE = 'enable' DISABLE = 'disable' STATUS = 'status' + PRUNE = 'pruning' class IPAACMEManage(AdminTool): command_name = "ipa-acme-manage" - usage = "%prog [enable|disable|status]" + usage = "%prog [enable|disable|status|pruning]" description = "Manage the IPA ACME service" + @classmethod + def add_options(cls, parser): + + group = OptionGroup(parser, 'Pruning') + group.add_option( + "--enable", dest="enable", action="store_true", + default=False, help="Enable certificate pruning") + group.add_option( + "--disable", dest="disable", action="store_true", + default=False, help="Disable certificate pruning") + group.add_option( + "--cron", dest="cron", action="store", + default=None, help="Configure the pruning cron job") + group.add_option( + "--certretention", dest="certretention", action="store", + default=None, help="Certificate retention time", type=int) + group.add_option( + "--certretentionunit", dest="certretentionunit", action="store", + choices=['minute', 'hour', 'day', 'year'], + default=None, help="Certificate retention units") + group.add_option( + "--certsearchsizelimit", dest="certsearchsizelimit", + action="store", + default=None, help="LDAP search size limit", type=int) + group.add_option( + "--certsearchtimelimit", dest="certsearchtimelimit", action="store", + default=None, help="LDAP search time limit", type=int) + group.add_option( + "--requestretention", dest="requestretention", action="store", + default=None, help="Request retention time", type=int) + group.add_option( + "--requestretentionunit", dest="requestretentionunit", + choices=['minute', 'hour', 'day', 'year'], + action="store", default=None, help="Request retention units") + group.add_option( + "--requestsearchsizelimit", dest="requestsearchsizelimit", + action="store", + default=None, help="LDAP search size limit", type=int) + group.add_option( + "--requestsearchtimelimit", dest="requestsearchtimelimit", + action="store", + default=None, help="LDAP search time limit", type=int) + group.add_option( + "--config-show", dest="config_show", action="store_true", + default=False, help="Show the current pruning configuration") + group.add_option( + "--run", dest="run", action="store_true", + default=False, help="Run the pruning job now") + parser.add_option_group(group) + super(IPAACMEManage, cls).add_options(parser, debug_option=True) + + def validate_options(self): - # needs root now - if/when this program changes to an API - # wrapper we will no longer need root. super(IPAACMEManage, self).validate_options(needs_root=True) if len(self.args) < 1: self.option_parser.error(f'missing command argument') - else: - try: - self.command = Command(self.args[0]) - except ValueError: - self.option_parser.error(f'unknown command "{self.args[0]}"') + + if self.args[0] == "pruning": + if self.options.enable and self.options.disable: + self.option_parser.error("Cannot both enable and disable") + elif ( + any( + [ + self.options.enable, + self.options.disable, + self.options.cron, + self.options.certretention, + self.options.certretentionunit, + self.options.requestretention, + self.options.requestretentionunit, + self.options.certsearchsizelimit, + self.options.certsearchtimelimit, + self.options.requestsearchsizelimit, + self.options.requestsearchtimelimit, + ] + ) + and (self.options.config_show or self.options.run) + ): + + self.option_parser.error( + "Cannot change and show config or run at the same time" + ) + elif self.options.cron: + if len(self.options.cron.split()) != 5: + self.option_parser.error("Invalid format for --cron") + # dogtag does no validation when setting an option so + # do the minimum. The dogtag cron is limited compared to + # crontab(5). + opt = self.options.cron.split() + validate_range(opt[0], 0, 59) + validate_range(opt[1], 0, 23) + validate_range(opt[2], 1, 31) + validate_range(opt[3], 1, 12) + validate_range(opt[4], 0, 6) + + try: + self.command = Command(self.args[0]) + except ValueError: + self.option_parser.error(f'unknown command "{self.args[0]}"') def check_san_status(self): """ @@ -100,6 +248,140 @@ class IPAACMEManage(AdminTool): cert = x509.load_certificate_from_file(paths.HTTPD_CERT_FILE) cainstance.check_ipa_ca_san(cert) + def pruning(self): + def run_pki_server(command, directive, prefix, value=None): + """Take a set of arguments to append to pki-server""" + args = [ + 'pki-server', command, + f'{prefix}.{directive}' + ] + if value: + args.extend([str(value)]) + logger.debug(args) + result = run(args, raiseonerr=False, capture_output=True, + capture_error=True) + if result.returncode != 0: + raise RuntimeError(result.error_output) + return result + + def ca_config_set(directive, value, + prefix='jobsScheduler.job.pruning'): + run_pki_server('ca-config-set', directive, prefix, value) + # ca-config-set always succeeds, even if the option is + # not supported. + newvalue = ca_config_show(directive) + if str(value) != newvalue.strip(): + raise RuntimeError('Updating %s failed' % directive) + + def ca_config_show(directive): + result = run_pki_server('ca-config-show', directive, + prefix='jobsScheduler.job.pruning') + return result.output.strip() + + def config_show(): + status = ca_config_show('enabled') + if status.strip() == 'true': + print("Status: enabled") + else: + print("Status: disabled") + for option in ( + 'certRetentionTime', 'certRetentionUnit', + 'certSearchSizeLimit', 'certSearchTimeLimit', + 'requestRetentionTime', 'requestRetentionUnit', + 'requestSearchSizeLimit', 'requestSearchTimeLimit', + 'cron', + ): + value = ca_config_show(option) + if value: + print("{}: {}".format(pruning_labels[option], value)) + else: + print("{}: {}".format(pruning_labels[option], + default_pruning_options[option])) + + def run_pruning(): + """Run the pruning job manually""" + + with NSSDatabase() as tmpdb: + print("Preparing...") + tmpdb.create_db() + tmpdb.import_files((paths.RA_AGENT_PEM, paths.RA_AGENT_KEY), + import_keys=True) + tmpdb.import_files((paths.IPA_CA_CRT,)) + for nickname, trust_flags in tmpdb.list_certs(): + if trust_flags.has_key: + ra_nickname = nickname + continue + # external is suffucient for our purposes: C,, + tmpdb.trust_root_cert(nickname, EXTERNAL_CA_TRUST_FLAGS) + print("Starting job...") + args = ['pki', '-C', tmpdb.pwd_file, '-d', tmpdb.secdir, + '-n', ra_nickname, + 'ca-job-start', 'pruning'] + logger.debug(args) + run(args, stdin='y') + + pki_version = pki.util.Version(pki.specification_version()) + if pki_version < pki.util.Version("11.3.0"): + raise RuntimeError( + 'Certificate pruning is not supported in PKI version %s' + % pki_version + ) + + if lookup_random_serial_number_version(api) == 0: + raise RuntimeError( + 'Certificate pruning requires random serial numbers' + ) + + if self.options.config_show: + config_show() + return + + if self.options.run: + run_pruning() + return + + # Don't play the enable/disable at the same time game + if self.options.enable: + ca_config_set('owner', 'ipara') + ca_config_set('enabled', 'true') + ca_config_set('enabled', 'true', 'jobsScheduler') + elif self.options.disable: + ca_config_set('enabled', 'false') + + # pki-server ca-config-set can only set one option at a time so + # loop through all the options and set what is there. + if self.options.certretention: + ca_config_set('certRetentionTime', + self.options.certretention) + if self.options.certretentionunit: + ca_config_set('certRetentionUnit', + self.options.certretentionunit) + if self.options.certsearchtimelimit: + ca_config_set('certSearchTimeLimit', + self.options.certsearchtimelimit) + if self.options.certsearchsizelimit: + ca_config_set('certSearchSizeLimit', + self.options.certsearchsizelimit) + if self.options.requestretention: + ca_config_set('requestRetentionTime', + self.options.requestretention) + if self.options.requestretentionunit: + ca_config_set('requestRetentionUnit', + self.options.requestretentionunit) + if self.options.requestsearchsizelimit: + ca_config_set('requestSearchSizeLimit', + self.options.requestsearchsizelimit) + if self.options.requestsearchtimelimit: + ca_config_set('requestSearchTimeLimit', + self.options.requestsearchtimelimit) + if self.options.cron: + ca_config_set('cron', self.options.cron) + + config_show() + + print("The CA service must be restarted for changes to take effect") + + def run(self): if not is_ipa_configured(): print("IPA is not configured.") @@ -123,7 +405,8 @@ class IPAACMEManage(AdminTool): elif self.command == Command.STATUS: status = "enabled" if dogtag.acme_status() else "disabled" print("ACME is {}".format(status)) - return 0 + elif self.command == Command.PRUNE: + self.pruning() else: raise RuntimeError('programmer error: unhandled enum case') diff --git a/ipatests/test_integration/test_acme.py b/ipatests/test_integration/test_acme.py index 15d7543cf..93e785d8f 100644 --- a/ipatests/test_integration/test_acme.py +++ b/ipatests/test_integration/test_acme.py @@ -12,6 +12,9 @@ from ipalib.constants import IPA_CA_RECORD from ipatests.test_integration.base import IntegrationTest from ipatests.pytest_ipa.integration import tasks from ipatests.test_integration.test_caless import CALessBase, ipa_certs_cleanup +from ipatests.test_integration.test_random_serial_numbers import ( + pki_supports_RSNv3 +) from ipaplatform.osinfo import osinfo from ipaplatform.paths import paths from ipatests.test_integration.test_external_ca import ( @@ -388,6 +391,16 @@ class TestACME(CALessBase): status = check_acme_status(self.replicas[0], 'disabled') assert status == 'disabled' + def test_acme_pruning_no_random_serial(self): + """This ACME install is configured without random serial + numbers. Verify that we can't enable pruning on it.""" + self.master.run_command(['ipa-acme-manage', 'enable']) + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', '--enable'], + raiseonerr=False) + assert result.returncode == 1 + assert "requires random serial numbers" in result.stderr_text + @server_install_teardown def test_third_party_certs(self): """Require ipa-ca SAN on replacement web certificates""" @@ -630,3 +643,148 @@ class TestACMERenew(IntegrationTest): renewed_expiry = cert.not_valid_after assert initial_expiry != renewed_expiry + + +class TestACMEPrune(IntegrationTest): + """Validate that ipa-acme-manage configures dogtag for pruning""" + + random_serial = True + + @classmethod + def install(cls, mh): + if not pki_supports_RSNv3(mh.master): + raise pytest.skip("RNSv3 not supported") + tasks.install_master(cls.master, setup_dns=True, + random_serial=True) + + @classmethod + def uninstall(cls, mh): + if not pki_supports_RSNv3(mh.master): + raise pytest.skip("RSNv3 not supported") + super(TestACMEPrune, cls).uninstall(mh) + + def test_enable_pruning(self): + if (tasks.get_pki_version(self.master) + < tasks.parse_version('11.3.0')): + raise pytest.skip("Certificate pruning is not available") + cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH) + assert "jobsScheduler.job.pruning.enabled=false".encode() in cs_cfg + + self.master.run_command(['ipa-acme-manage', 'pruning', '--enable']) + + cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH) + assert "jobsScheduler.enabled=true".encode() in cs_cfg + assert "jobsScheduler.job.pruning.enabled=true".encode() in cs_cfg + assert "jobsScheduler.job.pruning.owner=ipara".encode() in cs_cfg + + def test_pruning_options(self): + if (tasks.get_pki_version(self.master) + < tasks.parse_version('11.3.0')): + raise pytest.skip("Certificate pruning is not available") + + self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--certretention=60', + '--certretentionunit=minute', + '--certsearchsizelimit=2000', + '--certsearchtimelimit=5',] + ) + cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH) + assert ( + "jobsScheduler.job.pruning.certRetentionTime=60".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.certRetentionUnit=minute".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.certSearchSizeLimit=2000".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.certSearchTimeLimit=5".encode() + in cs_cfg + ) + + self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--requestretention=60', + '--requestretentionunit=minute', + '--requestresearchsizelimit=2000', + '--requestsearchtimelimit=5',] + ) + cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH) + assert ( + "jobsScheduler.job.pruning.requestRetentionTime=60".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.requestRetentionUnit=minute".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.requestSearchSizeLimit=2000".encode() + in cs_cfg + ) + assert ( + "jobsScheduler.job.pruning.requestSearchTimeLimit=5".encode() + in cs_cfg + ) + + self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--cron="0 23 1 * *',] + ) + cs_cfg = self.master.get_file_contents(paths.CA_CS_CFG_PATH) + assert ( + "jobsScheduler.job.pruning.cron=0 23 1 * *".encode() + in cs_cfg + ) + + def test_pruning_negative_options(self): + """Negative option testing for things we directly cover""" + if (tasks.get_pki_version(self.master) + < tasks.parse_version('11.3.0')): + raise pytest.skip("Certificate pruning is not available") + + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--enable', '--disable'], + raiseonerr=False + ) + assert result.returncode == 1 + assert "Cannot both enable and disable" in result.stderr_text + + for cmd in ('--config-show', '--run'): + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', + cmd, '--enable'], + raiseonerr=False + ) + assert result.returncode == 1 + assert "Cannot change and show config" in result.stderr_text + + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--cron="* *"'], + raiseonerr=False + ) + assert result.returncode == 1 + assert "Invalid format format --cron" in result.stderr_text + + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--cron="100 * * * *"'], + raiseonerr=False + ) + assert result.returncode == 1 + assert "100 not within the range 0-59" in result.stderr_text + + result = self.master.run_command( + ['ipa-acme-manage', 'pruning', + '--cron="10 1-5 * * *"'], + raiseonerr=False + ) + assert result.returncode == 1 + assert "1-5 ranges are not supported" in result.stderr_text