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