diff --git a/.pylintrc b/.pylintrc index 44fc15b1c..9cd5a8781 100644 --- a/.pylintrc +++ b/.pylintrc @@ -239,9 +239,7 @@ ignore-mixin-members=yes # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis - -# Get rid of the spurious no-member errors in pkg_resources -ignored-modules=pkg_resources +ignored-modules=pkg_resources,confargparse,argparse # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). diff --git a/docs/api/client/CONFIG.rst b/docs/api/client/CONFIG.rst deleted file mode 100644 index 9ed190768..000000000 --- a/docs/api/client/CONFIG.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.CONFIG` --------------------------------- - -.. automodule:: letsencrypt.client.CONFIG - :members: diff --git a/docs/api/client/configuration.rst b/docs/api/client/configuration.rst new file mode 100644 index 000000000..0bec61480 --- /dev/null +++ b/docs/api/client/configuration.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.configuration` +--------------------------------------- + +.. automodule:: letsencrypt.client.configuration + :members: diff --git a/docs/api/client/constants.rst b/docs/api/client/constants.rst new file mode 100644 index 000000000..c901e13c2 --- /dev/null +++ b/docs/api/client/constants.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.constants` +----------------------------------- + +.. automodule:: letsencrypt.client.constants + :members: diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py deleted file mode 100644 index 5a07a4aa2..000000000 --- a/letsencrypt/client/CONFIG.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Config for Let's Encrypt.""" -import os.path - - -ACME_SERVER = "letsencrypt-demo.org:443" -"""CA hostname (and optionally :port). - -If you create your own server... change this line - -Note: the server certificate must be trusted in order to avoid -further modifications to the client.""" - -# Directories -SERVER_ROOT = "/etc/apache2/" -"""Apache server root directory""" - -CONFIG_DIR = "/etc/letsencrypt/" -"""Configuration file directory for letsencrypt""" - -WORK_DIR = "/var/lib/letsencrypt/" -"""Working directory for letsencrypt""" - -BACKUP_DIR = os.path.join(WORK_DIR, "backups/") -"""Directory where configuration backups are stored""" - -TEMP_CHECKPOINT_DIR = os.path.join(WORK_DIR, "temp_checkpoint/") -"""Directory where temp checkpoint is created""" - -IN_PROGRESS_DIR = os.path.join(BACKUP_DIR, "IN_PROGRESS/") -"""Directory used before a permanent checkpoint is finalized""" - -CERT_KEY_BACKUP = os.path.join(WORK_DIR, "keys-certs/") -"""Directory where all certificates/keys are stored. Used for easy revocation""" - -REV_TOKENS_DIR = os.path.join(WORK_DIR, "revocation_tokens/") -"""Directory where all revocation tokens are saved.""" - -KEY_DIR = os.path.join(SERVER_ROOT, "keys/") -"""Where all keys should be stored""" - -CERT_DIR = os.path.join(SERVER_ROOT, "certs/") -"""Certificate storage""" - -# Files and extensions -OPTIONS_SSL_CONF = os.path.join(CONFIG_DIR, "options-ssl.conf") -"""Contains standard Apache SSL directives""" - -LE_VHOST_EXT = "-le-ssl.conf" -"""Let's Encrypt SSL vhost configuration extension""" - -CERT_PATH = CERT_DIR + "cert-letsencrypt.pem" -"""Let's Encrypt cert file.""" - -CHAIN_PATH = CERT_DIR + "chain-letsencrypt.pem" -"""Let's Encrypt chain file.""" - -INVALID_EXT = ".acme.invalid" -"""Invalid Extension""" - -# Challenge Sets -EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] -"""Mutually Exclusive Challenges - only solve 1""" - -DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) -"""These are challenges that must be solved by an Authenticator object""" - -CLIENT_CHALLENGES = frozenset( - ["recoveryToken", "recoveryContact", "proofOfPossession"]) -"""These are challenges that are handled by client.py""" - -# Challenge Constants -S_SIZE = 32 -"""Byte size of S""" - -NONCE_SIZE = 16 -"""byte size of Nonce""" - -# Key Sizes -RSA_KEY_SIZE = 2048 -"""Key size""" - -# Enhancements -ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"] -"""List of possible IInstaller enhancements. - -List of expected options parameters: -redirect, None -http-header, TODO -ocsp-stapling, TODO -spdy, TODO - -""" -# Apache Enhancement Arguments -REWRITE_HTTPS_ARGS = [ - "^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"] -"""Rewrite rule arguments used for redirections to https vhost""" - -# Apache Interaction -APACHE_CTL = "/usr/sbin/apache2ctl" -"""Command used for configtest and version number.""" - -APACHE2 = "/etc/init.d/apache2" -"""Command used for reload and restart.""" diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 6ca26bd24..198d83e81 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,7 +1,6 @@ """Apache Configuration based off of Augeas Configurator.""" import logging import os -import pkg_resources import re import shutil import socket @@ -12,7 +11,7 @@ import zope.interface from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -63,9 +62,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): The API of this class will change in the coming weeks as the exact needs of clients are clarified with the new and developing protocol. - :ivar str server_root: Path to Apache root directory - :ivar dict location: Path to various files associated - with the configuration + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) @@ -75,34 +74,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) - def __init__(self, server_root=CONFIG.SERVER_ROOT, direc=None, - ssl_options=CONFIG.OPTIONS_SSL_CONF, version=None): + def __init__(self, config, version=None): """Initialize an Apache Configurator. - :param str server_root: the apache server root directory - :param dict direc: locations of various config directories - (used mostly for unittesting) - :param str ssl_options: path of options-ssl.conf - (used mostly for unittesting) :param tup version: version of Apache as a tuple (2, 4, 7) (used mostly for unittesting) """ - if direc is None: - direc = {"backup": CONFIG.BACKUP_DIR, - "temp": CONFIG.TEMP_CHECKPOINT_DIR, - "progress": CONFIG.IN_PROGRESS_DIR, - "config": CONFIG.CONFIG_DIR, - "work": CONFIG.WORK_DIR} - - super(ApacheConfigurator, self).__init__(direc) - self.direc = direc + super(ApacheConfigurator, self).__init__(config) # Verify that all directories and files exist with proper permissions if os.geteuid() == 0: self.verify_setup() - self.parser = parser.ApacheParser(self.aug, server_root, ssl_options) + self.parser = parser.ApacheParser( + self.aug, self.config.apache_server_root, + self.config.apache_mod_ssl_conf) # Check for errors in parsing files with Augeas self.check_parsing_errors("httpd.aug") @@ -124,7 +111,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self._prepare_server_https() self.enhance_func = {"redirect": self._enable_redirect} - temp_install(ssl_options) + temp_install(self.config.apache_mod_ssl_conf) def deploy_cert(self, domain, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. @@ -330,7 +317,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ # Search sites-available, httpd.conf for possible virtual hosts paths = self.aug.match( - ("/files%ssites-available//*[label()=~regexp('%s')]" % + ("/files%s/sites-available//*[label()=~regexp('%s')]" % (self.parser.root, parser.case_i('VirtualHost')))) vhs = [] @@ -382,9 +369,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): is appropriately listening on port 443. """ - if not mod_loaded("ssl_module"): + if not mod_loaded("ssl_module", self.config.apache_ctl): logging.info("Loading mod_ssl into Apache Server") - enable_mod("ssl") + enable_mod("ssl", self.config.apache_init_script, + self.config.apache_enmod) # Check for Listen 443 # Note: This could be made to also look for ip:443 combo @@ -427,7 +415,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Makes an ssl_vhost version of a nonssl_vhost. Duplicates vhost and adds default ssl options - New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT + New vhost will reside as (nonssl_vhost.path) + ``IConfig.le_vhost_ext`` + .. note:: This function saves the configuration :param nonssl_vhost: Valid VH that doesn't have SSLEngine on @@ -440,27 +429,24 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): avail_fp = nonssl_vhost.filep # Get filepath of new ssl_vhost if avail_fp.endswith(".conf"): - ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT + ssl_fp = avail_fp[:-(len(".conf"))] + self.config.le_vhost_ext else: - ssl_fp = avail_fp + CONFIG.LE_VHOST_EXT + ssl_fp = avail_fp + self.config.le_vhost_ext # First register the creation so that it is properly removed if # configuration is rolled back self.reverter.register_file_creation(False, ssl_fp) try: - orig_file = open(avail_fp, 'r') - new_file = open(ssl_fp, 'w') - new_file.write("\n") - for line in orig_file: - new_file.write(line) - new_file.write("\n") + with open(avail_fp, 'r') as orig_file: + with open(ssl_fp, 'w') as new_file: + new_file.write("\n") + for line in orig_file: + new_file.write(line) + new_file.write("\n") except IOError: logging.fatal("Error writing/reading to file in make_vhost_ssl") sys.exit(49) - finally: - orig_file.close() - new_file.close() self.aug.load() @@ -528,9 +514,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain to enhance :param str enhancement: enhancement type defined in - :class:`letsencrypt.client.CONFIG.ENHANCEMENTS` + :const:`~letsencrypt.client.constants.ENHANCEMENTS` :param options: options for the enhancement - :type options: See :class:`letsencrypt.client.CONFIG.ENHANCEMENTS` + See :const:`~letsencrypt.client.constants.ENHANCEMENTS` documentation for appropriate parameter. """ @@ -565,8 +551,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) """ - if not mod_loaded("rewrite_module"): - enable_mod("rewrite") + if not mod_loaded("rewrite_module", self.config.apache_ctl): + enable_mod("rewrite", self.config.apache_init_script, + self.config.apache_enmod) general_v = self._general_vhost(ssl_vhost) if general_v is None: @@ -590,8 +577,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "in {}".format(general_v.filep)) # Add directives to server self.parser.add_dir(general_v.path, "RewriteEngine", "On") - self.parser.add_dir( - general_v.path, "RewriteRule", CONFIG.REWRITE_HTTPS_ARGS) + self.parser.add_dir(general_v.path, "RewriteRule", + constants.APACHE_REWRITE_HTTPS_ARGS) self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % (general_v.filep, ssl_vhost.filep)) self.save() @@ -630,9 +617,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not rewrite_path: # "No existing redirection for virtualhost" return False, -1 - if len(rewrite_path) == len(CONFIG.REWRITE_HTTPS_ARGS): + if len(rewrite_path) == len(constants.APACHE_REWRITE_HTTPS_ARGS): for idx, match in enumerate(rewrite_path): - if self.aug.get(match) != CONFIG.REWRITE_HTTPS_ARGS[idx]: + if (self.aug.get(match) != + constants.APACHE_REWRITE_HTTPS_ARGS[idx]): # Not a letsencrypt https rewrite return True, 2 # Existing letsencrypt https rewrite rule is in place @@ -681,7 +669,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "LogLevel warn\n" "\n" % (servername, serveralias, - " ".join(CONFIG.REWRITE_HTTPS_ARGS))) + " ".join(constants.APACHE_REWRITE_HTTPS_ARGS))) # Write out the file # This is the default name @@ -694,8 +682,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if ssl_vhost.names[0] < (255-23): redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] - redirect_filepath = ("%ssites-available/%s" % - (self.parser.root, redirect_filename)) + redirect_filepath = os.path.join( + self.parser.root, 'sites-available', redirect_filename) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -709,8 +697,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.aug.load() # Make a new vhost data structure and add it to the lists - new_fp = self.parser.root + "sites-available/" + redirect_filename - new_vhost = self._create_vhost(parser.get_aug_path(new_fp)) + new_vhost = self._create_vhost(parser.get_aug_path(redirect_filepath)) self.vhosts.append(new_vhost) # Finally create documentation for the change @@ -841,7 +828,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: bool """ - enabled_dir = os.path.join(self.parser.root, "sites-enabled/") + enabled_dir = os.path.join(self.parser.root, "sites-enabled") for entry in os.listdir(enabled_dir): if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp: return True @@ -866,7 +853,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return True if "/sites-available/" in vhost.filep: - enabled_path = ("%ssites-enabled/%s" % + enabled_path = ("%s/sites-enabled/%s" % (self.parser.root, os.path.basename(vhost.filep))) self.reverter.register_file_creation(False, enabled_path) os.symlink(vhost.filep, enabled_path) @@ -876,14 +863,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return True return False - def restart(self): # pylint: disable=no-self-use + def restart(self): """Restarts apache server. :returns: Success :rtype: bool """ - return apache_restart() + return apache_restart(self.config.apache_init_script) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Apache for errors. @@ -894,7 +881,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - ['sudo', '/usr/sbin/apache2ctl', 'configtest'], + ['sudo', self.config.apache_ctl, 'configtest'], # TODO: sudo? stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -920,14 +907,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ uid = os.geteuid() - le_util.make_or_verify_dir(self.direc["config"], 0o755, uid) - le_util.make_or_verify_dir(self.direc["work"], 0o755, uid) - le_util.make_or_verify_dir(self.direc["backup"], 0o755, uid) + le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) @classmethod def __str__(cls): return "Apache version %s" % ".".join(get_version()) + ########################################################################### # Challenges Section ########################################################################### @@ -985,7 +973,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() -def get_version(): +def get_version(apache_ctl): """Return version of Apache Server. Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) @@ -999,13 +987,13 @@ def get_version(): """ try: proc = subprocess.Popen( - [CONFIG.APACHE_CTL, '-v'], + [apache_ctl, '-v'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[0] except (OSError, ValueError): raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -v" % CONFIG.APACHE_CTL) + "Unable to run %s -v" % apache_ctl) regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) matches = regex.findall(text) @@ -1017,47 +1005,51 @@ def get_version(): return tuple([int(i) for i in matches[0].split('.')]) -def enable_mod(mod_name): +def enable_mod(mod_name, apache_init, apache_enmod): """Enables module in Apache. Both enables and restarts Apache so module is active. - :param str mod_name: Name of the module to enable + :param str mod_name: Name of the module to enable. + :param str apache_init: Path to the Apache init script. + :param str apache_enmod: Path to the Apache a2enmod script. """ try: # Use check_output so the command will finish before reloading # TODO: a2enmod is debian specific... - subprocess.check_call(["sudo", "a2enmod", mod_name], + subprocess.check_call(["sudo", apache_enmod, mod_name], # TODO: sudo? stdout=open("/dev/null", 'w'), stderr=open("/dev/null", 'w')) - apache_restart() + apache_restart(apache_init) except (OSError, subprocess.CalledProcessError) as err: logging.error("Error enabling mod_%s", mod_name) logging.error("Exception: %s", err) sys.exit(1) -def mod_loaded(module): +def mod_loaded(module, apache_ctl): """Checks to see if mod_ssl is loaded - Uses CONFIG.APACHE_CTL to get loaded module list. This also effectively + Uses ``apache_ctl`` to get loaded module list. This also effectively serves as a config_test. + :param str apache_ctl: Path to apache2ctl binary. + :returns: If ssl_module is included and active in Apache :rtype: bool """ try: proc = subprocess.Popen( - [CONFIG.APACHE_CTL, '-M'], + [apache_ctl, '-M'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() except (OSError, ValueError): logging.error( - "Error accessing %s for loaded modules!", CONFIG.APACHE_CTL) + "Error accessing %s for loaded modules!", apache_ctl) raise errors.LetsEncryptConfiguratorError( "Error accessing loaded modules") # Small errors that do not impede @@ -1072,9 +1064,11 @@ def mod_loaded(module): return False -def apache_restart(): +def apache_restart(apache_init_script): """Restarts the Apache Server. + :param str apache_init_script: Path to the Apache init script. + .. todo:: Try to use reload instead. (This caused timing problems before) .. todo:: On failure, this should be a recovery_routine call with another @@ -1086,7 +1080,7 @@ def apache_restart(): """ try: - proc = subprocess.Popen([CONFIG.APACHE2, 'restart'], + proc = subprocess.Popen([apache_init_script, 'restart'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -1143,6 +1137,4 @@ def temp_install(options_ssl): # Check to make sure options-ssl.conf is installed if not os.path.isfile(options_ssl): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(options_ssl)) - shutil.copyfile(dist_conf, options_ssl) + shutil.copyfile(constants.APACHE_MOD_SSL_CONF, options_ssl) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index feb25c3eb..9b4cd957a 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -3,7 +3,7 @@ import logging import os from letsencrypt.client import challenge_util -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client.apache import parser @@ -11,8 +11,9 @@ from letsencrypt.client.apache import parser class ApacheDvsni(object): """Class performs DVSNI challenges within the Apache configurator. - :ivar config: ApacheConfigurator object - :type config: :class:`letsencrypt.client.apache.configurator` + :ivar configurator: ApacheConfigurator object + :type configurator: + :class:`letsencrypt.client.apache.configurator.ApacheConfigurator` :ivar dvsni_chall: Data required for challenges. where DvsniChall tuples have the following fields @@ -32,12 +33,12 @@ class ApacheDvsni(object): :param str challenge_conf: location of the challenge config file """ - def __init__(self, config): - self.config = config + def __init__(self, configurator): + self.configurator = configurator self.dvsni_chall = [] self.indices = [] self.challenge_conf = os.path.join( - config.direc["config"], "le_dvsni_cert_challenge.conf") + configurator.config.config_dir, "le_dvsni_cert_challenge.conf") # self.completed = 0 def add_chall(self, chall, idx=None): @@ -59,12 +60,12 @@ class ApacheDvsni(object): return None # Save any changes to the configuration as a precaution # About to make temporary changes to the config - self.config.save() + self.configurator.save() addresses = [] default_addr = "*:443" for chall in self.dvsni_chall: - vhost = self.config.choose_vhost(chall.domain) + vhost = self.configurator.choose_vhost(chall.domain) if vhost is None: logging.error( "No vhost exists with servername or alias of: %s", @@ -74,7 +75,7 @@ class ApacheDvsni(object): return None # TODO - @jdkasten review this code to make sure it makes sense - self.config.make_server_sni_ready(vhost, default_addr) + self.configurator.make_server_sni_ready(vhost, default_addr) for addr in vhost.addrs: if "_default_" == addr.get_addr(): @@ -94,7 +95,7 @@ class ApacheDvsni(object): self._mod_config(addresses) # Save reversible changes - self.config.save("SNI Challenge", True) + self.configurator.save("SNI Challenge", True) return responses @@ -102,7 +103,7 @@ class ApacheDvsni(object): """Generate and write out challenge certificate.""" cert_path = self.get_cert_file(chall.nonce) # Register the path before you write out the file - self.config.reverter.register_file_creation(True, cert_path) + self.configurator.reverter.register_file_creation(True, cert_path) cert_pem, s_b64 = challenge_util.dvsni_gen_cert( chall.domain, chall.r_b64, chall.nonce, chall.key) @@ -130,8 +131,9 @@ class ApacheDvsni(object): self.dvsni_chall[idx].key.file) config_text += "\n" - self._conf_include_check(self.config.parser.loc["default"]) - self.config.reverter.register_file_creation(True, self.challenge_conf) + self._conf_include_check(self.configurator.parser.loc["default"]) + self.configurator.reverter.register_file_creation( + True, self.challenge_conf) with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) @@ -145,11 +147,12 @@ class ApacheDvsni(object): :param str main_config: file path to main user apache config file """ - if len(self.config.parser.find_dir( + if len(self.configurator.parser.find_dir( parser.case_i("Include"), self.challenge_conf)) == 0: # print "Including challenge virtual host(s)" - self.config.parser.add_dir(parser.get_aug_path(main_config), - "Include", self.challenge_conf) + self.configurator.parser.add_dir( + parser.get_aug_path(main_config), + "Include", self.challenge_conf) def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): """Chocolate virtual server configuration text @@ -164,18 +167,20 @@ class ApacheDvsni(object): """ ips = " ".join(str(i) for i in ip_addrs) + document_root = os.path.join( + self.configurator.config.config_dir, "dvsni_page/") return ("\n" - "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "ServerName " + nonce + constants.DVSNI_DOMAIN_SUFFIX + "\n" "UseCanonicalName on\n" "SSLStrictSNIVHostCheck on\n" "\n" "LimitRequestBody 1048576\n" "\n" - "Include " + self.config.parser.loc["ssl_options"] + "\n" + "Include " + self.configurator.parser.loc["ssl_options"] + "\n" "SSLCertificateFile " + self.get_cert_file(nonce) + "\n" "SSLCertificateKeyFile " + dvsni_key_file + "\n" "\n" - "DocumentRoot " + self.config.direc["config"] + "dvsni_page/\n" + "DocumentRoot " + document_root + "\n" "\n\n") def get_cert_file(self, nonce): @@ -187,4 +192,4 @@ class ApacheDvsni(object): :rtype: str """ - return self.config.direc["work"] + nonce + ".crt" + return os.path.join(self.configurator.config.work_dir, nonce + ".crt") diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py index 8bc636dd1..0a5eff97c 100644 --- a/letsencrypt/client/apache/parser.py +++ b/letsencrypt/client/apache/parser.py @@ -6,18 +6,23 @@ from letsencrypt.client import errors class ApacheParser(object): - """Class handles the fine details of parsing the Apache Configuration.""" + """Class handles the fine details of parsing the Apache Configuration. + + :ivar str root: Normalized abosulte path to the server root + directory. Without trailing slash. + + """ def __init__(self, aug, root, ssl_options): # Find configuration root and make sure augeas can parse it. self.aug = aug - self.root = root + self.root = os.path.abspath(root) self.loc = self._set_locations(ssl_options) self._parse_file(self.loc["root"]) # Must also attempt to parse sites-available or equivalent # Sites-available is not included naturally in configuration - self._parse_file(os.path.join(self.root, "sites-available/*")) + self._parse_file(os.path.join(self.root, "sites-available") + "/*") # This problem has been fixed in Augeas 1.0 self.standardize_excl() @@ -190,7 +195,7 @@ class ApacheParser(object): arg = cur_dir + arg # conf/ is a special variable for ServerRoot in Apache elif arg.startswith("conf/"): - arg = self.root + arg[5:] + arg = self.root + arg[4:] # TODO: Test if Apache allows ../ or ~/ for Includes # Attempts to add a transform to the file if one does not already exist @@ -301,12 +306,12 @@ class ApacheParser(object): excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", "*~", - self.root + "*.augsave", - self.root + "*~", - self.root + "*/*augsave", - self.root + "*/*~", - self.root + "*/*/*.augsave", - self.root + "*/*/*~"] + self.root + "/*.augsave", + self.root + "/*~", + self.root + "/*/*augsave", + self.root + "/*/*~", + self.root + "/*/*/*.augsave", + self.root + "/*/*/*~"] for i in range(len(excl)): self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i]) diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index c35df5c2d..8854fef09 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -3,35 +3,26 @@ import logging import augeas -from letsencrypt.client import CONFIG from letsencrypt.client import reverter class AugeasConfigurator(object): """Base Augeas Configurator class. + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar aug: Augeas object :type aug: :class:`augeas.Augeas` :ivar str save_notes: Human-readable configuration change notes - :ivar dict direc: dictionary containing save directory paths :ivar reverter: saves and reverts checkpoints :type reverter: :class:`letsencrypt.client.reverter.Reverter` """ - def __init__(self, direc=None): - """Initialize Augeas Configurator. - - :param dict direc: location of save directories - (used mostly for testing) - - """ - - if not direc: - direc = {"backup": CONFIG.BACKUP_DIR, - "temp": CONFIG.TEMP_CHECKPOINT_DIR, - "progress": CONFIG.IN_PROGRESS_DIR} + def __init__(self, config): + self.config = config # Set Augeas flags to not save backup (we do it ourselves) # Set Augeas to not load anything by default @@ -43,7 +34,7 @@ class AugeasConfigurator(object): # This needs to occur before VirtualHost objects are setup... # because this will change the underlying configuration and potential # vhosts - self.reverter = reverter.Reverter(direc) + self.reverter = reverter.Reverter(config) self.reverter.recovery_routine() def check_parsing_errors(self, lens): diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index e2ff9d292..6f0ece535 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -3,18 +3,20 @@ import logging import sys from letsencrypt.client import acme -from letsencrypt.client import CONFIG from letsencrypt.client import challenge_util +from letsencrypt.client import constants from letsencrypt.client import errors class AuthHandler(object): # pylint: disable=too-many-instance-attributes """ACME Authorization Handler for a client. - :ivar dv_auth: Authenticator capable of solving CONFIG.DV_CHALLENGES + :ivar dv_auth: Authenticator capable of solving + :const:`~letsencrypt.client.constants.DV_CHALLENGES` :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` - :ivar client_auth: Authenticator capable of solving CONFIG.CLIENT_CHALLENGES + :ivar client_auth: Authenticator capable of solving + :const:`~letsencrypt.client_auth.constants.CLIENT_CHALLENGES` :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` :ivar network: Network object for sending and receiving authorization @@ -258,12 +260,12 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes chall = challenges[index] # Authenticator Challenges - if chall["type"] in CONFIG.DV_CHALLENGES: + if chall["type"] in constants.DV_CHALLENGES: dv_chall.append(challenge_util.IndexedChall( self._construct_dv_chall(chall, domain), index)) # Client Challenges - elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + elif chall["type"] in constants.CLIENT_CHALLENGES: client_chall.append(challenge_util.IndexedChall( self._construct_client_chall(chall, domain), index)) @@ -449,7 +451,7 @@ def _find_dumb_path(challenges, preferences): def is_preferred(offered_challenge_type, path): """Return whether or not the challenge is preferred in path.""" for _, challenge_type in path: - for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: + for mutually_exclusive in constants.EXCLUSIVE_CHALLENGES: # Second part is in case we eventually allow multiple names # to be challenges at the same time if (challenge_type in mutually_exclusive and diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index d1b105d8f..2619f3f23 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -4,7 +4,7 @@ import hashlib from Crypto import Random -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client import crypto_util from letsencrypt.client import le_util @@ -44,14 +44,14 @@ def dvsni_gen_cert(name, r_b64, nonce, key): """ # Generate S - dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE) + dvsni_s = Random.get_random_bytes(constants.S_SIZE) dvsni_r = le_util.jose_b64decode(r_b64) # Generate extension ext = _dvsni_gen_ext(dvsni_r, dvsni_s) cert_pem = crypto_util.make_ss_cert( - key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) + key.pem, [nonce + constants.DVSNI_DOMAIN_SUFFIX, name, ext]) return cert_pem, le_util.jose_b64encode(dvsni_s) @@ -62,7 +62,7 @@ def _dvsni_gen_ext(dvsni_r, dvsni_s): :param bytearray dvsni_r: DVSNI r value :param bytearray dvsni_s: DVSNI s value - :returns: z + CONFIG.INVALID_EXT + :returns: z + :const:`~letsencrypt.client.constants.DVSNI_DOMAIN_SUFFIX` :rtype: str """ @@ -70,4 +70,4 @@ def _dvsni_gen_ext(dvsni_r, dvsni_s): z_base.update(dvsni_r) z_base.update(dvsni_s) - return z_base.hexdigest() + CONFIG.INVALID_EXT + return z_base.hexdigest() + constants.DVSNI_DOMAIN_SUFFIX diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index aca683265..2ec530f82 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -9,7 +9,6 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator -from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util from letsencrypt.client import errors from letsencrypt.client import interfaces @@ -39,43 +38,41 @@ class Client(object): :ivar installer: Object supporting the IInstaller interface. :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + """ zope.interface.implements(interfaces.IAuthenticator) - def __init__(self, server, authkey, dv_auth, installer): + def __init__(self, config, authkey, dv_auth, installer): """Initialize a client. - :param str server: CA server to contact - :param dv_auth: IAuthenticator Interface that can solve the - CONFIG.DV_CHALLENGES + :param dv_auth: IAuthenticator that can solve the + :const:`letsencrypt.client.constants.DV_CHALLENGES` :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - self.network = network.Network(server) + self.network = network.Network(config.server) self.authkey = authkey - self.installer = installer + self.config = config if dv_auth is not None: - client_auth = client_authenticator.ClientAuthenticator(server) + client_auth = client_authenticator.ClientAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( dv_auth, client_auth, self.network) else: self.auth_handler = None - def obtain_certificate(self, domains, csr=None, - cert_path=CONFIG.CERT_PATH, - chain_path=CONFIG.CHAIN_PATH): + def obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. :param str domains: list of 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` - :param str cert_path: Full desired path to end certificate. - :param str chain_path: Full desired path to end chain file. - :returns: cert_file, chain_file (paths to respective files) :rtype: `tuple` of `str` @@ -94,16 +91,14 @@ class Client(object): # Create CSR from names if csr is None: - csr = init_csr(self.authkey, domains) + csr = init_csr(self.authkey, domains, self.config.cert_dir) # Retrieve certificate certificate_dict = self.acme_certificate(csr.data) # Save Certificate cert_file, chain_file = self.save_certificate( - certificate_dict, cert_path, chain_path) - - print cert_file + certificate_dict, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key(cert_file, self.authkey.file, False) @@ -288,7 +283,7 @@ def validate_key_csr(privkey, csr=None): "The key and CSR do not match") -def init_key(key_size): +def init_key(key_size, key_dir): """Initializes privkey. Inits key and CSR using provided files or generating new files @@ -296,19 +291,19 @@ def init_key(key_size): filesystem. The CSR is placed into DER format to allow the namedtuple to easily work with the protocol. + :param str key_dir: Key save directory. + """ try: key_pem = crypto_util.make_key(key_size) except ValueError as err: logging.fatal(str(err)) - logging.info("Note: The default RSA key size is %d bits.", - CONFIG.RSA_KEY_SIZE) sys.exit(1) # Save file - le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700) + le_util.make_or_verify_dir(key_dir, 0o700) key_f, key_filename = le_util.unique_file( - os.path.join(CONFIG.KEY_DIR, "key-letsencrypt.pem"), 0o600) + os.path.join(key_dir, "key-letsencrypt.pem"), 0o600) key_f.write(key_pem) key_f.close() @@ -316,15 +311,23 @@ def init_key(key_size): return le_util.Key(key_filename, key_pem) +def init_csr(privkey, names, cert_dir): + """Initialize a CSR with the given private key. -def init_csr(privkey, names): - """Initialize a CSR with the given private key.""" + :param privkey: Key to include in the CSR + :type privkey: :class:`letsencrypt.client.le_util.Key` + + :param list names: `str` names to include in the CSR + + :param str cert_dir: Certificate save directory. + + """ csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) # Save CSR - le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) + le_util.make_or_verify_dir(cert_dir, 0o755) csr_f, csr_filename = le_util.unique_file( - os.path.join(CONFIG.CERT_DIR, "csr-letsencrypt.pem"), 0o644) + os.path.join(cert_dir, "csr-letsencrypt.pem"), 0o644) csr_f.write(csr_pem) csr_f.close() @@ -341,11 +344,16 @@ def csr_pem_to_der(csr): # This should be controlled by commandline parameters -def determine_authenticator(): - """Returns a valid IAuthenticator.""" +def determine_authenticator(config): + """Returns a valid IAuthenticator. + + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ auths = [] try: - auths.append(configurator.ApacheConfigurator()) + auths.append(configurator.ApacheConfigurator(config)) except errors.LetsEncryptNoInstallationError: logging.info("Unable to determine a way to authenticate the server") if len(auths) > 1: @@ -354,15 +362,20 @@ def determine_authenticator(): return auths[0] -def determine_installer(): - """Returns a valid installer if one exists.""" +def determine_installer(config): + """Returns a valid installer if one exists. + + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ try: - return configurator.ApacheConfigurator() + return configurator.ApacheConfigurator(config) except errors.LetsEncryptNoInstallationError: logging.info("Unable to find a way to install the certificate.") -def rollback(checkpoints): +def rollback(checkpoints, config): """Revert configuration the specified number of checkpoints. .. note:: If another installer uses something other than the reverter class @@ -379,12 +392,15 @@ def rollback(checkpoints): :param int checkpoints: Number of checkpoints to revert. + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + """ # Misconfigurations are only a slight problems... allow the user to rollback try: - installer = determine_installer() + installer = determine_installer(config) except errors.LetsEncryptMisconfigurationError: - _misconfigured_rollback(checkpoints) + _misconfigured_rollback(checkpoints, config) return # No Errors occurred during init... proceed normally @@ -395,9 +411,16 @@ def rollback(checkpoints): installer.restart() -def _misconfigured_rollback(checkpoints): - """Handles the case where the Installer is misconfigured.""" - yes = zope.component.getUtility(interfaces.IDisplay).yesno( +def _misconfigured_rollback(checkpoints, config): + """Handles the case where the Installer is misconfigured. + + :param int checkpoints: Number of checkpoints to revert. + + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ + yes = zope.component.getUtility(interfaces.IDisplay).generic_yesno( "Oh, no! The web server is currently misconfigured.{0}{0}" "Would you still like to rollback the " "configuration?".format(os.linesep)) @@ -410,13 +433,13 @@ def _misconfigured_rollback(checkpoints): # recovery routine has probably already been run by installer # in the__init__ attempt, run it again for safety... it shouldn't hurt # Also... not sure how future installers will handle recovery. - rev = reverter.Reverter() + rev = reverter.Reverter(config) rev.recovery_routine() rev.rollback_checkpoints(checkpoints) # We should try to restart the server try: - installer = determine_installer() + installer = determine_installer(config) installer.restart() logging.info("Hooray! Rollback solved the misconfiguration!") logging.info("Your web server is back up and running.") @@ -425,16 +448,17 @@ def _misconfigured_rollback(checkpoints): "Rollback was unable to solve the misconfiguration issues") -def revoke(server): +def revoke(config): """Revoke certificates. - :param str server: ACME server the client wishes to revoke certificates from + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` """ # Misconfigurations don't really matter. Determine installer better choose # correctly though. try: - installer = determine_installer() + installer = determine_installer(config) except errors.LetsEncryptMisconfigurationError: zope.component.getUtility(interfaces.IDisplay).notification( "The web server is currently misconfigured. Some " @@ -446,12 +470,15 @@ def revoke(server): revoc.display_menu() -def view_config_changes(): +def view_config_changes(config): """View checkpoints and associated configuration changes. .. note:: This assumes that the installation is using a Reverter object. + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + """ - rev = reverter.Reverter() + rev = reverter.Reverter(config) rev.recovery_routine() rev.view_config_changes() diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py index 699e5e598..7229239dc 100644 --- a/letsencrypt/client/client_authenticator.py +++ b/letsencrypt/client/client_authenticator.py @@ -8,7 +8,8 @@ from letsencrypt.client import recovery_token class ClientAuthenticator(object): - """IAuthenticator for CONFIG.CLIENT_CHALLENGES. + """IAuthenticator for + :const:`~letsencrypt.client.constants.CLIENT_CHALLENGES`. :ivar rec_token: Performs "recoveryToken" challenges :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` @@ -17,13 +18,15 @@ class ClientAuthenticator(object): zope.interface.implements(interfaces.IAuthenticator) # This will have an installer soon for get_key/cert purposes - def __init__(self, server): + def __init__(self, config): """Initialize Client Authenticator. - :param str server: ACME CA Server + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` """ - self.rec_token = recovery_token.RecoveryToken(server) + self.rec_token = recovery_token.RecoveryToken( + config.server, config.rec_token_dir) def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use """Return list of challenge preferences.""" diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py new file mode 100644 index 000000000..1bdbe2059 --- /dev/null +++ b/letsencrypt/client/configuration.py @@ -0,0 +1,52 @@ +"""Let's Encrypt user-supplied configuration.""" +import os +import zope.interface + +from letsencrypt.client import constants +from letsencrypt.client import interfaces + + +class NamespaceConfig(object): + """Configuration wrapper around :class:`argparse.Namespace`. + + For more documentation, including available attributes, please see + :class:`letsencrypt.client.interfaces.IConfig`. However, note that + the following attributes are dynamically resolved using + :attr:`~letsencrypt.client.interfaces.IConfig.work_dir` and relative + paths defined in :py:mod:`letsencrypt.client.constants`: + + - ``temp_checkpoint_dir`` + - ``in_progress_dir`` + - ``cert_key_backup`` + - ``rec_token_dir`` + + :ivar namespace: Namespace typically produced by + :meth:`argparse.ArgumentParser.parse_args`. + :type namespace: :class:`argparse.Namespace` + + """ + zope.interface.implements(interfaces.IConfig) + + def __init__(self, namespace): + self.namespace = namespace + + def __getattr__(self, name): + return getattr(self.namespace, name) + + @property + def temp_checkpoint_dir(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.work_dir, constants.TEMP_CHECKPOINT_DIR) + + @property + def in_progress_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) + + @property + def cert_key_backup(self): # pylint: disable=missing-docstring + return os.path.join( + self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR) + + @property + def rec_token_dir(self): # pylint: disable=missing-docstring + return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR) diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py new file mode 100644 index 000000000..3652face7 --- /dev/null +++ b/letsencrypt/client/constants.py @@ -0,0 +1,65 @@ +"""Let's Encrypt constants.""" +import pkg_resources + + +S_SIZE = 32 +"""Size (in bytes) of secret base64-encoded octet string "s" used in +challanges.""" + +NONCE_SIZE = 16 +"""Size of nonce used in JWS objects (in bytes).""" + + +EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] +"""Mutually exclusive challenges.""" + +DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +"""Challenges that must be solved by a +:class:`letsencrypt.client.interfaces.IAuthenticator` object.""" + +CLIENT_CHALLENGES = frozenset( + ["recoveryToken", "recoveryContact", "proofOfPossession"]) +"""Challenges that are handled by the Let's Encrypt client.""" + + +ENHANCEMENTS = ["redirect", "http-header", "ocsp-stapling", "spdy"] +"""List of possible :class:`letsencrypt.client.interfaces.IInstaller` +enhancements. + +List of expected options parameters: +- redirect: None +- http-header: TODO +- ocsp-stapling: TODO +- spdy: TODO + +""" + + +APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( + 'letsencrypt.client.apache', 'options-ssl.conf') +"""Path to the Apache mod_ssl config file found in the Let's Encrypt +distribution.""" + +APACHE_REWRITE_HTTPS_ARGS = [ + "^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"] +"""Apache rewrite rule arguments used for redirections to https vhost""" + + +DVSNI_DOMAIN_SUFFIX = ".acme.invalid" +"""Suffix appended to domains in DVSNI validation.""" + + +TEMP_CHECKPOINT_DIR = "temp_checkpoint" +"""Temporary checkpoint directory (relative to IConfig.work_dir).""" + +IN_PROGRESS_DIR = "IN_PROGRESS" +"""Directory used before a permanent checkpoint is finalized (relative to +IConfig.work_dir).""" + +CERT_KEY_BACKUP_DIR = "keys-certs" +"""Directory where all certificates and keys are stored (relative to +IConfig.work_dir. Used for easy revocation.""" + +REC_TOKEN_DIR = "recovery_tokens" +"""Directory where all recovery tokens are saved (relative to +IConfig.work_dir).""" diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index a2e4d27be..4e6779b5f 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -15,11 +15,11 @@ import Crypto.Signature.PKCS1_v1_5 import M2Crypto -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client import le_util -def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): +def create_sig(msg, key_str, nonce=None): """Create signature with nonce prepended to the message. .. todo:: Protect against crypto unicode errors... is this sufficient? @@ -30,19 +30,17 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): :param str key_str: Key in string form. Accepted formats are the same as for `Crypto.PublicKey.RSA.importKey`. - :param nonce: Nonce to be used. If None, nonce of `nonce_len` size - will be randomly generated. - :type nonce: str or None - - :param int nonce_len: Size of the automatically generated nonce. + :param str msg: Message to be signed + :param str nonce: Nonce to be used (required size) :returns: Signature. :rtype: dict """ - msg = str(msg) key = Crypto.PublicKey.RSA.importKey(key_str) - nonce = Random.get_random_bytes(nonce_len) if nonce is None else nonce + if nonce is None: + nonce = Random.get_random_bytes(constants.NONCE_SIZE) + assert len(nonce) == constants.NONCE_SIZE msg_with_nonce = nonce + msg hashed = Crypto.Hash.SHA256.new(msg_with_nonce) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 3ceeee3e8..2530fa752 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -2,6 +2,7 @@ import zope.interface # pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class +# pylint: disable=too-few-public-methods class IAuthenticator(zope.interface.Interface): @@ -69,10 +70,10 @@ class IInstaller(zope.interface.Interface): :param str domain: domain for which to provide enhancement :param str enhancement: An enhancement as defined in - :const:`~letsencrypt.client.CONFIG.ENHANCEMENTS` + :const:`~letsencrypt.client.constants.ENHANCEMENTS` :param options: Flexible options parameter for enhancement. Check documentation of - :const:`~letsencrypt.client.CONFIG.ENHANCEMENTS` + :const:`~letsencrypt.client.constants.ENHANCEMENTS` for expected options for each enhancement. """ @@ -81,7 +82,7 @@ class IInstaller(zope.interface.Interface): """Returns a list of supported enhancements. :returns: supported enhancements which should be a subset of - :const:`~letsencrypt.client.CONFIG.ENHANCEMENTS` + :const:`~letsencrypt.client.constants.ENHANCEMENTS` :rtype: :class:`list` of :class:`str` """ diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index 84e91e891..4d556eb51 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -3,9 +3,7 @@ import errno import os import zope.component -# import zope.interface -from letsencrypt.client import CONFIG from letsencrypt.client import le_util from letsencrypt.client import interfaces @@ -16,7 +14,7 @@ class RecoveryToken(object): Based on draft-barnes-acme, section 6.4. """ - def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): + def __init__(self, server, direc): self.token_dir = os.path.join(direc, server) def perform(self, chall): diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index b0e94c663..1e65d54ba 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -6,7 +6,6 @@ import time import zope.component -from letsencrypt.client import CONFIG from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util @@ -14,13 +13,14 @@ from letsencrypt.client.display import display_util class Reverter(object): - """Reverter Class - save and revert configuration checkpoints""" - def __init__(self, direc=None): - if not direc: - direc = {"backup": CONFIG.BACKUP_DIR, - "temp": CONFIG.TEMP_CHECKPOINT_DIR, - "progress": CONFIG.IN_PROGRESS_DIR} - self.direc = direc + """Reverter Class - save and revert configuration checkpoints. + + :param config: Configuration. + :type config: :class:`letsencrypt.client.interfaces.IConfig` + + """ + def __init__(self, config): + self.config = config def revert_temporary_config(self): """Reload users original configuration files after a temporary save. @@ -32,13 +32,13 @@ class Reverter(object): Unable to revert config """ - if os.path.isdir(self.direc["temp"]): + if os.path.isdir(self.config.temp_checkpoint_dir): try: - self._recover_checkpoint(self.direc["temp"]) + self._recover_checkpoint(self.config.temp_checkpoint_dir) except errors.LetsEncryptReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for %s", - self.direc["temp"]) + self.config.temp_checkpoint_dir) raise errors.LetsEncryptReverterError( "Unable to revert temporary config") @@ -63,7 +63,7 @@ class Reverter(object): logging.error("Rollback argument must be a positive integer") raise errors.LetsEncryptReverterError("Invalid Input") - backups = os.listdir(self.direc["backup"]) + backups = os.listdir(self.config.backup_dir) backups.sort() if len(backups) < rollback: @@ -71,7 +71,7 @@ class Reverter(object): rollback, len(backups)) while rollback > 0 and backups: - cp_dir = os.path.join(self.direc["backup"], backups.pop()) + cp_dir = os.path.join(self.config.backup_dir, backups.pop()) try: self._recover_checkpoint(cp_dir) except errors.LetsEncryptReverterError: @@ -88,7 +88,7 @@ class Reverter(object): .. todo:: Decide on a policy for error handling, OSError IOError... """ - backups = os.listdir(self.direc["backup"]) + backups = os.listdir(self.config.backup_dir) backups.sort(reverse=True) if not backups: @@ -102,12 +102,12 @@ class Reverter(object): float(bkup) except ValueError: raise errors.LetsEncryptReverterError( - "Invalid directories in {0}".format(self.direc["backup"])) + "Invalid directories in {0}".format(self.config.backup_dir)) output = [] for bkup in backups: output.append(time.ctime(float(bkup))) - cur_dir = os.path.join(self.direc["backup"], bkup) + cur_dir = os.path.join(self.config.backup_dir, bkup) with open(os.path.join(cur_dir, "CHANGES_SINCE")) as changes_fd: output.append(changes_fd.read()) @@ -136,7 +136,8 @@ class Reverter(object): param str save_notes: notes about changes during the save """ - self._add_to_checkpoint_dir(self.direc["temp"], save_files, save_notes) + self._add_to_checkpoint_dir( + self.config.temp_checkpoint_dir, save_files, save_notes) def add_to_checkpoint(self, save_files, save_notes): """Add files to a permanent checkpoint @@ -148,7 +149,7 @@ class Reverter(object): # Check to make sure we are not overwriting a temp file self._check_tempfile_saves(save_files) self._add_to_checkpoint_dir( - self.direc["progress"], save_files, save_notes) + self.config.in_progress_dir, save_files, save_notes) def _add_to_checkpoint_dir(self, cp_dir, save_files, save_notes): """Add save files to checkpoint directory. @@ -258,13 +259,13 @@ class Reverter(object): protected_files = [] # Get temp modified files - temp_path = os.path.join(self.direc["temp"], "FILEPATHS") + temp_path = os.path.join(self.config.temp_checkpoint_dir, "FILEPATHS") if os.path.isfile(temp_path): with open(temp_path, "r") as protected_fd: protected_files.extend(protected_fd.read().splitlines()) # Get temp new files - new_path = os.path.join(self.direc["temp"], "NEW_FILES") + new_path = os.path.join(self.config.temp_checkpoint_dir, "NEW_FILES") if os.path.isfile(new_path): with open(new_path, "r") as protected_fd: protected_files.extend(protected_fd.read().splitlines()) @@ -299,9 +300,9 @@ class Reverter(object): "Forgot to provide files to registration call") if temporary: - cp_dir = self.direc["temp"] + cp_dir = self.config.temp_checkpoint_dir else: - cp_dir = self.direc["progress"] + cp_dir = self.config.in_progress_dir le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) @@ -325,7 +326,7 @@ class Reverter(object): def recovery_routine(self): """Revert all previously modified files. - First, any changes found in self.direc["temp"] are removed, + First, any changes found in IConfig.temp_checkpoint_dir are removed, then IN_PROGRESS changes are removed The order is important. IN_PROGRESS is unable to add files that are already added by a TEMP change. Thus TEMP must be rolled back first because that will be the @@ -333,17 +334,17 @@ class Reverter(object): """ self.revert_temporary_config() - if os.path.isdir(self.direc["progress"]): + if os.path.isdir(self.config.in_progress_dir): try: - self._recover_checkpoint(self.direc["progress"]) + self._recover_checkpoint(self.config.in_progress_dir) except errors.LetsEncryptReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for IN_PROGRESS " "checkpoint - %s", - self.direc["progress"]) + self.config.in_progress_dir) raise errors.LetsEncryptReverterError( "Incomplete or failed recovery for IN_PROGRESS checkpoint " - "- %s" % self.direc["progress"]) + "- %s" % self.config.in_progress_dir) def _remove_contained_files(self, file_list): # pylint: disable=no-self-use """Erase all files contained within file_list. @@ -386,8 +387,8 @@ class Reverter(object): def finalize_checkpoint(self, title): """Move IN_PROGRESS checkpoint to timestamped checkpoint. - Adds title to self.direc["progress"] CHANGES_SINCE - Move self.direc["progress"] to Backups directory and + Adds title to self.config.in_progress_dir CHANGES_SINCE + Move self.config.in_progress_dir to Backups directory and rename the directory as a timestamp :param str title: Title describing checkpoint @@ -396,13 +397,14 @@ class Reverter(object): """ # Check to make sure an "in progress" directory exists - if not os.path.isdir(self.direc["progress"]): + if not os.path.isdir(self.config.in_progress_dir): return changes_since_path = os.path.join( - self.direc["progress"], "CHANGES_SINCE") + self.config.in_progress_dir, "CHANGES_SINCE") + changes_since_tmp_path = os.path.join( - self.direc["progress"], "CHANGES_SINCE.tmp") + self.config.in_progress_dir, "CHANGES_SINCE.tmp") try: with open(changes_since_tmp_path, "w") as changes_tmp: @@ -423,9 +425,9 @@ class Reverter(object): # collisions in the naming convention. cur_time = time.time() for _ in range(10): - final_dir = os.path.join(self.direc["backup"], str(cur_time)) + final_dir = os.path.join(self.config.backup_dir, str(cur_time)) try: - os.rename(self.direc["progress"], final_dir) + os.rename(self.config.in_progress_dir, final_dir) return except OSError: # It is possible if the checkpoints are made extremely quickly @@ -436,6 +438,6 @@ class Reverter(object): # After 10 attempts... something is probably wrong here... logging.error( "Unable to finalize checkpoint, %s -> %s", - self.direc["progress"], final_dir) + self.config.in_progress_dir, final_dir) raise errors.LetsEncryptReverterError( "Unable to finalize checkpoint renaming") diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 48d4deaff..dd4cbe3bd 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -8,9 +8,9 @@ import shutil import M2Crypto from letsencrypt.client import acme -from letsencrypt.client import CONFIG from letsencrypt.client import errors from letsencrypt.client import le_util + from letsencrypt.client import network from letsencrypt.client.display import display_util @@ -26,20 +26,27 @@ class Revoker(object): :type network: :class:`letsencrypt.client.network` :ivar installer: Installer object - :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + :type installer: :class:`~letsencrypt.client.interfaces.IInstaller` + + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` """ - list_path = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - marked_path = os.path.join(CONFIG.CERT_KEY_BACKUP, "MARKED") - - def __init__(self, server, installer): - self.network = network.Network(server) + def __init__(self, installer, config): + self.network = network.Network(config.server) self.installer = installer + self.config = config + # This will go through and make sure that nothing almost got revoked... # but didn't quite make it... also, guarantees no orphan cert/key files self.recovery_routine() + # TODO: WTF do I do with these... + self.list_path = os.path.join(config.cert_key_backup, "LIST") + self.marked_path = os.path.join(config.cert_key_backup, "MARKED") + + def revoke_from_interface(self, cert): """Handle ACME "revocation" phase. @@ -84,9 +91,9 @@ class Revoker(object): def recovery_routine(self): """Intended to make sure files aren't orphaned.""" - if not os.path.isfile(Revoker.marked_path): + if not os.path.isfile(self.marked_path): return - with open(Revoker.marked_path, "r") as marked_file: + with open(self.marked_path, "r") as marked_file: csvreader = csv.reader(marked_file) for row in csvreader: self.revoke(row[0], row[1]) @@ -100,18 +107,18 @@ class Revoker(object): if os.path.isfile(Revoker.marked_path): raise errors.LetsEncryptRevokerError( "MARKED file was never cleaned.") - with open(Revoker.marked_path, "w") as marked_file: + with open(self.marked_path, "w") as marked_file: csvwriter = csv.writer(marked_file) csvwriter.writerow([cert.backup_path, cert.backup_key_path]) def _remove_mark(self): # pylint: disable=no-self-use """Remove the marked file.""" - os.remove(Revoker.marked_path) + os.remove(self.marked_path) def display_menu(self): """List trusted Let's Encrypt certificates.""" - if not os.path.isfile(Revoker.list_path): + if not os.path.isfile(self.list_path): logging.info( "You don't have any certificates saved from letsencrypt") return @@ -131,14 +138,14 @@ class Revoker(object): # pylint: disable=no-self-use """Populate a list of all the saved certs.""" certs = [] - with open(Revoker.list_path, "rb") as csvfile: + with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) # idx, orig_cert, orig_key for row in csvreader: # Generate backup key/cert names - b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, + b_k = os.path.join(self.config.cert_key_backup, os.path.basename(row[2]) + "_" + row[0]) - b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, + b_c = os.path.join(self.config.cert_key_backup, os.path.basename(row[1]) + "_" + row[0]) cert = Cert(b_c) @@ -191,7 +198,7 @@ class Revoker(object): def _remove_cert_from_list(self, cert): # pylint: disable=no-self-use """Remove a certificate from the LIST file.""" - list_path2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") with open(Revoker.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) @@ -209,12 +216,14 @@ class Revoker(object): os.remove(list_path2) @classmethod - def store_cert_key(cls, cert_path, key_path, encrypt=False): + def store_cert_key(cls, cert_path, key_path, config, encrypt=False): """Store certificate key. (Used to allow quick revocation) :param str cert_path: Path to a certificate file. :param key_path: Authorized key for certificate :type key_path: :class:`letsencrypt.client.le_util.Key` + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` :param bool encrypt: Should the certificate key be encrypted? @@ -222,7 +231,8 @@ class Revoker(object): :rtype: bool """ - le_util.make_or_verify_dir(CONFIG.CERT_KEY_BACKUP, 0o700) + list_path = (config.cert_key_backup, "LIST") + le_util.make_or_verify_dir(config.cert_key_backup, 0o700) idx = 0 if encrypt: @@ -232,23 +242,23 @@ class Revoker(object): "next update!") return False - cls._append_index_file(cert_path, key_path) + cls._append_index_file(cert_path, key_path, list_path) shutil.copy2(key_path, os.path.join( - CONFIG.CERT_KEY_BACKUP, + config.cert_key_backup, os.path.basename(key_path) + "_" + str(idx))) shutil.copy2(cert_path, os.path.join( - CONFIG.CERT_KEY_BACKUP, + config.cert_key_backup, os.path.basename(cert_path) + "_" + str(idx))) return True @classmethod - def _append_index_file(cls, cert_path, key_path): - if os.path.isfile(Revoker.list_path): - with open(Revoker.list_path, 'r+b') as csvfile: + def _append_index_file(cls, cert_path, key_path, list_path): + if os.path.isfile(list_path): + with open(list_path, 'r+b') as csvfile: csvreader = csv.reader(csvfile) # Find the highest index in the file @@ -258,7 +268,7 @@ class Revoker(object): csvwriter.writerow([str(idx), cert_path, key_path]) else: - with open(Revoker.list_path, 'wb') as csvfile: + with open(list_path, 'wb') as csvfile: csvwriter = csv.writer(csvfile) csvwriter.writerow(["0", cert_path, key_path]) diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py index 504009f02..2218de055 100644 --- a/letsencrypt/client/tests/acme_util.py +++ b/letsencrypt/client/tests/acme_util.py @@ -1,5 +1,5 @@ """Class helps construct valid ACME messages for testing.""" -from letsencrypt.client import CONFIG +from letsencrypt.client import constants CHALLENGES = { @@ -62,13 +62,13 @@ CHALLENGES = { def get_dv_challenges(): """Returns all auth challenges.""" return [chall for typ, chall in CHALLENGES.iteritems() - if typ in CONFIG.DV_CHALLENGES] + if typ in constants.DV_CHALLENGES] def get_client_challenges(): """Returns all client challenges.""" return [chall for typ, chall in CHALLENGES.iteritems() - if typ in CONFIG.CLIENT_CHALLENGES] + if typ in constants.CLIENT_CHALLENGES] def get_challenges(): @@ -83,7 +83,7 @@ def gen_combos(challs): combos = [] for i, chall in enumerate(challs): - if chall["type"] in CONFIG.DV_CHALLENGES: + if chall["type"] in constants.DV_CHALLENGES: dv_chall.append(i) else: renewal_chall.append(i) diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index eedc32f87..3e1054389 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -6,9 +6,10 @@ import shutil import mock from letsencrypt.client import challenge_util -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client import le_util + from letsencrypt.client.tests.apache import util @@ -100,7 +101,7 @@ class DvsniPerformTest(util.ApacheTest): # Check to make sure challenge config path is included in apache config. self.assertEqual( - len(self.sni.config.parser.find_dir( + len(self.sni.configurator.parser.find_dir( "Include", self.sni.challenge_conf)), 1) self.assertEqual(len(responses), 1) @@ -125,7 +126,7 @@ class DvsniPerformTest(util.ApacheTest): mock_setup_cert.call_args_list[1], mock.call(self.challs[1])) self.assertEqual( - len(self.sni.config.parser.find_dir( + len(self.sni.configurator.parser.find_dir( "Include", self.sni.challenge_conf)), 1) self.assertEqual(len(responses), 2) @@ -142,27 +143,30 @@ class DvsniPerformTest(util.ApacheTest): ll_addr.append(v_addr1) ll_addr.append(v_addr2) self.sni._mod_config(ll_addr) # pylint: disable=protected-access - self.sni.config.save() + self.sni.configurator.save() - self.sni.config.parser.find_dir("Include", self.sni.challenge_conf) - vh_match = self.sni.config.aug.match( + self.sni.configurator.parser.find_dir( + "Include", self.sni.challenge_conf) + vh_match = self.sni.configurator.aug.match( "/files" + self.sni.challenge_conf + "//VirtualHost") vhs = [] for match in vh_match: # pylint: disable=protected-access - vhs.append(self.sni.config._create_vhost(match)) + vhs.append(self.sni.configurator._create_vhost(match)) self.assertEqual(len(vhs), 2) for vhost in vhs: if vhost.addrs == set(v_addr1): self.assertEqual( vhost.names, - set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)])) + set([str(self.challs[0].nonce + + constants.DVSNI_DOMAIN_SUFFIX)])) else: self.assertEqual(vhost.addrs, set(v_addr2)) self.assertEqual( vhost.names, - set([str(self.challs[1].nonce + CONFIG.INVALID_EXT)])) + set([str(self.challs[1].nonce + + constants.DVSNI_DOMAIN_SUFFIX)])) if __name__ == '__main__': diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index 3022940f3..3f5cc36ae 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -9,7 +9,6 @@ import mock import zope.component from letsencrypt.client import errors -from letsencrypt.client.apache import parser from letsencrypt.client.display import display_util from letsencrypt.client.tests.apache import util @@ -23,15 +22,32 @@ class ApacheParserTest(util.ApacheTest): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - self.parser = parser.ApacheParser( - augeas.Augeas(flags=augeas.Augeas.NONE), - self.config_path, self.ssl_options) + from letsencrypt.client.apache.parser import ApacheParser + self.aug = augeas.Augeas(flags=augeas.Augeas.NONE) + self.parser = ApacheParser(self.aug, self.config_path, self.ssl_options) def tearDown(self): shutil.rmtree(self.temp_dir) shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + def test_root_normalized(self): + from letsencrypt.client.apache.parser import ApacheParser + path = os.path.join(self.temp_dir, "debian_apache_2_4/////" + "two_vhost_80/../two_vhost_80/apache2") + parser = ApacheParser(self.aug, path, None) + self.assertEqual(parser.root, self.config_path) + + def test_root_absolute(self): + from letsencrypt.client.apache.parser import ApacheParser + parser = ApacheParser(self.aug, os.path.relpath(self.config_path), None) + self.assertEqual(parser.root, self.config_path) + + def test_root_no_trailing_slash(self): + from letsencrypt.client.apache.parser import ApacheParser + parser = ApacheParser(self.aug, self.config_path + os.path.sep, None) + self.assertEqual(parser.root, self.config_path) + def test_parse_file(self): """Test parse_file. @@ -51,10 +67,10 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue(matches) def test_find_dir(self): - test = self.parser.find_dir(parser.case_i("Listen"), "443") + from letsencrypt.client.apache.parser import case_i + test = self.parser.find_dir(case_i("Listen"), "443") # This will only look in enabled hosts - test2 = self.parser.find_dir( - parser.case_i("documentroot")) + test2 = self.parser.find_dir(case_i("documentroot")) self.assertEqual(len(test), 2) self.assertEqual(len(test2), 3) @@ -76,8 +92,9 @@ class ApacheParserTest(util.ApacheTest): Path must be valid before attempting to add to augeas """ + from letsencrypt.client.apache.parser import get_aug_path self.parser.add_dir_to_ifmodssl( - parser.get_aug_path(self.parser.loc["default"]), + get_aug_path(self.parser.loc["default"]), "FakeDirective", "123") matches = self.parser.find_dir("FakeDirective", "123") @@ -86,8 +103,8 @@ class ApacheParserTest(util.ApacheTest): self.assertTrue("IfModule" in matches[0]) def test_get_aug_path(self): - self.assertEqual( - "/files/etc/apache", parser.get_aug_path("/etc/apache")) + from letsencrypt.client.apache.parser import get_aug_path + self.assertEqual("/files/etc/apache", get_aug_path("/etc/apache")) def test_set_locations(self): with mock.patch("letsencrypt.client.apache.parser." diff --git a/letsencrypt/client/tests/apache/util.py b/letsencrypt/client/tests/apache/util.py index fe27921b7..78566e1e4 100644 --- a/letsencrypt/client/tests/apache/util.py +++ b/letsencrypt/client/tests/apache/util.py @@ -7,7 +7,7 @@ import unittest import mock -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj @@ -22,9 +22,8 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods self.ssl_options = setup_apache_ssl_options(self.config_dir) - # Final slash is currently important self.config_path = os.path.join( - self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2") self.rsa256_file = pkg_resources.resource_filename( "letsencrypt.client.tests", 'testdata/rsa256_key.pem') @@ -50,11 +49,7 @@ def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): def setup_apache_ssl_options(config_dir): """Move the ssl_options into position and return the path.""" option_path = os.path.join(config_dir, "options-ssl.conf") - temp_options = pkg_resources.resource_filename( - "letsencrypt.client.apache", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile( - temp_options, option_path) - + shutil.copyfile(constants.APACHE_MOD_SSL_CONF, option_path) return option_path @@ -69,15 +64,15 @@ def get_apache_configurator( # This just states that the ssl module is already loaded mock_popen().communicate.return_value = ("ssl_module", "") config = configurator.ApacheConfigurator( - config_path, - { - "backup": backups, - "temp": os.path.join(work_dir, "temp_checkpoint"), - "progress": os.path.join(backups, "IN_PROGRESS"), - "config": config_dir, - "work": work_dir, - }, - ssl_options, + mock.MagicMock( + apache_server_root=config_path, + apache_mod_ssl_conf=ssl_options, + le_vhost_ext="-le-ssl.conf", + backup_dir=backups, + config_dir=config_dir, + temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), + in_progress_dir=os.path.join(backups, "IN_PROGRESS"), + work_dir=work_dir), version) return config diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 54f1f13d2..97f341b0d 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -7,7 +7,7 @@ import unittest import M2Crypto from letsencrypt.client import challenge_util -from letsencrypt.client import CONFIG +from letsencrypt.client import constants from letsencrypt.client import le_util @@ -36,11 +36,11 @@ class DvsniGenCertTest(unittest.TestCase): dns_regex = r"DNS:([^, $]*)" cert = M2Crypto.X509.load_cert_string(pem) self.assertEqual( - cert.get_subject().CN, nonce + CONFIG.INVALID_EXT) + cert.get_subject().CN, nonce + constants.DVSNI_DOMAIN_SUFFIX) sans = cert.get_ext("subjectAltName").get_value() - exp_sans = set([nonce + CONFIG.INVALID_EXT, domain, ext]) + exp_sans = set([nonce + constants.DVSNI_DOMAIN_SUFFIX, domain, ext]) act_sans = set(re.findall(dns_regex, sans)) self.assertEqual(exp_sans, act_sans) diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py index 9c9f4d89f..b2eff7d28 100644 --- a/letsencrypt/client/tests/client_authenticator_test.py +++ b/letsencrypt/client/tests/client_authenticator_test.py @@ -6,10 +6,12 @@ import mock class PerformTest(unittest.TestCase): """Test client perform function.""" + def setUp(self): from letsencrypt.client.client_authenticator import ClientAuthenticator - self.auth = ClientAuthenticator("demo_server.org") + self.auth = ClientAuthenticator( + mock.MagicMock(server="demo_server.org")) self.auth.rec_token.perform = mock.MagicMock( name="rec_token_perform", side_effect=gen_client_resp) @@ -45,10 +47,12 @@ class PerformTest(unittest.TestCase): class CleanupTest(unittest.TestCase): """Test the Authenticator cleanup function.""" + def setUp(self): from letsencrypt.client.client_authenticator import ClientAuthenticator - self.auth = ClientAuthenticator("demo_server.org") + self.auth = ClientAuthenticator( + mock.MagicMock(server="demo_server.org")) self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") self.auth.rec_token.cleanup = self.mock_cleanup diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 497bb8be0..c490df770 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -14,7 +14,7 @@ class RollbackTest(unittest.TestCase): @classmethod def _call(cls, checkpoints): from letsencrypt.client.client import rollback - rollback(checkpoints) + rollback(checkpoints, mock.MagicMock()) @mock.patch("letsencrypt.client.client.determine_installer") def test_no_problems(self, mock_det): diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py new file mode 100644 index 000000000..a07953396 --- /dev/null +++ b/letsencrypt/client/tests/configuration_test.py @@ -0,0 +1,32 @@ +"""Tests for letsencrypt.client.configuration.""" +import unittest + +import mock + + +class NamespaceConfigTest(unittest.TestCase): + """Tests for letsencrypt.client.configuration.NamespaceConfig.""" + + def setUp(self): + from letsencrypt.client.configuration import NamespaceConfig + namespace = mock.MagicMock(work_dir='/tmp/foo', foo='bar') + self.config = NamespaceConfig(namespace) + + def test_proxy_getattr(self): + self.assertEqual(self.config.foo, 'bar') + self.assertEqual(self.config.work_dir, '/tmp/foo') + + @mock.patch('letsencrypt.client.configuration.constants') + def test_dynamic_dirs(self, constants): + constants.TEMP_CHECKPOINT_DIR = 't' + constants.IN_PROGRESS_DIR = '../p' + constants.CERT_KEY_BACKUP_DIR = 'c/' + constants.REC_TOKEN_DIR = '/r' + self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') + self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') + self.assertEqual(self.config.cert_key_backup, '/tmp/foo/c/') + self.assertEqual(self.config.rec_token_dir, '/r') + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 028d037bd..0e3346924 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -19,15 +19,14 @@ class ReverterCheckpointLocalTest(unittest.TestCase): # Disable spurious errors... we are trying to test for them logging.disable(logging.CRITICAL) - self.work_dir, self.direc = setup_work_direc() - - self.reverter = Reverter(self.direc) + self.config = setup_work_direc() + self.reverter = Reverter(self.config) tup = setup_test_files() self.config1, self.config2, self.dir1, self.dir2, self.sets = tup def tearDown(self): - shutil.rmtree(self.work_dir) + shutil.rmtree(self.config.work_dir) shutil.rmtree(self.dir1) shutil.rmtree(self.dir2) @@ -38,13 +37,14 @@ class ReverterCheckpointLocalTest(unittest.TestCase): self.reverter.add_to_temp_checkpoint(self.sets[0], "save1") self.reverter.add_to_temp_checkpoint(self.sets[1], "save2") - self.assertTrue(os.path.isdir(self.reverter.direc['temp'])) - self.assertEqual(get_save_notes(self.direc['temp']), "save1save2") + self.assertTrue(os.path.isdir(self.config.temp_checkpoint_dir)) + self.assertEqual(get_save_notes( + self.config.temp_checkpoint_dir), "save1save2") self.assertFalse(os.path.isfile( - os.path.join(self.direc['temp'], "NEW_FILES"))) + os.path.join(self.config.temp_checkpoint_dir, "NEW_FILES"))) self.assertEqual( - get_filepaths(self.direc['temp']), + get_filepaths(self.config.temp_checkpoint_dir), "{0}\n{1}\n".format(self.config1, self.config2)) def test_add_to_checkpoint_copy_failure(self): @@ -113,7 +113,7 @@ class ReverterCheckpointLocalTest(unittest.TestCase): self.reverter.register_file_creation(True, self.config1) self.reverter.register_file_creation(True, self.config1) - files = get_new_files(self.direc['temp']) + files = get_new_files(self.config.temp_checkpoint_dir) self.assertEqual(len(files), 1) @@ -240,14 +240,14 @@ class TestFullCheckpointsReverter(unittest.TestCase): # Disable spurious errors... logging.disable(logging.CRITICAL) - self.work_dir, self.direc = setup_work_direc() - self.reverter = Reverter(self.direc) + self.config = setup_work_direc() + self.reverter = Reverter(self.config) tup = setup_test_files() self.config1, self.config2, self.dir1, self.dir2, self.sets = tup def tearDown(self): - shutil.rmtree(self.work_dir) + shutil.rmtree(self.config.work_dir) shutil.rmtree(self.dir1) shutil.rmtree(self.dir2) @@ -269,7 +269,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): config3 = self._setup_three_checkpoints() # Check resulting backup directory - self.assertEqual(len(os.listdir(self.direc['backup'])), 3) + self.assertEqual(len(os.listdir(self.config.backup_dir)), 3) # Check rollbacks # First rollback self.reverter.rollback_checkpoints(1) @@ -285,11 +285,11 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.assertFalse(os.path.isfile(config3)) # One dir left... check title - all_dirs = os.listdir(self.direc['backup']) + all_dirs = os.listdir(self.config.backup_dir) self.assertEqual(len(all_dirs), 1) self.assertTrue( "First Checkpoint" in get_save_notes( - os.path.join(self.direc['backup'], all_dirs[0]))) + os.path.join(self.config.backup_dir, all_dirs[0]))) # Final rollback self.reverter.rollback_checkpoints(1) self.assertEqual(read_in(self.config1), "directive-dir1") @@ -349,7 +349,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): def test_view_config_changes_bad_backups_dir(self): # There shouldn't be any "in progess directories when this is called # It must just be clean checkpoints - os.makedirs(os.path.join(self.direc['backup'], "in_progress")) + os.makedirs(os.path.join(self.config.backup_dir, "in_progress")) self.assertRaises(errors.LetsEncryptReverterError, self.reverter.view_config_changes) @@ -383,29 +383,20 @@ class TestFullCheckpointsReverter(unittest.TestCase): return config3 -class QuickInitReverterTest(unittest.TestCase): - # pylint: disable=too-few-public-methods - """Quick test of init.""" - def test_init(self): - from letsencrypt.client.reverter import Reverter - rev = Reverter() - - # Verify direc is set - self.assertTrue(rev.direc['backup']) - self.assertTrue(rev.direc['temp']) - self.assertTrue(rev.direc['progress']) - - def setup_work_direc(): - """Setup directories.""" - work_dir = tempfile.mkdtemp("work") - backup = os.path.join(work_dir, "backup") - os.makedirs(backup) - direc = {'backup': backup, - 'temp': os.path.join(work_dir, "temp"), - 'progress': os.path.join(backup, "progress")} + """Setup directories. - return work_dir, direc + :returns: Mocked :class:`letsencrypt.client.interfaces.IConfig` + + """ + work_dir = tempfile.mkdtemp("work") + backup_dir = os.path.join(work_dir, "backup") + os.makedirs(backup_dir) + + return mock.MagicMock( + work_dir=work_dir, backup_dir=backup_dir, + temp_checkpoint_dir=os.path.join(work_dir, "temp"), + in_progress_dir=os.path.join(backup_dir, "in_progress_dir")) def setup_test_files(): diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 24892f6a8..78ea122cd 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -9,11 +9,12 @@ import logging import os import sys +import confargparse import zope.component -import zope.interface import letsencrypt -from letsencrypt.client import CONFIG + +from letsencrypt.client import configuration from letsencrypt.client import client from letsencrypt.client import errors from letsencrypt.client import interfaces @@ -23,49 +24,74 @@ from letsencrypt.client.display import display_util from letsencrypt.client.display import ops -def main(): # pylint: disable=too-many-statements,too-many-branches - """Command line argument parsing and main script execution.""" - parser = argparse.ArgumentParser( +def create_parser(): + """Create parser.""" + parser = confargparse.ConfArgParser( description="letsencrypt client %s" % letsencrypt.__version__) - parser.add_argument("-d", "--domains", dest="domains", metavar="DOMAIN", - nargs="+") - parser.add_argument("-s", "--server", dest="server", - default=CONFIG.ACME_SERVER, - help="The ACME CA server. [%(default)s]") - parser.add_argument("-p", "--privkey", dest="privkey", type=read_file, - help="Path to the private key file for certificate " - "generation.") - parser.add_argument("-b", "--rollback", dest="rollback", type=int, - default=0, metavar="N", - help="Revert configuration N number of checkpoints.") - parser.add_argument("-B", "--keysize", dest="key_size", type=int, - default=CONFIG.RSA_KEY_SIZE, metavar="N", - help="RSA key shall be sized N bits. [%(default)d]") - parser.add_argument("-k", "--revoke", dest="revoke", action="store_true", - help="Revoke a certificate.") - parser.add_argument("-v", "--view-config-changes", - dest="view_config_changes", - action="store_true", - help="View checkpoints and associated configuration " - "changes.") - parser.add_argument("-r", "--redirect", dest="redirect", - action="store_const", const=True, - help="Automatically redirect all HTTP traffic to HTTPS " - "for the newly authenticated vhost.") - parser.add_argument("-n", "--no-redirect", dest="redirect", - action="store_const", const=False, - help="Skip the HTTPS redirect question, allowing both " - "HTTP and HTTPS.") - parser.add_argument("-e", "--agree-tos", dest="eula", action="store_true", - help="Skip the end user license agreement screen.") - parser.add_argument("-t", "--text", dest="use_curses", action="store_false", - help="Use the text output instead of the curses UI.") - parser.add_argument("--test", dest="test", action="store_true", - help="Run in test mode.") + add = parser.add_argument + config_help = lambda name: interfaces.IConfig[name].__doc__ + add("-d", "--domains", metavar="DOMAIN", nargs="+") + add("-s", "--server", default="letsencrypt-demo.org:443", + help=config_help("server")) + + add("-p", "--privkey", type=read_file, + help="Path to the private key file for certificate generation.") + add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", + help=config_help("rsa_key_size")) + + add("-k", "--revoke", action="store_true", help="Revoke a certificate.") + add("-b", "--rollback", type=int, default=0, metavar="N", + help="Revert configuration N number of checkpoints.") + add("-v", "--view-config-changes", action="store_true", + help="View checkpoints and associated configuration changes.") + + # TODO: resolve - assumes binary logic while client.py assumes ternary. + add("-r", "--redirect", action="store_true", + help="Automatically redirect all HTTP traffic to HTTPS for the newly " + "authenticated vhost.") + + add("-e", "--agree-tos", dest="eula", action="store_true", + help="Skip the end user license agreement screen.") + add("-t", "--text", dest="use_curses", action="store_false", + help="Use the text output instead of the curses UI.") + + add("--config-dir", default="/etc/letsencrypt", + help=config_help("config_dir")) + add("--work-dir", default="/var/lib/letsencrypt", + help=config_help("work_dir")) + add("--backup-dir", default="/var/lib/letsencrypt/backups", + help=config_help("backup_dir")) + add("--key-dir", default="/etc/letsencrypt/keys", + help=config_help("key_dir")) + add("--cert-dir", default="/etc/letsencrypt/certs", + help=config_help("cert_dir")) + + add("--le-vhost-ext", default="-le-ssl.conf", + help=config_help("le_vhost_ext")) + add("--cert-path", default="/etc/letsencrypt/certs/cert-letsencrypt.pem", + help=config_help("cert_path")) + add("--chain-path", default="/etc/letsencrypt/certs/chain-letsencrypt.pem", + help=config_help("chain_path")) + + add("--apache-server-root", default="/etc/apache2", + help=config_help("apache_server_root")) + add("--apache-mod-ssl-conf", default="/etc/letsencrypt/options-ssl.conf", + help=config_help("apache_mod_ssl_conf")) + add("--apache-ctl", default="apache2ctl", help=config_help("apache_ctl")) + add("--apache-enmod", default="a2enmod", help=config_help("apache_enmod")) + add("--apache-init-script", default="/etc/init.d/apache2", + help=config_help("apache_init_script")) + + return parser + + +def main(): # pylint: disable=too-many-branches + """Command line argument parsing and main script execution.""" # note: arg parser internally handles --help (and exits afterwards) - args = parser.parse_args() + args = create_parser().parse_args() + config = configuration.NamespaceConfig(args) # note: check is done after arg parsing as --help should work w/o root also. if not os.geteuid() == 0: @@ -84,15 +110,15 @@ def main(): # pylint: disable=too-many-statements,too-many-branches zope.component.provideUtility(displayer) if args.view_config_changes: - client.view_config_changes() + client.view_config_changes(config) sys.exit() if args.revoke: - client.revoke(args.server) + client.revoke(config) sys.exit() if args.rollback > 0: - client.rollback(args.rollback) + client.rollback(args.rollback, config) sys.exit() if not args.eula: @@ -118,11 +144,11 @@ def main(): # pylint: disable=too-many-statements,too-many-branches # Prepare for init of Client if args.privkey is None: - privkey = client.init_key(args.key_size) + privkey = client.init_key(args.rsa_key_size, config.key_dir) else: privkey = le_util.Key(args.privkey[0], args.privkey[1]) - acme = client.Client(args.server, privkey, auth, installer) + acme = client.Client(config, privkey, auth, installer) # Validate the key and csr client.validate_key_csr(privkey) diff --git a/setup.py b/setup.py index 634437558..51c3a1c27 100755 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', + 'ConfArgParse', 'jsonschema', 'mock', 'pycrypto',