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 <flo@redhat.com>
This commit is contained in:
Rob Crittenden 2023-01-12 15:06:27 -05:00 committed by Florence Blanc-Renaud
parent 5154f8e639
commit 78298fd4e1
3 changed files with 534 additions and 10 deletions

View File

@ -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

View File

@ -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')

View File

@ -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