diff --git a/.gitignore b/.gitignore index ace5d7a0f..9dac99790 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ dist/ # editor temporary files *~ *.swp -\#*# \ No newline at end of file +\#*# + +# auth --cert-path --chain-path +/*.pem \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 167d6ad74..6e29702ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,19 +3,24 @@ language: python # http://docs.travis-ci.com/user/ci-environment/#CI-environment-OS before_install: travis_retry sudo ./bootstrap/ubuntu.sh -install: "travis_retry pip install tox coveralls" -script: "travis_retry tox" - -after_success: '[ "$TOXENV" == "cover" ] && coveralls' - # using separate envs with different TOXENVs creates 4x1 Travis build # matrix, which allows us to clearly distinguish which component under # test has failed env: - - TOXENV=py26 - - TOXENV=py27 - - TOXENV=lint - - TOXENV=cover + global: + - GOPATH=/tmp/go + matrix: + - TOXENV=py26 + - TOXENV=py27 + - TOXENV=lint + - TOXENV=cover + +install: "travis_retry pip install tox coveralls" +before_script: '[ "${TOXENV:0:2}" != "py" ] || ./tests/boulder-start.sh' +# TODO: eliminate substring slice bashism +script: 'travis_retry tox && ([ "${TOXENV:0:2}" != "py" ] || (source .tox/$TOXENV/bin/activate && ./tests/boulder-integration.sh))' + +after_success: '[ "$TOXENV" == "cover" ] && coveralls' notifications: email: false diff --git a/docs/api/augeas_configurator.rst b/docs/api/augeas_configurator.rst deleted file mode 100644 index 402eee797..000000000 --- a/docs/api/augeas_configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.augeas_configurator` --------------------------------------- - -.. automodule:: letsencrypt.augeas_configurator - :members: diff --git a/docs/pkgs/letsencrypt_apache.rst b/docs/pkgs/letsencrypt_apache.rst index 44966cca6..f78caa971 100644 --- a/docs/pkgs/letsencrypt_apache.rst +++ b/docs/pkgs/letsencrypt_apache.rst @@ -27,3 +27,10 @@ .. automodule:: letsencrypt_apache.parser :members: + + +:mod:`letsencrypt_apache.augeas_configurator` +============================================= + +.. automodule:: letsencrypt_apache.augeas_configurator + :members: diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 000000000..abaf425d1 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,3 @@ +# generate-csr.sh: +/key.pem +/csr.der \ No newline at end of file diff --git a/examples/generate-csr.sh b/examples/generate-csr.sh new file mode 100755 index 000000000..617319c3d --- /dev/null +++ b/examples/generate-csr.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# This script generates a simple SAN CSR to be used with Let's Encrypt +# CA. Mostly intedened for "auth --csr" testing, but, since its easily +# auditable, feel free to adjust it and use on you production web +# server. + +if [ "$#" -lt 1 ] +then + echo "Usage: $0 domain [domain...]" >&2 + exit 1 +fi + +domains="DNS:$1" +shift +for x in "$@" +do + domains="$domains,DNS:$x" +done + +SAN="$domains" openssl req -config "${OPENSSL_CNF:-openssl.cnf}" \ + -new -nodes -subj '/' -reqexts san \ + -out "${CSR_PATH:-csr.der}" \ + -keyout key.pem \ + -newkey rsa:2048 \ + -outform DER +# 512 or 1024 too low for Boulder, 2048 is smallest for tests + +echo "You can now run: letsencrypt auth --csr ${CSR_PATH:-csr.der}" diff --git a/examples/openssl.cnf b/examples/openssl.cnf new file mode 100644 index 000000000..a3e6f3895 --- /dev/null +++ b/examples/openssl.cnf @@ -0,0 +1,5 @@ +[ req ] +distinguished_name = req_distinguished_name +[ req_distinguished_name ] +[ san ] +subjectAltName=${ENV::SAN} diff --git a/letsencrypt/auth_handler.py b/letsencrypt/auth_handler.py index d7d590878..43f7b9fd2 100644 --- a/letsencrypt/auth_handler.py +++ b/letsencrypt/auth_handler.py @@ -3,12 +3,15 @@ import itertools import logging import time +import zope.component + from acme import challenges from acme import messages from letsencrypt import achallenges from letsencrypt import constants from letsencrypt import errors +from letsencrypt import interfaces class AuthHandler(object): @@ -193,6 +196,7 @@ class AuthHandler(object): updated for _, updated in failed_achalls) if all_failed_achalls: + _report_failed_challs(all_failed_achalls) raise errors.FailedChallenges(all_failed_achalls) dom_to_check -= comp_domains @@ -480,3 +484,80 @@ def is_preferred(offered_challb, satisfied, different=True): return False return True + + +_ERROR_HELP_COMMON = ( + "To fix these errors, please make sure that your domain name was entered " + "correctly and the DNS A/AAAA record(s) for that domain contains the " + "right IP address.") + + +_ERROR_HELP = { + "connection" : + _ERROR_HELP_COMMON + " Additionally, please check that your computer " + "has publicly routable IP address and no firewalls are preventing the " + "server from communicating with the client.", + "dnssec" : + _ERROR_HELP_COMMON + " Additionally, if you have DNSSEC enabled for " + "your domain, please ensure the signature is valid.", + "malformed" : + "To fix these errors, please make sure that you did not provide any " + "invalid information to the client and try running Let's Encrypt " + "again.", + "serverInternal" : + "Unfortunately, an error on the ACME server prevented you from completing " + "authorization. Please try again later.", + "tls" : + _ERROR_HELP_COMMON + " Additionally, please check that you have an up " + "to date TLS configuration that allows the server to communicate with " + "the Let's Encrypt client.", + "unauthorized" : _ERROR_HELP_COMMON, + "unknownHost" : _ERROR_HELP_COMMON,} + + +def _report_failed_challs(failed_achalls): + """Notifies the user about failed challenges. + + :param set failed_achalls: A set of failed + :class:`letsencrypt.achallenges.AnnotatedChallenge`. + + """ + problems = dict() + for achall in failed_achalls: + if achall.error: + problems.setdefault(achall.error.typ, []).append(achall) + + reporter = zope.component.getUtility(interfaces.IReporter) + for achalls in problems.itervalues(): + reporter.add_message( + _generate_failed_chall_msg(achalls), reporter.MEDIUM_PRIORITY, True) + + +def _generate_failed_chall_msg(failed_achalls): + """Creates a user friendly error message about failed challenges. + + :param list failed_achalls: A list of failed + :class:`letsencrypt.achallenges.AnnotatedChallenge` with the same error + type. + + :returns: A formatted error message for the client. + :rtype: str + + """ + typ = failed_achalls[0].error.typ + msg = [ + "The following '{0}' errors were reported by the server:".format(typ)] + + problems = dict() + for achall in failed_achalls: + problems.setdefault(achall.error.description, set()).add(achall.domain) + for problem in problems: + msg.append("\n\nDomains: ") + msg.append(", ".join(sorted(problems[problem]))) + msg.append("\nError: {0}".format(problem)) + + if typ in _ERROR_HELP: + msg.append("\n\n") + msg.append(_ERROR_HELP[typ]) + + return "".join(msg) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index b2ecd4887..8fab21f44 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -89,16 +89,20 @@ def _account_init(args, config): return None -def _common_run(args, config, acc, authenticator, installer): +def _find_domains(args, installer): if args.domains is None: - doms = display_ops.choose_names(installer) + domains = display_ops.choose_names(installer) else: - doms = args.domains + domains = args.domains - if not doms: + if not domains: sys.exit("Please specify --domains, or --installer that will " "help in domain names autodiscovery") + return domains + + +def _init_acme(config, acc, authenticator, installer): acme = client.Client(config, acc, authenticator, installer) # Validate the key and csr @@ -111,7 +115,7 @@ def _common_run(args, config, acc, authenticator, installer): except errors.LetsEncryptClientError: sys.exit("Unable to register an account with ACME server") - return acme, doms + return acme def run(args, config, plugins): @@ -139,19 +143,26 @@ def run(args, config, plugins): if installer is None or authenticator is None: return "Configurator could not be determined" - acme, doms = _common_run(args, config, acc, authenticator, installer) - # TODO: Handle errors from _common_run? - lineage = acme.obtain_and_enroll_certificate(doms, authenticator, - installer, plugins) + domains = _find_domains(args, installer) + # TODO: Handle errors from _init_acme? + acme = _init_acme(config, acc, authenticator, installer) + lineage = acme.obtain_and_enroll_certificate( + domains, authenticator, installer, plugins) if not lineage: return "Certificate could not be obtained" - acme.deploy_certificate(doms, lineage.privkey, lineage.cert, lineage.chain) - acme.enhance_config(doms, args.redirect) + acme.deploy_certificate(domains, lineage.privkey, lineage.cert, lineage.chain) + acme.enhance_config(domains, args.redirect) def auth(args, config, plugins): """Authenticate & obtain cert, but do not install it.""" # XXX: Update for renewer / RenewableCert + + if args.domains is not None and args.csr is not None: + # TODO: --csr could have a priority, when --domains is + # supplied, check if CSR matches given domains? + return "--domains and --csr are mutually exclusive" + acc = _account_init(args, config) if acc is None: return None @@ -166,13 +177,18 @@ def auth(args, config, plugins): else: installer = None - # TODO: Handle errors from _common_run? - acme, doms = _common_run( - args, config, acc, authenticator=authenticator, installer=installer) - if not acme.obtain_and_enroll_certificate( - doms, authenticator, installer, plugins): - return "Certificate could not be obtained" + # TODO: Handle errors from _init_acme? + acme = _init_acme(config, acc, authenticator, installer) + if args.csr is not None: + certr, chain = acme.obtain_certificate_from_csr(le_util.CSR( + file=args.csr[0], data=args.csr[1], form="der")) + acme.save_certificate(certr, chain, args.cert_path, args.chain_path) + else: + domains = _find_domains(args, installer) + if not acme.obtain_and_enroll_certificate( + domains, authenticator, installer, plugins): + return "Certificate could not be obtained" def install(args, config, plugins): """Install a previously obtained cert in a server.""" @@ -184,11 +200,11 @@ def install(args, config, plugins): installer = display_ops.pick_installer(config, args.installer, plugins) if installer is None: return "Installer could not be determined" - acme, doms = _common_run( - args, config, acc, authenticator=None, installer=installer) + domains = _find_domains(args, installer) + acme = _init_acme(config, acc, authenticator=None, installer=installer) assert args.cert_path is not None - acme.deploy_certificate(doms, acc.key.file, args.cert_path, args.chain_path) - acme.enhance_config(doms, args.redirect) + acme.deploy_certificate(domains, acc.key.file, args.cert_path, args.chain_path) + acme.enhance_config(domains, args.redirect) def revoke(args, unused_config, unused_plugins): @@ -243,10 +259,11 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDiplay rathern than print print str(available) -def read_file(filename): - """Returns the given file's contents with universal new line support. +def read_file(filename, mode="rb"): + """Returns the given file's contents. :param str filename: Filename + :param str mode: open mode (see `open`) :returns: A tuple of filename and its contents :rtype: tuple @@ -255,7 +272,7 @@ def read_file(filename): """ try: - return filename, open(filename, "rU").read() + return filename, open(filename, mode).read() except IOError as exc: raise argparse.ArgumentTypeError(exc.strerror) @@ -466,12 +483,22 @@ def create_parser(plugins, args): return subparser add_subparser("run", run) - add_subparser("auth", auth) + parser_auth = add_subparser("auth", auth) add_subparser("install", install) parser_revoke = add_subparser("revoke", revoke) parser_rollback = add_subparser("rollback", rollback) add_subparser("config_changes", config_changes) + parser_auth.add_argument( + "--csr", type=read_file, help="Path to a Certificate Signing " + "Request (CSR) in DER format.") + parser_auth.add_argument( + "--cert-path", default=flag_default("cert_path"), + help="When using --csr this is where certificate is saved.") + parser_auth.add_argument( + "--chain-path", default=flag_default("chain_path"), + help="When using --csr this is where certificate chain is saved.") + parser_plugins = add_subparser("plugins", plugins_cmd) parser_plugins.add_argument("--init", action="store_true") parser_plugins.add_argument("--prepare", action="store_true") @@ -539,10 +566,6 @@ def _paths_parser(helpful): help=config_help("cert_dir")) add("paths", "--le-vhost-ext", default="-le-ssl.conf", help=config_help("le_vhost_ext")) - add("paths", "--cert-path", default=flag_default("cert_path"), - help=config_help("cert_path")) - add("paths", "--chain-path", default=flag_default("chain_path"), - help=config_help("chain_path")) add("paths", "--renewer-config-file", default=flag_default("renewer_config_file"), help=config_help("renewer_config_file")) add("paths", "-s", "--server", default=flag_default("server"), diff --git a/letsencrypt/client.py b/letsencrypt/client.py index b0cabbb6c..5c54835f8 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -4,6 +4,7 @@ import os import pkg_resources import M2Crypto +import OpenSSL.crypto import zope.component from acme import jose @@ -11,6 +12,8 @@ from acme.jose import jwk from letsencrypt import account from letsencrypt import auth_handler +from letsencrypt import configuration +from letsencrypt import constants from letsencrypt import continuity_auth from letsencrypt import crypto_util from letsencrypt import errors @@ -123,22 +126,20 @@ class Client(object): "{0}.".format(self.account.email)) reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True) - def obtain_certificate(self, domains, csr=None): - """Obtains a certificate from the ACME server. + def _obtain_certificate(self, domains, csr): + """Obtain certificate. - :meth:`.register` must be called before :meth:`.obtain_certificate` + Internal function with precondition that `domains` are + consistent with identifiers present in the `csr`. - .. todo:: This function does not currently handle CSR correctly. + :param list domains: Domain names. + :param .le_util.CSR csr: DER-encoded Certificate Signing + Request. The key used to generate this CSR can be different + than `authkey`. - :param set domains: domains to get a certificate - - :param csr: CSR must contain requested domains, the key used to generate - this CSR can be different than self.authkey - :type csr: :class:`CSR` - - :returns: Certificate, private key, and certificate chain (all - PEM-encoded). - :rtype: `tuple` of `str` + :returns: `.CertificateResource` and certificate chain (as + returned by `.fetch_chain`). + :rtype: tuple """ if self.auth_handler is None: @@ -149,37 +150,54 @@ class Client(object): if self.account.regr is None: raise errors.Error("Please register with the ACME server first.") - # Perform Challenges/Get Authorizations + logging.debug("CSR: %s, domains: %s", csr, domains) + authzr = self.auth_handler.get_authorizations(domains) - - # Create CSR from names - cert_key = crypto_util.init_save_key( - self.config.rsa_key_size, self.config.key_dir) - csr = crypto_util.init_save_csr( - cert_key, domains, self.config.cert_dir) - - # Retrieve certificate certr = self.network.request_issuance( jose.ComparableX509( M2Crypto.X509.load_request_der_string(csr.data)), authzr) + return certr, self.network.fetch_chain(certr) - cert_pem = certr.body.as_pem() - chain_pem = None - if certr.cert_chain_uri is not None: - chain_pem = self.network.fetch_chain(certr) + def obtain_certificate_from_csr(self, csr): + """Obtain certficiate from CSR. - if chain_pem is None: - # XXX: just to stop RenewableCert from complaining; this is - # probably not a good solution - chain_pem = "" - else: - chain_pem = chain_pem.as_pem() + :param .le_util.CSR csr: DER-encoded Certificate Signing + Request. - return cert_pem, cert_key.pem, chain_pem + :returns: `.CertificateResource` and certificate chain (as + returned by `.fetch_chain`). + :rtype: tuple + + """ + return self._obtain_certificate( + # TODO: add CN to domains? + crypto_util.get_sans_from_csr( + csr.data, OpenSSL.crypto.FILETYPE_ASN1), csr) + + def obtain_certificate(self, domains): + """Obtains a certificate from the ACME server. + + `.register` must be called before `.obtain_certificate` + + :param set domains: domains to get a certificate + + :returns: `.CertificateResource`, certificate chain (as + returned by `.fetch_chain`), and newly generated private key + (`.le_util.Key`) and DER-encoded Certificate Signing Request + (`.le_util.CSR`). + :rtype: tuple + + """ + # Create CSR from names + key = crypto_util.init_save_key( + self.config.rsa_key_size, self.config.key_dir) + csr = crypto_util.init_save_csr(key, domains, self.config.cert_dir) + + return self._obtain_certificate(domains, csr) + (key, csr) def obtain_and_enroll_certificate( - self, domains, authenticator, installer, plugins, csr=None): + self, domains, authenticator, installer, plugins): """Obtain and enroll certificate. Get a new certificate for the specified domains using the specified @@ -195,14 +213,14 @@ class Client(object): :param plugins: A PluginsFactory object. - :param str csr: A preexisting CSR to use with this request. - :returns: A new :class:`letsencrypt.storage.RenewableCert` instance referred to the enrolled cert lineage, or False if the cert could not be obtained. """ - cert, privkey, chain = self.obtain_certificate(domains, csr) + certr, chain, key, _ = self.obtain_certificate(domains) + + # TODO: remove this dirty hack self.config.namespace.authenticator = plugins.find_init( authenticator).name if installer is not None: @@ -215,19 +233,29 @@ class Client(object): # ideally should be a ConfigObj, but in this case a dict will be # accepted in practice.) params = vars(self.config.namespace) - config = {"renewer_config_file": - params["renewer_config_file"]} if "renewer_config_file" in params else None - renewable_cert = storage.RenewableCert.new_lineage(domains[0], cert, privkey, - chain, params, config) - self._report_renewal_status(renewable_cert) - return renewable_cert + config = {} + cli_config = configuration.RenewerConfiguration(self.config.namespace) + + if (cli_config.config_dir != constants.CLI_DEFAULTS["config_dir"] or + cli_config.work_dir != constants.CLI_DEFAULTS["work_dir"]): + logging.warning( + "Non-standard path(s), might not work with crontab installed " + "by your operating system package manager") + + # XXX: just to stop RenewableCert from complaining; this is + # probably not a good solution + chain_pem = "" if chain is None else chain.as_pem() + lineage = storage.RenewableCert.new_lineage( + domains[0], certr.body.as_pem(), key.pem, chain_pem, params, + config, cli_config) + self._report_renewal_status(lineage) + return lineage def _report_renewal_status(self, cert): # pylint: disable=no-self-use """Informs the user about automatic renewal and deployment. - :param cert: Newly issued certificate - :type cert: :class:`letsencrypt.storage.RenewableCert` + :param .RenewableCert cert: Newly issued certificate """ if ("autorenew" not in cert.configuration @@ -246,17 +274,18 @@ class Client(object): msg += ("been enabled for your certificate. These settings can be " "configured in the directories under {0}.").format( - cert.configuration["renewal_configs_dir"]) + cert.cli_config.renewal_configs_dir) reporter = zope.component.getUtility(interfaces.IReporter) reporter.add_message(msg, reporter.LOW_PRIORITY, True) - def save_certificate(self, certr, cert_path, chain_path): + def save_certificate(self, certr, chain_cert, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. :param certr: ACME "certificate" resource. :type certr: :class:`acme.messages.Certificate` + :param chain_cert: :param str cert_path: Candidate path to a certificate. :param str chain_path: Candidate path to a certificate chain. @@ -266,6 +295,10 @@ class Client(object): :raises IOError: If unable to find room to write the cert files """ + for path in cert_path, chain_path: + le_util.make_or_verify_dir( + os.path.dirname(path), 0o755, os.geteuid()) + # try finally close cert_chain_abspath = None cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) @@ -278,22 +311,20 @@ class Client(object): logging.info("Server issued certificate; certificate written to %s", act_cert_path) - if certr.cert_chain_uri is not None: + if chain_cert is not None: + chain_file, act_chain_path = le_util.unique_file( + chain_path, 0o644) # TODO: Except - chain_cert = self.network.fetch_chain(certr) - if chain_cert is not None: - chain_file, act_chain_path = le_util.unique_file( - chain_path, 0o644) - chain_pem = chain_cert.as_pem() - try: - chain_file.write(chain_pem) - finally: - chain_file.close() + chain_pem = chain_cert.as_pem() + try: + chain_file.write(chain_pem) + finally: + chain_file.close() - logging.info("Cert chain written to %s", act_chain_path) + logging.info("Cert chain written to %s", act_chain_path) - # This expects a valid chain file - cert_chain_abspath = os.path.abspath(act_chain_path) + # This expects a valid chain file + cert_chain_abspath = os.path.abspath(act_chain_path) return os.path.abspath(act_cert_path), cert_chain_abspath @@ -382,8 +413,7 @@ def validate_key_csr(privkey, csr=None): :param privkey: Key associated with CSR :type privkey: :class:`letsencrypt.le_util.Key` - :param csr: CSR - :type csr: :class:`letsencrypt.le_util.CSR` + :param .le_util.CSR csr: CSR :raises .errors.Error: when validation fails diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index d6b29bd73..2a9e87ade 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -59,15 +59,15 @@ class NamespaceConfig(object): def backup_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.BACKUP_DIR) + @property + def cert_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.CERT_DIR) + @property def cert_key_backup(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, self.server_path) - @property - def cert_dir(self): # pylint: disable=missing-docstring - return os.path.join(self.namespace.config_dir, constants.CERT_DIR) - @property def in_progress_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) @@ -81,12 +81,35 @@ class NamespaceConfig(object): def rec_token_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR) - @property - def renewer_config_file(self): # pylint: disable=missing-docstring - return os.path.join( - self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME) - @property def temp_checkpoint_dir(self): # pylint: disable=missing-docstring return os.path.join( self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR) + + +class RenewerConfiguration(object): + """Configuration wrapper for renewer.""" + + def __init__(self, namespace): + self.namespace = namespace + + def __getattr__(self, name): + return getattr(self.namespace, name) + + @property + def archive_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.ARCHIVE_DIR) + + @property + def live_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.config_dir, constants.LIVE_DIR) + + @property + def renewal_configs_dir(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.RENEWAL_CONFIGS_DIR) + + @property + def renewer_config_file(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index df41fbe5b..81ec4a90b 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -17,23 +17,19 @@ CLI_DEFAULTS = dict( work_dir="/var/lib/letsencrypt", no_verify_ssl=False, dvsni_port=challenges.DVSNI.PORT, + cert_path="./cert.pem", + chain_path="./chain.pem", # TODO: blocked by #485, values ignored backup_dir="not used", key_dir="not used", certs_dir="not used", - cert_path="not used", - chain_path="not used", renewer_config_file="not used", ) """Defaults for CLI flags and `.IConfig` attributes.""" RENEWER_DEFAULTS = dict( - renewer_config_file="/etc/letsencrypt/renewer.conf", - renewal_configs_dir="/etc/letsencrypt/configs", - archive_dir="/etc/letsencrypt/archive", - live_dir="/etc/letsencrypt/live", renewer_enabled="yes", renew_before_expiry="30 days", deploy_before_expiry="20 days", @@ -58,6 +54,8 @@ List of expected options parameters: """ +ARCHIVE_DIR = "archive" +"""Archive directory, relative to `IConfig.config_dir`.""" CONFIG_DIRS_MODE = 0o755 """Directory mode for ``.IConfig.config_dir`` et al.""" @@ -71,13 +69,13 @@ ACCOUNT_KEYS_DIR = "keys" BACKUP_DIR = "backups" """Directory (relative to `IConfig.work_dir`) where backups are kept.""" +CERT_DIR = "certs" +"""See `.IConfig.cert_dir`.""" + CERT_KEY_BACKUP_DIR = "keys-certs" """Directory where all certificates and keys are stored (relative to `IConfig.work_dir`). Used for easy revocation.""" -CERT_DIR = "certs" -"""See `.IConfig.cert_dir`.""" - IN_PROGRESS_DIR = "IN_PROGRESS" """Directory used before a permanent checkpoint is finalized (relative to `IConfig.work_dir`).""" @@ -85,6 +83,9 @@ IN_PROGRESS_DIR = "IN_PROGRESS" KEY_DIR = "keys" """Directory (relative to `IConfig.config_dir`) where keys are saved.""" +LIVE_DIR = "live" +"""Live directory, relative to `IConfig.config_dir`.""" + TEMP_CHECKPOINT_DIR = "temp_checkpoint" """Temporary checkpoint directory (relative to `IConfig.work_dir`).""" @@ -92,6 +93,8 @@ REC_TOKEN_DIR = "recovery_tokens" """Directory where all recovery tokens are saved (relative to `IConfig.work_dir`).""" +RENEWAL_CONFIGS_DIR = "configs" +"""Renewal configs directory, relative to `IConfig.config_dir`.""" RENEWER_CONFIG_FILENAME = "renewer.conf" """Renewer config file name (relative to `IConfig.config_dir`).""" diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 31df697c0..943fd27eb 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -90,6 +90,9 @@ def make_csr(key_str, domains): :param str key_str: RSA key. :param list domains: Domains included in the certificate. + .. todo:: Detect duplicates in `domains`? Using a set doesn't + preserve order... + :returns: new CSR in PEM and DER form containing all domains :rtype: tuple @@ -101,13 +104,7 @@ def make_csr(key_str, domains): csr = M2Crypto.X509.Request() csr.set_pubkey(pubkey) - name = csr.get_subject() - name.C = "US" - name.ST = "Michigan" - name.L = "Ann Arbor" - name.O = "EFF" - name.OU = "University of Michigan" - name.CN = domains[0] + # TODO: what to put into csr.get_subject()? extstack = M2Crypto.X509.X509_Extension_Stack() ext = M2Crypto.X509.new_extension( diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index fa77e3566..1a1887b9a 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -205,8 +205,22 @@ def success_installation(domains): """ util(interfaces.IDisplay).notification( - "Congratulations! You have successfully enabled " - "%s!" % _gen_https_names(domains), pause=False) + "Congratulations! You have successfully enabled {0}!{1}{1}" + "You should test your configuration at:{1}{2}".format( + _gen_https_names(domains), + os.linesep, + os.linesep.join(_gen_ssl_lab_urls(domains))), + height=(10 + len(domains)), + pause=False) + + +def _gen_ssl_lab_urls(domains): + """Returns a list of urls. + + :param list domains: Each domain is a 'str' + + """ + return ["https://www.ssllabs.com/ssltest/analyze.html?d=%s" % dom for dom in domains] def _gen_https_names(domains): diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 3c71fc1fa..ce12c4a56 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -190,8 +190,6 @@ class IConfig(zope.interface.Interface): # TODO: the following are not used, but blocked by #485 le_vhost_ext = zope.interface.Attribute("not used") - cert_path = zope.interface.Attribute("not used") - chain_path = zope.interface.Attribute("not used") class IInstaller(IPlugin): diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 6e61fd893..d2c0b8e7d 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -7,15 +7,21 @@ within lineages of successor certificates, according to configuration. .. todo:: Call new installer API to restart servers after deployment """ +import argparse import os +import sys import configobj +import zope.component from letsencrypt import configuration +from letsencrypt import cli from letsencrypt import client from letsencrypt import crypto_util from letsencrypt import notify from letsencrypt import storage + +from letsencrypt.display import util as display_util from letsencrypt.plugins import disco as plugins_disco @@ -60,6 +66,7 @@ def renew(cert, old_version): # XXX: this loses type data (for example, the fact that key_size # was an int, not a str) config.rsa_key_size = int(config.rsa_key_size) + config.dvsni_port = int(config.dvsni_port) try: authenticator = plugins[renewalparams["authenticator"]] except KeyError: @@ -76,14 +83,15 @@ def renew(cert, old_version): our_client = client.Client(config, account, authenticator, None) with open(cert.version("cert", old_version)) as f: sans = crypto_util.get_sans_from_cert(f.read()) - new_cert, new_key, new_chain = our_client.obtain_certificate(sans) - if new_cert and new_key and new_chain: + new_certr, new_chain, new_key, _ = our_client.obtain_certificate(sans) + if new_chain is not None: # XXX: Assumes that there was no key change. We need logic # for figuring out whether there was or not. Probably # best is to have obtain_certificate return None for # new_key if the old key is to be used (since save_successor # already understands this distinction!) - return cert.save_successor(old_version, new_cert, new_key, new_chain) + return cert.save_successor(old_version, new_certr.body.as_pem(), + new_key.pem, new_chain.as_pem()) # TODO: Notify results else: # TODO: Notify negative results @@ -92,7 +100,23 @@ def renew(cert, old_version): # (where fewer than all names were renewed) -def main(config=None): +def _paths_parser(parser): + add = parser.add_argument_group("paths").add_argument + add("--config-dir", default=cli.flag_default("config_dir"), + help=cli.config_help("config_dir")) + add("--work-dir", default=cli.flag_default("work_dir"), + help=cli.config_help("work_dir")) + return parser + + +def _create_parser(): + parser = argparse.ArgumentParser() + #parser.add_argument("--cron", action="store_true", help="Run as cronjob.") + # pylint: disable=protected-access + return _paths_parser(parser) + + +def main(config=None, args=sys.argv[1:]): """Main function for autorenewer script.""" # TODO: Distinguish automated invocation from manual invocation, # perhaps by looking at sys.argv[0] and inhibiting automated @@ -100,6 +124,11 @@ def main(config=None): # turned it off. (The boolean parameter should probably be # called renewer_enabled.) + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + cli_config = configuration.RenewerConfiguration( + _create_parser().parse_args(args)) + config = storage.config_with_defaults(config) # Now attempt to read the renewer config file and augment or replace # the renewer defaults with any options contained in that file. If @@ -108,14 +137,17 @@ def main(config=None): # elaborate renewer command line, we will presumably also be able to # specify a config file on the command line, which, if provided, should # take precedence over this one. - config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + config.merge(configobj.ConfigObj(cli_config.renewer_config_file)) - for i in os.listdir(config["renewal_configs_dir"]): + for i in os.listdir(cli_config.renewal_configs_dir): print "Processing", i if not i.endswith(".conf"): continue - rc_config = configobj.ConfigObj( - os.path.join(config["renewal_configs_dir"], i)) + rc_config = configobj.ConfigObj(cli_config.renewer_config_file) + rc_config.merge(configobj.ConfigObj( + os.path.join(cli_config.renewal_configs_dir, i))) + # TODO: this is a dirty hack! + rc_config.filename = os.path.join(cli_config.renewal_configs_dir, i) try: # TODO: Before trying to initialize the RenewableCert object, # we could check here whether the combination of the config @@ -125,7 +157,7 @@ def main(config=None): # RenewableCert object for this cert at all, which could # dramatically improve performance for large deployments # where autorenewal is widely turned off. - cert = storage.RenewableCert(rc_config) + cert = storage.RenewableCert(rc_config, cli_config=cli_config) except ValueError: # This indicates an invalid renewal configuration file, such # as one missing a required parameter (in the future, perhaps diff --git a/letsencrypt/reporter.py b/letsencrypt/reporter.py index 045c1befa..dc3859535 100644 --- a/letsencrypt/reporter.py +++ b/letsencrypt/reporter.py @@ -66,7 +66,8 @@ class Reporter(object): If there is an unhandled exception, only messages for which ``on_crash`` is ``True`` are printed. -""" + + """ bold_on = False if not self.messages.empty(): no_exception = sys.exc_info()[0] is None @@ -74,14 +75,21 @@ class Reporter(object): if bold_on: print self._BOLD print 'IMPORTANT NOTES:' - wrapper = textwrap.TextWrapper(initial_indent=' - ', - subsequent_indent=(' ' * 3)) + first_wrapper = textwrap.TextWrapper( + initial_indent=' - ', subsequent_indent=(' ' * 3)) + next_wrapper = textwrap.TextWrapper( + initial_indent=first_wrapper.subsequent_indent, + subsequent_indent=first_wrapper.subsequent_indent) while not self.messages.empty(): msg = self.messages.get() if no_exception or msg.on_crash: if bold_on and msg.priority > self.HIGH_PRIORITY: sys.stdout.write(self._RESET) bold_on = False - print wrapper.fill(msg.text) + lines = msg.text.splitlines() + print first_wrapper.fill(lines[0]) + if len(lines) > 1: + print "\n".join( + next_wrapper.fill(line) for line in lines[1:]) if bold_on: sys.stdout.write(self._RESET) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 2648be3ba..4ad1216e6 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -78,14 +78,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes renewal configuration file and/or systemwide defaults. """ - def __init__(self, configfile, config_opts=None): + def __init__(self, configfile, config_opts=None, cli_config=None): """Instantiate a RenewableCert object from an existing lineage. :param configobj.ConfigObj configfile: an already-parsed - ConfigObj object made from reading the renewal config file - that defines this lineage. :param configobj.ConfigObj - config_opts: systemwide defaults for renewal properties not - otherwise specified in the individual renewal config file. + ConfigObj object made from reading the renewal config file + that defines this lineage. + + :param configobj.ConfigObj config_opts: systemwide defaults for + renewal properties not otherwise specified in the individual + renewal config file. + :param .RenewerConfiguration cli_config: :raises ValueError: if the configuration file's name didn't end in ".conf", or the file is missing or broken. @@ -93,6 +96,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes ConfigObj object. """ + self.cli_config = cli_config if isinstance(configfile, configobj.ConfigObj): if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") @@ -149,7 +153,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Each element's link must point within the cert lineage's # directory within the official archive directory desired_directory = os.path.join( - self.configuration["archive_dir"], self.lineagename) + self.cli_config.archive_dir, self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): return False @@ -499,7 +503,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=None): + renewalparams=None, config=None, cli_config=None): # pylint: disable=too-many-locals,too-many-arguments """Create a new certificate lineage. @@ -536,17 +540,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # the renewer defaults with any options contained in that file. If # renewer_config_file is undefined or if the file is nonexistent or # empty, this .merge() will have no effect. - config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + config.merge(configobj.ConfigObj(cli_config.renewer_config_file)) # Examine the configuration and find the new lineage's name - configs_dir = config["renewal_configs_dir"] - archive_dir = config["archive_dir"] - live_dir = config["live_dir"] - for i in (configs_dir, archive_dir, live_dir): + for i in (cli_config.renewal_configs_dir, cli_config.archive_dir, + cli_config.live_dir): if not os.path.exists(i): os.makedirs(i, 0700) - config_file, config_filename = le_util.unique_lineage_name(configs_dir, - lineagename) + config_file, config_filename = le_util.unique_lineage_name( + cli_config.renewal_configs_dir, lineagename) if not config_filename.endswith(".conf"): raise ValueError("renewal config file name must end in .conf") @@ -554,8 +556,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # lineagename will now potentially be modified based on which # renewal configuration file could actually be created lineagename = os.path.basename(config_filename)[:-len(".conf")] - archive = os.path.join(archive_dir, lineagename) - live_dir = os.path.join(live_dir, lineagename) + archive = os.path.join(cli_config.archive_dir, lineagename) + live_dir = os.path.join(cli_config.live_dir, lineagename) if os.path.exists(archive): raise ValueError("archive directory exists for " + lineagename) if os.path.exists(live_dir): @@ -593,7 +595,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # TODO: add human-readable comments explaining other available # parameters new_config.write() - return cls(new_config, config) + return cls(new_config, config, cli_config) def save_successor(self, prior_version, new_cert, new_privkey, new_chain): @@ -624,7 +626,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Figure out what the new version is and hence where to save things target_version = self.next_free_version() - archive = self.configuration["archive_dir"] + archive = self.cli_config.archive_dir prefix = os.path.join(archive, self.lineagename) target = dict( [(kind, diff --git a/letsencrypt/tests/auth_handler_test.py b/letsencrypt/tests/auth_handler_test.py index 24bceb5f8..6a94baea7 100644 --- a/letsencrypt/tests/auth_handler_test.py +++ b/letsencrypt/tests/auth_handler_test.py @@ -216,7 +216,8 @@ class PollChallengesTest(unittest.TestCase): self.assertEqual(authzr.body.status, messages.STATUS_PENDING) @mock.patch("letsencrypt.auth_handler.time") - def test_poll_challenges_failure(self, unused_mock_time): + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_poll_challenges_failure(self, unused_mock_time, unused_mock_zope): self.mock_net.poll.side_effect = self._mock_poll_solve_one_invalid self.assertRaises( errors.AuthorizationError, self.handler._poll_challenges, @@ -420,6 +421,54 @@ class IsPreferredTest(unittest.TestCase): self._call(acme_util.DVSNI_P, frozenset([acme_util.DVSNI_P]))) +class ReportFailedChallsTest(unittest.TestCase): + """Tests for letsencrypt.auth_handler._report_failed_challs.""" + # pylint: disable=protected-access + + def setUp(self): + from letsencrypt import achallenges + + kwargs = { + "chall" : acme_util.SIMPLE_HTTP, + "uri": "uri", + "status": messages.STATUS_INVALID, + "error": messages.Error(typ="tls", detail="detail"), + } + + self.simple_http = achallenges.SimpleHTTP( + challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + domain="example.com", + key=acme_util.KEY) + + kwargs["chall"] = acme_util.DVSNI + self.dvsni_same = achallenges.DVSNI( + challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + domain="example.com", + key=acme_util.KEY) + + kwargs["error"] = messages.Error(typ="dnssec", detail="detail") + self.dvsni_diff = achallenges.DVSNI( + challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args + domain="foo.bar", + key=acme_util.KEY) + + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_same_error_and_domain(self, mock_zope): + from letsencrypt import auth_handler + + auth_handler._report_failed_challs([self.simple_http, self.dvsni_same]) + call_list = mock_zope().add_message.call_args_list + self.assertTrue(len(call_list) == 1) + self.assertTrue("Domains: example.com\n" in call_list[0][0][0]) + + @mock.patch("letsencrypt.auth_handler.zope.component.getUtility") + def test_different_errors_and_domains(self, mock_zope): + from letsencrypt import auth_handler + + auth_handler._report_failed_challs([self.simple_http, self.dvsni_diff]) + self.assertTrue(mock_zope().add_message.call_count == 2) + + def gen_auth_resp(chall_list): """Generate a dummy authorization response.""" return ["%s%s" % (chall.__class__.__name__, chall.domain) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 79e2597ea..9b7634e20 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -6,8 +6,11 @@ import shutil import tempfile import configobj +import M2Crypto.X509 import mock +from acme import jose + from letsencrypt import account from letsencrypt import configuration from letsencrypt import le_util @@ -15,31 +18,74 @@ from letsencrypt import le_util KEY = pkg_resources.resource_string( __name__, os.path.join("testdata", "rsa512_key.pem")) +CSR_SAN = pkg_resources.resource_string( + __name__, os.path.join("testdata", "csr-san.der")) class ClientTest(unittest.TestCase): """Tests for letsencrypt.client.Client.""" def setUp(self): - self.config = mock.MagicMock(no_verify_ssl=False) + self.config = mock.MagicMock( + no_verify_ssl=False, config_dir="/etc/letsencrypt") # pylint: disable=star-args self.account = mock.MagicMock(**{"key.pem": KEY}) from letsencrypt.client import Client - with mock.patch("letsencrypt.client.network") as network: + with mock.patch("letsencrypt.client.network.Network") as network: self.client = Client( - config=self.config, account_=self.account, dv_auth=None, - installer=None) + config=self.config, account_=self.account, + dv_auth=None, installer=None) self.network = network def test_init_network_verify_ssl(self): - self.network.Network.assert_called_once_with( + self.network.assert_called_once_with( mock.ANY, mock.ANY, verify_ssl=True) + def _mock_obtain_certificate(self): + self.client.auth_handler = mock.MagicMock() + self.network().request_issuance.return_value = mock.sentinel.certr + self.network().fetch_chain.return_value = mock.sentinel.chain + + def _check_obtain_certificate(self): + self.client.auth_handler.get_authorizations.assert_called_once_with( + ["example.com", "www.example.com"]) + self.network.request_issuance.assert_callend_once_with( + jose.ComparableX509( + M2Crypto.X509.load_request_der_string(CSR_SAN)), + self.client.auth_handler.get_authorizations()) + self.network().fetch_chain.assert_called_once_with(mock.sentinel.certr) + + def test_obtain_certificate_from_csr(self): + self._mock_obtain_certificate() + self.assertEqual( + (mock.sentinel.certr, mock.sentinel.chain), + self.client.obtain_certificate_from_csr(le_util.CSR( + form="der", file=None, data=CSR_SAN))) + self._check_obtain_certificate() + + @mock.patch("letsencrypt.client.crypto_util") + def test_obtain_certificate(self, mock_crypto_util): + self._mock_obtain_certificate() + + csr = le_util.CSR(form="der", file=None, data=CSR_SAN) + mock_crypto_util.init_save_csr.return_value = csr + mock_crypto_util.init_save_key.return_value = mock.sentinel.key + domains = ["example.com", "www.example.com"] + + self.assertEqual( + self.client.obtain_certificate(domains), + (mock.sentinel.certr, mock.sentinel.chain, mock.sentinel.key, csr)) + + mock_crypto_util.init_save_key.assert_called_once_with( + self.config.rsa_key_size, self.config.key_dir) + mock_crypto_util.init_save_csr.assert_called_once_with( + mock.sentinel.key, domains, self.config.cert_dir) + self._check_obtain_certificate() + @mock.patch("letsencrypt.client.zope.component.getUtility") def test_report_new_account(self, mock_zope): # pylint: disable=protected-access - self.config.config_dir = "/usr/bin/coffee" self.account.recovery_token = "ECCENTRIC INVISIBILITY RHINOCEROS" self.account.email = "rhino@jungle.io" @@ -54,32 +100,33 @@ class ClientTest(unittest.TestCase): # pylint: disable=protected-access cert = mock.MagicMock() cert.configuration = configobj.ConfigObj() - cert.configuration["renewal_configs_dir"] = "/etc/letsencrypt/configs" + cert.cli_config = configuration.RenewerConfiguration(self.config) cert.configuration["autorenew"] = "True" cert.configuration["autodeploy"] = "True" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has been" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autorenew"] = "False" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("deployment but not automatic renewal" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autodeploy"] = "False" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal and deployment has not" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) cert.configuration["autorenew"] = "True" self.client._report_renewal_status(cert) msg = mock_zope().add_message.call_args[0][0] self.assertTrue("renewal but not automatic deployment" in msg) - self.assertTrue(cert.configuration["renewal_configs_dir"] in msg) + self.assertTrue(cert.cli_config.renewal_configs_dir in msg) + class DetermineAccountTest(unittest.TestCase): """Tests for letsencrypt.client.determine_authenticator.""" diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index d5e9296dd..faf7021be 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -9,10 +9,10 @@ class NamespaceConfigTest(unittest.TestCase): """Tests for letsencrypt.configuration.NamespaceConfig.""" def setUp(self): - from letsencrypt.configuration import NamespaceConfig self.namespace = mock.MagicMock( config_dir='/tmp/config', work_dir='/tmp/foo', foo='bar', server='https://acme-server.org:443/new') + from letsencrypt.configuration import NamespaceConfig self.config = NamespaceConfig(self.namespace) def test_proxy_getattr(self): @@ -38,7 +38,6 @@ class NamespaceConfigTest(unittest.TestCase): constants.IN_PROGRESS_DIR = '../p' constants.KEY_DIR = 'keys' constants.REC_TOKEN_DIR = '/r' - constants.RENEWER_CONFIG_FILENAME = 'r.conf' constants.TEMP_CHECKPOINT_DIR = 't' self.assertEqual( @@ -53,9 +52,30 @@ class NamespaceConfigTest(unittest.TestCase): self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual(self.config.key_dir, '/tmp/config/keys') self.assertEqual(self.config.rec_token_dir, '/r') - self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') +class RenewerConfigurationTest(unittest.TestCase): + """Test for letsencrypt.configuration.RenewerConfiguration.""" + + def setUp(self): + self.namespace = mock.MagicMock(config_dir='/tmp/config') + from letsencrypt.configuration import RenewerConfiguration + self.config = RenewerConfiguration(self.namespace) + + @mock.patch('letsencrypt.configuration.constants') + def test_dynamic_dirs(self, constants): + constants.ARCHIVE_DIR = "a" + constants.LIVE_DIR = 'l' + constants.RENEWAL_CONFIGS_DIR = "renewal_configs" + constants.RENEWER_CONFIG_FILENAME = 'r.conf' + + self.assertEqual(self.config.archive_dir, '/tmp/config/a') + self.assertEqual(self.config.live_dir, '/tmp/config/l') + self.assertEqual( + self.config.renewal_configs_dir, '/tmp/config/renewal_configs') + self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf') + + if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index 01daf0004..25be6bebc 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -182,6 +182,24 @@ class ChooseAccountTest(unittest.TestCase): self.assertTrue(self._call([self.acc1, self.acc2]) is None) +class GenSSLLabURLs(unittest.TestCase): + """Loose test of _gen_ssl_lab_urls. URL can change easily in the future.""" + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + @classmethod + def _call(cls, domains): + from letsencrypt.display.ops import _gen_ssl_lab_urls + return _gen_ssl_lab_urls(domains) + + def test_zero(self): + self.assertEqual(self._call([]), []) + + def test_two(self): + urls = self._call(["eff.org", "umich.edu"]) + self.assertTrue("eff.org" in urls[0]) + self.assertTrue("umich.edu" in urls[1]) + class GenHttpsNamesTest(unittest.TestCase): """Test _gen_https_names.""" def setUp(self): diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 0f85674d4..1ba58a7c8 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -10,6 +10,7 @@ import configobj import mock import pytz +from letsencrypt import configuration from letsencrypt.storage import ALL_FOUR @@ -31,22 +32,24 @@ class RenewableCertTests(unittest.TestCase): def setUp(self): from letsencrypt import storage self.tempdir = tempfile.mkdtemp() + + self.cli_config = configuration.RenewerConfiguration( + namespace=mock.MagicMock(config_dir=self.tempdir)) + # TODO: maybe provide RenewerConfiguration.make_dirs? os.makedirs(os.path.join(self.tempdir, "live", "example.org")) os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) os.makedirs(os.path.join(self.tempdir, "configs")) - defaults = configobj.ConfigObj() - defaults["live_dir"] = os.path.join(self.tempdir, "live") - defaults["archive_dir"] = os.path.join(self.tempdir, "archive") - defaults["renewal_configs_dir"] = os.path.join(self.tempdir, - "configs") + config = configobj.ConfigObj() for kind in ALL_FOUR: config[kind] = os.path.join(self.tempdir, "live", "example.org", kind + ".pem") config.filename = os.path.join(self.tempdir, "configs", "example.org.conf") - self.defaults = defaults # for main() test - self.test_rc = storage.RenewableCert(config, defaults) + + self.defaults = configobj.ConfigObj() + self.test_rc = storage.RenewableCert( + config, self.defaults, self.cli_config) def tearDown(self): shutil.rmtree(self.tempdir) @@ -457,60 +460,57 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" from letsencrypt import storage - config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["archive_dir"] - live_dir = self.defaults["live_dir"] - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert", - "privkey", "chain", None, - self.defaults) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert", "privkey", "chain", None, + self.defaults, self.cli_config) # This consistency check tests most relevant properties about the # newly created cert lineage. self.assertTrue(result.consistent()) - self.assertTrue(os.path.exists(os.path.join(config_dir, - "the-lineage.com.conf"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com.conf"))) with open(result.fullchain) as f: self.assertEqual(f.read(), "cert" + "chain") # Let's do it again and make sure it makes a different lineage - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", None, - self.defaults) - self.assertTrue(os.path.exists( - os.path.join(config_dir, "the-lineage.com-0001.conf"))) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", None, + self.defaults, self.cli_config) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files - os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) + os.mkdir(os.path.join( + self.cli_config.live_dir, "the-lineage.com-0002")) self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "the-lineage.com", "cert3", "privkey3", "chain3", - None, self.defaults) - os.mkdir(os.path.join(archive_dir, "other-example.com")) + None, self.defaults, self.cli_config) + os.mkdir(os.path.join(self.cli_config.archive_dir, "other-example.com")) self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", - None, self.defaults) + None, self.defaults, self.cli_config) # Make sure it can accept renewal parameters params = {"stuff": "properties of stuff", "great": "awesome"} - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - params, self.defaults) + result = storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", + params, self.defaults, self.cli_config) # TODO: Conceivably we could test that the renewal parameters actually # got saved def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" from letsencrypt import storage - config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["archive_dir"] - live_dir = self.defaults["live_dir"] - shutil.rmtree(config_dir) - shutil.rmtree(archive_dir) - shutil.rmtree(live_dir) - storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - None, self.defaults) + shutil.rmtree(self.cli_config.renewal_configs_dir) + shutil.rmtree(self.cli_config.archive_dir) + shutil.rmtree(self.cli_config.live_dir) + + storage.RenewableCert.new_lineage( + "the-lineage.com", "cert2", "privkey2", "chain2", + None, self.defaults, self.cli_config) self.assertTrue(os.path.exists( - os.path.join(config_dir, "the-lineage.com.conf"))) - self.assertTrue(os.path.exists( - os.path.join(live_dir, "the-lineage.com", "privkey.pem"))) - self.assertTrue(os.path.exists( - os.path.join(archive_dir, "the-lineage.com", "privkey1.pem"))) + os.path.join( + self.cli_config.renewal_configs_dir, "the-lineage.com.conf"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.live_dir, "the-lineage.com", "privkey.pem"))) + self.assertTrue(os.path.exists(os.path.join( + self.cli_config.archive_dir, "the-lineage.com", "privkey1.pem"))) @mock.patch("letsencrypt.storage.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): @@ -518,7 +518,7 @@ class RenewableCertTests(unittest.TestCase): mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", - None, self.defaults) + None, self.defaults, self.cli_config) def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") @@ -574,19 +574,25 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configfile["renewalparams"]["rsa_key_size"] = "2048" self.test_rc.configfile["renewalparams"]["server"] = "acme.example.com" self.test_rc.configfile["renewalparams"]["authenticator"] = "fake" + self.test_rc.configfile["renewalparams"]["dvsni_port"] = "4430" mock_auth = mock.MagicMock() mock_pd.PluginsRegistry.find_all.return_value = {"apache": mock_auth} # Fails because "fake" != "apache" self.assertFalse(renewer.renew(self.test_rc, 1)) self.test_rc.configfile["renewalparams"]["authenticator"] = "apache" mock_client = mock.MagicMock() - mock_client.obtain_certificate.return_value = ("cert", "key", "chain") + # pylint: disable=star-args + mock_client.obtain_certificate.return_value = ( + mock.Mock(**{'body.as_pem.return_value': 'cert'}), + mock.Mock(**{'as_pem.return_value': 'chain'}), + mock.Mock(pem="key"), mock.sentinel.csr) mock_c.return_value = mock_client self.assertEqual(2, renewer.renew(self.test_rc, 1)) # TODO: We could also make several assertions about calls that should # have been made to the mock functions here. self.assertEqual(mock_da.call_count, 1) - mock_client.obtain_certificate.return_value = (None, None, None) + mock_client.obtain_certificate.return_value = ( + mock.sentinel.certr, None, mock.sentinel.key, mock.sentinel.csr) # This should fail because the renewal itself appears to fail self.assertFalse(renewer.renew(self.test_rc, 1)) @@ -602,22 +608,23 @@ class RenewableCertTests(unittest.TestCase): mock_rc_instance.should_autorenew.return_value = True mock_rc_instance.latest_common_version.return_value = 10 mock_rc.return_value = mock_rc_instance - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "README"), "w") as f: f.write("This is a README file to make sure that the renewer is") f.write("able to correctly ignore files that don't end in .conf.") - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "example.org.conf"), "w") as f: # This isn't actually parsed in this test; we have a separate # test_initialization that tests the initialization, assuming # that configobj can correctly parse the config file. f.write("cert = cert.pem\nprivkey = privkey.pem\n") f.write("chain = chain.pem\nfullchain = fullchain.pem\n") - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "example.com.conf"), "w") as f: f.write("cert = cert.pem\nprivkey = privkey.pem\n") f.write("chain = chain.pem\nfullchain = fullchain.pem\n") - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) self.assertEqual(mock_rc.call_count, 2) self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2) self.assertEqual(mock_notify.notify.call_count, 4) @@ -630,7 +637,8 @@ class RenewableCertTests(unittest.TestCase): mock_happy_instance.should_autorenew.return_value = False mock_happy_instance.latest_common_version.return_value = 10 mock_rc.return_value = mock_happy_instance - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) self.assertEqual(mock_rc.call_count, 4) self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0) self.assertEqual(mock_notify.notify.call_count, 4) @@ -638,10 +646,11 @@ class RenewableCertTests(unittest.TestCase): def test_bad_config_file(self): from letsencrypt import renewer - with open(os.path.join(self.defaults["renewal_configs_dir"], + with open(os.path.join(self.cli_config.renewal_configs_dir, "bad.conf"), "w") as f: f.write("incomplete = configfile\n") - renewer.main(self.defaults) + renewer.main(self.defaults, args=[ + '--config-dir', self.cli_config.config_dir]) # The ValueError is caught inside and nothing happens. diff --git a/letsencrypt/augeas_configurator.py b/letsencrypt_apache/augeas_configurator.py similarity index 100% rename from letsencrypt/augeas_configurator.py rename to letsencrypt_apache/augeas_configurator.py diff --git a/letsencrypt_apache/configurator.py b/letsencrypt_apache/configurator.py index 699ce0797..ae2ee261a 100644 --- a/letsencrypt_apache/configurator.py +++ b/letsencrypt_apache/configurator.py @@ -12,7 +12,6 @@ import zope.interface from acme import challenges from letsencrypt import achallenges -from letsencrypt import augeas_configurator from letsencrypt import constants as core_constants from letsencrypt import errors from letsencrypt import interfaces @@ -20,6 +19,7 @@ from letsencrypt import le_util from letsencrypt.plugins import common +from letsencrypt_apache import augeas_configurator from letsencrypt_apache import constants from letsencrypt_apache import display_ops from letsencrypt_apache import dvsni diff --git a/setup.py b/setup.py index ef819f50b..e2dd6f88e 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,6 @@ letsencrypt_install_requires = [ # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509Req.get_extensions 'PyOpenSSL>=0.15', 'pyrfc3339', - 'python-augeas', 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 'pytz', 'requests', diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh new file mode 100755 index 000000000..c3cc49c70 --- /dev/null +++ b/tests/boulder-integration.sh @@ -0,0 +1,47 @@ +#!/bin/sh -xe +# Simple integration test, make sure to activate virtualenv beforehand +# (source venv/bin/activate) and that you are running Boulder test +# instance (see ./boulder-start.sh). + +root="$(mktemp -d)" +echo "\nRoot integration tests directory: $root" +store_flags="--config-dir $root/conf --work-dir $root/work" + +common() { + # first three flags required, rest is handy defaults + letsencrypt \ + --server http://localhost:4000/acme/new-reg \ + --no-verify-ssl \ + --dvsni-port 5001 \ + $store_flags \ + --text \ + --agree-eula \ + --email "" \ + --authenticator standalone \ + -vvvvvvv "$@" +} + +common --domains le.wtf auth + +export CSR_PATH="${root}/csr.der" OPENSSL_CNF=examples/openssl.cnf +./examples/generate-csr.sh le.wtf +common auth --csr "$CSR_PATH" \ + --cert-path "${root}/csr/cert.pem" \ + --chain-path "${root}/csr/chain.pem" +openssl x509 -in "${root}/csr/0000_cert.pem" -text +openssl x509 -in "${root}/csr/0000_chain.pem" -text + +# the following assumes that Boulder issues certificates for less than +# 10 years, otherwise renewal will not take place +cat < "$root/conf/renewer.conf" +renew_before_expiry = 10 years +deploy_before_expiry = 10 years +EOF +letsencrypt-renewer $store_flags +dir="$root/conf/archive/le.wtf" +for x in cert chain fullchain privkey; +do + latest="$(ls -1t $dir/ | grep -e "^${x}" | head -n1)" + live="$(readlink -f "$root/conf/live/le.wtf/${x}.pem")" + #[ "${dir}/${latest}" = "$live" ] # renewer fails this test +done diff --git a/tests/boulder-start.sh b/tests/boulder-start.sh new file mode 100755 index 000000000..49139ff3c --- /dev/null +++ b/tests/boulder-start.sh @@ -0,0 +1,15 @@ +#!/bin/sh -xe +# Download and run Boulder instance for integration testing + +export GOPATH="${GOPATH:-/tmp/go}" + +# $ go get github.com/letsencrypt/boulder +# package github.com/letsencrypt/boulder +# imports github.com/letsencrypt/boulder +# imports github.com/letsencrypt/boulder: no buildable Go source files in /tmp/go/src/github.com/letsencrypt/boulder + +go get -d github.com/letsencrypt/boulder/cmd/boulder +cd $GOPATH/src/github.com/letsencrypt/boulder +make -j4 # Travis has 2 cores per build instance. +./start.sh & +# Hopefully start.sh bootstraps before integration test is started...