diff --git a/.gitignore b/.gitignore index 51164db97..2e0578223 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ m3 *~ .vagrant *.swp -\#*# +\#*# \ No newline at end of file diff --git a/README.rst b/README.rst index fac36dbd7..4f170f11b 100644 --- a/README.rst +++ b/README.rst @@ -17,11 +17,15 @@ It's all automated: * If domain control has been proven, a certificate will get issued and the tool will automatically install it. -All you need to do is: +All you need to do is:: -:: + user@www:~$ sudo letsencrypt -d www.example.org auth - user@www:~$ sudo letsencrypt -d www.example.org +and if you have a compatbile web server (Apache), Let's Encrypt can +not only get a new certificate, but also deploy it and configure your +server automatically!:: + + user@www:~$ sudo letsencrypt -d www.example.org run **Encrypt ALL the things!** diff --git a/Vagrantfile b/Vagrantfile index b4a06ea05..1d3b48f06 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -10,7 +10,7 @@ cd /vagrant sudo ./bootstrap/ubuntu.sh if [ ! -d "venv" ]; then virtualenv --no-site-packages -p python2 venv - ./venv/bin/python setup.py dev + ./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing] fi SETUP_SCRIPT diff --git a/docs/api/client/plugins/common.rst b/docs/api/client/plugins/common.rst new file mode 100644 index 000000000..9ee3e6b3e --- /dev/null +++ b/docs/api/client/plugins/common.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.plugins.common` +---------------------------------------- + +.. automodule:: letsencrypt.client.plugins.common + :members: diff --git a/docs/api/client/plugins/disco.rst b/docs/api/client/plugins/disco.rst new file mode 100644 index 000000000..2b877b654 --- /dev/null +++ b/docs/api/client/plugins/disco.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.plugins.disco` +--------------------------------------- + +.. automodule:: letsencrypt.client.plugins.disco + :members: diff --git a/docs/contributing.rst b/docs/contributing.rst index 0ed022724..d5088705b 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -15,7 +15,7 @@ Now you can install the development packages: .. code-block:: shell - ./venv/bin/python setup.py dev + ./venv/bin/pip install -r requirements.txt -e .[dev,docs,testing] The code base, including your pull requests, **must** have 100% test statement coverage **and** be compliant with the :ref:`coding style @@ -48,7 +48,7 @@ synced to ``/vagrant``, so you can get started with: vagrant ssh cd /vagrant - ./venv/bin/python setup.py install + ./venv/bin/pip install -r requirements.txt sudo ./venv/bin/letsencrypt Support for other Linux distributions coming soon. diff --git a/docs/using.rst b/docs/using.rst index 3a7940993..daa2425ea 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -53,8 +53,7 @@ Installation .. code-block:: shell virtualenv --no-site-packages -p python2 venv - ./venv/bin/python setup.py install - sudo ./venv/bin/letsencrypt + ./venv/bin/pip install -r requirements.txt Usage diff --git a/examples/plugins/letsencrypt_example_plugins.py b/examples/plugins/letsencrypt_example_plugins.py index 987a2b33b..7c6d1311c 100644 --- a/examples/plugins/letsencrypt_example_plugins.py +++ b/examples/plugins/letsencrypt_example_plugins.py @@ -1,18 +1,31 @@ -"""Example Let's Encrypt plugins.""" +"""Example Let's Encrypt plugins. + +For full examples, see `letsencrypt.client.plugins`. + +""" import zope.interface from letsencrypt.client import interfaces +from letsencrypt.client.plugins import common -class Authenticator(object): +class Authenticator(common.Plugin): + """Example Authenticator.""" zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) - description = 'Example Authenticator plugin' - - def __init__(self, config): - self.config = config + description = "Example Authenticator plugin" # Implement all methods from IAuthenticator, remembering to add # "self" as first argument, e.g. def prepare(self)... - # For full examples, see letsencrypt.client.plugins + +class Installer(common.Plugins): + """Example Installer.""" + zope.interface.implements(interfaces.IInstaller) + zope.interface.classProvides(interfaces.IPluginFactory) + + description = "Example Installer plugin" + + # Implement all methods from IInstaller, remembering to add + # "self" as first argument, e.g. def get_all_names(self)... diff --git a/examples/plugins/setup.py b/examples/plugins/setup.py index 845d6eb66..71bb95333 100644 --- a/examples/plugins/setup.py +++ b/examples/plugins/setup.py @@ -9,8 +9,9 @@ setup( 'zope.interface', ], entry_points={ - 'letsencrypt.authenticators': [ - 'example = letsencrypt_example_plugins:Authenticator', + 'letsencrypt.plugins': [ + 'example_authenticator = letsencrypt_example_plugins:Authenticator', + 'example_installer = letsencrypt_example_plugins:Installer', ], }, ) diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 8854fef09..713d49291 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -4,9 +4,10 @@ import logging import augeas from letsencrypt.client import reverter +from letsencrypt.client.plugins import common -class AugeasConfigurator(object): +class AugeasConfigurator(common.Plugin): """Base Augeas Configurator class. :ivar config: Configuration. @@ -21,8 +22,8 @@ class AugeasConfigurator(object): """ - def __init__(self, config): - self.config = config + def __init__(self, *args, **kwargs): + super(AugeasConfigurator, self).__init__(*args, **kwargs) # Set Augeas flags to not save backup (we do it ourselves) # Set Augeas to not load anything by default @@ -34,7 +35,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(config) + self.reverter = reverter.Reverter(self.config) self.reverter.recovery_routine() def check_parsing_errors(self, lens): diff --git a/letsencrypt/client/cli.py b/letsencrypt/client/cli.py new file mode 100644 index 000000000..a83c0f767 --- /dev/null +++ b/letsencrypt/client/cli.py @@ -0,0 +1,373 @@ +"""Let's Encrypt CLI.""" +# TODO: Sanity check all input. Be sure to avoid shell code etc... +import argparse +import logging +import os +import sys + +import configargparse +import zope.component +import zope.interface.exceptions +import zope.interface.verify + +import letsencrypt + +from letsencrypt.client import account +from letsencrypt.client import configuration +from letsencrypt.client import constants +from letsencrypt.client import client +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util +from letsencrypt.client import log + +from letsencrypt.client.display import util as display_util +from letsencrypt.client.display import ops as display_ops + +from letsencrypt.client.plugins import disco as plugins_disco + + +def _account_init(args, config): + le_util.make_or_verify_dir( + config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) + + # Prepare for init of Client + if args.email is None: + return client.determine_account(config) + else: + try: + # The way to get the default would be args.email = "" + # First try existing account + return account.Account.from_existing_account(config, args.email) + except errors.LetsEncryptClientError: + try: + # Try to make an account based on the email address + return account.Account.from_email(config, args.email) + except errors.LetsEncryptClientError: + return None + + +def _common_run(args, config, acc, authenticator, installer): + if args.domains is None: + doms = display_ops.choose_names(installer) + else: + doms = args.domains + + if not doms: + sys.exit("Please specify --domains, or --installer that will " + "help in domain names autodiscovery") + + acme = client.Client(config, acc, authenticator, installer) + + # Validate the key and csr + client.validate_key_csr(acc.key) + + if authenticator is not None: + if acc.regr is None: + try: + acme.register() + except errors.LetsEncryptClientError: + sys.exit("Unable to register an account with ACME server") + + return acme, doms + + +def run(args, config, plugins): + """Obtain a certificate and install.""" + acc = _account_init(args, config) + if acc is None: + return None + + if args.configurator is not None and (args.installer is not None or + args.authenticator is not None): + return ("Either --configurator or --authenticator/--installer" + "pair, but not both, is allowed") + + if args.authenticator is not None or args.installer is not None: + installer = display_ops.pick_installer( + config, args.installer, plugins) + authenticator = display_ops.pick_authenticator( + config, args.authenticator, plugins) + else: + # TODO: this assume that user doesn't want to pick authenticator + # and installer separately... + authenticator = installer = display_ops.pick_configurator( + config, args.configurator, plugins) + + if installer is None or authenticator is None: + return "Configurator could not be determined" + + acme, doms = _common_run(args, config, acc, authenticator, installer) + cert_key, cert_path, chain_path = acme.obtain_certificate(doms) + acme.deploy_certificate(doms, cert_key, cert_path, chain_path) + acme.enhance_config(doms, args.redirect) + + +def auth(args, config, plugins): + """Obtain a certificate (no install).""" + acc = _account_init(args, config) + if acc is None: + return None + + authenticator = display_ops.pick_authenticator( + config, args.authenticator, plugins) + if authenticator is None: + return "Authenticator could not be determined" + + if args.installer is not None: + installer = display_ops.pick_installer(config, args.installer, plugins) + else: + installer = None + + acme, doms = _common_run( + args, config, acc, authenticator=authenticator, installer=installer) + acme.obtain_certificate(doms) + + +def install(args, config, plugins): + """Install (no auth).""" + acc = _account_init(args, config) + if acc is None: + return None + + 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) + assert args.cert_path is not None + acme.deploy_certificate(doms, acc.key, args.cert_path, args.chain_path) + acme.enhance_config(doms, args.redirect) + + +def revoke(args, unused_config, unused_plugins): + """Revoke.""" + if args.rev_cert is None and args.rev_key is None: + return "At least one of --certificate or --key is required" + + # This depends on the renewal config and cannot be completed yet. + zope.component.getUtility(interfaces.IDisplay).notification( + "Revocation is not available with the new Boulder server yet.") + #client.revoke(args.installer, config, plugins, args.no_confirm, + # args.rev_cert, args.rev_key) + + +def rollback(args, config, plugins): + """Rollback.""" + client.rollback(args.installer, args.checkpoints, config, plugins) + + +def config_changes(unused_args, config, unused_plugins): + """View config changes. + + View checkpoints and associated configuration changes. + + """ + client.view_config_changes(config) + + +def plugins_cmd(args, config, plugins): # TODO: Use IDiplay rathern than print + """List plugins.""" + logging.debug("Expected interfaces: %s", args.ifaces) + + ifaces = [] if args.ifaces is None else args.ifaces + filtered = plugins.ifaces(ifaces) + logging.debug("Filtered plugins: %r", filtered) + + if not args.init and not args.prepare: + print str(filtered) + return + + filtered.init(config) + verified = filtered.verify(ifaces) + logging.debug("Verified plugins: %r", verified) + + if not args.prepare: + print str(verified) + return + + verified.prepare() + available = verified.available() + logging.debug("Prepared plugins: %s", available) + print str(available) + + +def read_file(filename): + """Returns the given file's contents with universal new line support. + + :param str filename: Filename + + :returns: A tuple of filename and its contents + :rtype: tuple + + :raises argparse.ArgumentTypeError: File does not exist or is not readable. + + """ + try: + return filename, open(filename, "rU").read() + except IOError as exc: + raise argparse.ArgumentTypeError(exc.strerror) + + +def flag_default(name): + """Default value for CLI flag.""" + return constants.CLI_DEFAULTS[name] + +def config_help(name): + """Help message for `.IConfig` attribute.""" + return interfaces.IConfig[name].__doc__ + + +def create_parser(plugins): + """Create parser.""" + parser = configargparse.ArgParser( + description=__doc__, + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + args_for_setting_config_path=["-c", "--config"], + default_config_files=flag_default("config_files")) + add = parser.add_argument + + # --help is automatically provided by argparse + add("--version", action="version", version="%(prog)s {0}".format( + letsencrypt.__version__)) + add("-v", "--verbose", dest="verbose_count", action="count", + default=flag_default("verbose_count")) + add("--no-confirm", dest="no_confirm", action="store_true", + help="Turn off confirmation screens, currently used for --revoke") + add("-e", "--agree-tos", dest="tos", action="store_true", + help="Skip the end user license agreement screen.") + add("-t", "--text", dest="text_mode", action="store_true", + help="Use the text output instead of the curses UI.") + + subparsers = parser.add_subparsers(metavar="SUBCOMMAND") + def add_subparser(name, func): # pylint: disable=missing-docstring + subparser = subparsers.add_parser( + name, help=func.__doc__.splitlines()[0], description=func.__doc__) + subparser.set_defaults(func=func) + return subparser + + add_subparser("run", run) + 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_plugins = add_subparser("plugins", plugins_cmd) + parser_plugins.add_argument("--init", action="store_true") + parser_plugins.add_argument("--prepare", action="store_true") + parser_plugins.add_argument( + "--authenticators", action="append_const", dest="ifaces", + const=interfaces.IAuthenticator) + parser_plugins.add_argument( + "--installers", action="append_const", dest="ifaces", + const=interfaces.IInstaller) + + parser.add_argument("--configurator") + parser.add_argument("-a", "--authenticator") + parser.add_argument("-i", "--installer") + + # positional arg shadows --domains, instead of appending, and + # --domains is useful, because it can be stored in config + #for subparser in parser_run, parser_auth, parser_install: + # subparser.add_argument("domains", nargs="*", metavar="domain") + + add("-d", "--domains", metavar="DOMAIN", action="append") + add("-s", "--server", default=flag_default("server"), + help=config_help("server")) + add("-k", "--authkey", type=read_file, + help="Path to the authorized key file") + add("-m", "--email", help=config_help("email")) + add("-B", "--rsa-key-size", type=int, metavar="N", + default=flag_default("rsa_key_size"), help=config_help("rsa_key_size")) + # 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.") + + parser_revoke.add_argument( + "--certificate", dest="rev_cert", type=read_file, metavar="CERT_PATH", + help="Revoke a specific certificate.") + parser_revoke.add_argument( + "--key", dest="rev_key", type=read_file, metavar="KEY_PATH", + help="Revoke all certs generated by the provided authorized key.") + + parser_rollback.add_argument( + "--checkpoints", type=int, metavar="N", + default=flag_default("rollback_checkpoints"), + help="Revert configuration N number of checkpoints.") + + _paths_parser(parser.add_argument_group("paths")) + + # TODO: plugin_parser should be called for every detected plugin + for name, plugin_ep in plugins.iteritems(): + plugin_ep.plugin_cls.inject_parser_options( + parser.add_argument_group( + name, description=plugin_ep.description), name) + + return parser + + +def _paths_parser(parser): + add = parser.add_argument + add("--config-dir", default=flag_default("config_dir"), + help=config_help("config_dir")) + add("--work-dir", default=flag_default("work_dir"), + help=config_help("work_dir")) + add("--backup-dir", default=flag_default("backup_dir"), + help=config_help("backup_dir")) + add("--key-dir", default=flag_default("key_dir"), + help=config_help("key_dir")) + add("--cert-dir", default=flag_default("certs_dir"), + help=config_help("cert_dir")) + + add("--le-vhost-ext", default="-le-ssl.conf", + help=config_help("le_vhost_ext")) + add("--cert-path", default=flag_default("cert_path"), + help=config_help("cert_path")) + add("--chain-path", default=flag_default("chain_path"), + help=config_help("chain_path")) + + return parser + + +def main(args=sys.argv[1:]): + """Command line argument parsing and main script execution.""" + # note: arg parser internally handles --help (and exits afterwards) + plugins = plugins_disco.PluginsRegistry.find_all() + args = create_parser(plugins).parse_args(args) + config = configuration.NamespaceConfig(args) + + # Displayer + if args.text_mode: + displayer = display_util.FileDisplay(sys.stdout) + else: + displayer = display_util.NcursesDisplay() + zope.component.provideUtility(displayer) + + # Logging + level = -args.verbose_count * 10 + logger = logging.getLogger() + logger.setLevel(level) + logging.debug("Logging level set at %d", level) + if not args.text_mode: + logger.addHandler(log.DialogHandler()) + + logging.debug("Discovered plugins: %r", plugins) + + if not os.geteuid() == 0: + logging.warning( + "Root (sudo) is required to run most of letsencrypt functionality.") + # check must be done after arg parsing as --help should work + # w/o root; on the other hand, e.g. "letsencrypt run + # --authenticator dns" or "letsencrypt plugins" does not + # require root as well + #return ( + # "{0}Root is required to run letsencrypt. Please use sudo.{0}" + # .format(os.linesep)) + + return args.func(args, config, plugins) + + +if __name__ == "__main__": + sys.exit(main()) # pragma: no cover diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index ba3e0299b..a764a178c 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -20,7 +20,6 @@ from letsencrypt.client import network2 from letsencrypt.client import reverter from letsencrypt.client import revoker -from letsencrypt.client.plugins.apache import configurator from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -154,7 +153,7 @@ class Client(object): :param str cert_path: Path to attempt to save the cert file :param str chain_path: Path to attempt to save the chain file - :returns: cert_file, chain_file (absolute paths to the actual files) + :returns: cert_path, chain_path (absolute paths to the actual files) :rtype: `tuple` of `str` :raises IOError: If unable to find room to write the cert files @@ -191,7 +190,7 @@ class Client(object): return os.path.abspath(act_cert_path), cert_chain_abspath - def deploy_certificate(self, domains, privkey, cert_file, chain_file=None): + def deploy_certificate(self, domains, privkey, cert_path, chain_path=None): """Install certificate :param list domains: list of domains to install the certificate @@ -199,8 +198,8 @@ class Client(object): :param privkey: private key for certificate :type privkey: :class:`letsencrypt.client.le_util.Key` - :param str cert_file: certificate file path - :param str chain_file: chain file path + :param str cert_path: certificate file path + :param str chain_path: chain file path """ if self.installer is None: @@ -208,13 +207,12 @@ class Client(object): "the certificate") raise errors.LetsEncryptClientError("No installer available") - chain = None if chain_file is None else os.path.abspath(chain_file) + chain_path = None if chain_path is None else os.path.abspath(chain_path) for dom in domains: - self.installer.deploy_cert(dom, - os.path.abspath(cert_file), - os.path.abspath(privkey.file), - chain) + self.installer.deploy_cert( + dom, os.path.abspath(cert_path), + os.path.abspath(privkey.file), chain_path) self.installer.save("Deployed Let's Encrypt Certificate") # sites may have been enabled / final cleanup @@ -313,74 +311,6 @@ def validate_key_csr(privkey, csr=None): "The key and CSR do not match") -def list_available_authenticators(avail_auths): - """Return a pretty-printed list of authenticators. - - This is used to provide helpful feedback in the case where a user - specifies an invalid authenticator on the command line. - - """ - output_lines = ["Available authenticators:"] - for auth_name, auth in avail_auths.iteritems(): - output_lines.append(" - %s : %s" % (auth_name, auth.description)) - return '\n'.join(output_lines) - - -# This should be controlled by commandline parameters -def determine_authenticator(all_auths, config): - """Returns a valid IAuthenticator. - - :param list all_auths: Where each is a - :class:`letsencrypt.client.interfaces.IAuthenticator` object - - :param config: Used if an authenticator was specified on the command line. - :type config: :class:`letsencrypt.client.interfaces.IConfig` - - :returns: Valid Authenticator object or None - - :raises letsencrypt.client.errors.LetsEncryptClientError: If no - authenticator is available. - - """ - # Available Authenticator objects - avail_auths = {} - # Error messages for misconfigured authenticators - errs = {} - - for auth_name, auth in all_auths.iteritems(): - try: - auth.prepare() - except errors.LetsEncryptMisconfigurationError as err: - errs[auth] = err - except errors.LetsEncryptNoInstallationError: - continue - avail_auths[auth_name] = auth - - # If an authenticator was specified on the command line, try to use it - if config.authenticator: - try: - auth = avail_auths[config.authenticator] - except KeyError: - logging.info(list_available_authenticators(avail_auths)) - raise errors.LetsEncryptClientError( - "The specified authenticator '%s' could not be found" % - config.authenticator) - elif len(avail_auths) > 1: - auth = display_ops.choose_authenticator(avail_auths.values(), errs) - elif len(avail_auths.keys()) == 1: - auth = avail_auths[avail_auths.keys()[0]] - else: - raise errors.LetsEncryptClientError("No Authenticators available.") - - if auth is not None and auth in errs: - logging.error("Please fix the configuration for the Authenticator. " - "The following error message was received: " - "%s", errs[auth]) - return - - return auth - - def determine_account(config): """Determine which account to use. @@ -403,29 +333,7 @@ def determine_account(config): return account.Account.from_prompts(config) -def determine_installer(config): - """Returns a valid installer if one exists. - - :param config: Configuration. - :type config: :class:`letsencrypt.client.interfaces.IConfig` - - :returns: IInstaller or `None` - :rtype: :class:`~letsencrypt.client.interfaces.IInstaller` or `None` - - """ - installer = configurator.ApacheConfigurator(config) - try: - installer.prepare() - return installer - except errors.LetsEncryptNoInstallationError: - logging.info("Unable to find a way to install the certificate.") - return - except errors.LetsEncryptMisconfigurationError: - # This will have to be changed in the future... - return installer - - -def rollback(checkpoints, config): +def rollback(default_installer, checkpoints, config, plugins): """Revert configuration the specified number of checkpoints. :param int checkpoints: Number of checkpoints to revert. @@ -435,7 +343,9 @@ def rollback(checkpoints, config): """ # Misconfigurations are only a slight problems... allow the user to rollback - installer = determine_installer(config) + installer = display_ops.pick_installer( + config, default_installer, plugins, question="Which installer " + "should be used for rollback?") # No Errors occurred during init... proceed normally # If installer is None... couldn't find an installer... there shouldn't be @@ -445,18 +355,16 @@ def rollback(checkpoints, config): installer.restart() -def revoke(config, no_confirm, cert, authkey): +def revoke(default_installer, config, plugins, no_confirm, cert, authkey): """Revoke certificates. :param config: Configuration. :type config: :class:`letsencrypt.client.interfaces.IConfig` """ - # Misconfigurations don't really matter. Determine installer better choose - # correctly though. - # This will need some better prepared or properly configured parameter... - # I will figure it out later... - installer = determine_installer(config) + installer = display_ops.pick_installer( + config, default_installer, plugins, question="Which installer " + "should be used for certificate revocation?") revoc = revoker.Revoker(installer, config, no_confirm) # Cert is most selective, so it is chosen first. diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index d7cf1bae9..3f8cf4f05 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -1,9 +1,30 @@ """Let's Encrypt constants.""" -import pkg_resources +import logging from letsencrypt.acme import challenges +SETUPTOOLS_PLUGINS_ENTRY_POINT = "letsencrypt.plugins" +"""Setuptools entry point group name for plugins.""" + + +CLI_DEFAULTS = dict( + config_files=["/etc/letsencrypt/cli.ini"], + verbose_count=-(logging.WARNING / 10), + server="www.letsencrypt-demo.org/acme/new-reg", + rsa_key_size=2048, + rollback_checkpoints=0, + config_dir="/etc/letsencrypt", + work_dir="/var/lib/letsencrypt", + backup_dir="/var/lib/letsencrypt/backups", + key_dir="/etc/letsencrypt/keys", + certs_dir="/etc/letsencrypt/certs", + cert_path="/etc/letsencrypt/certs/cert-letsencrypt.pem", + chain_path="/etc/letsencrypt/certs/chain-letsencrypt.pem", +) +"""Defaults for CLI flags and `.IConfig` attributes.""" + + EXCLUSIVE_CHALLENGES = frozenset([frozenset([ challenges.DVSNI, challenges.SimpleHTTPS])]) """Mutually exclusive challenges.""" @@ -22,22 +43,6 @@ List of expected options parameters: """ -APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( - "letsencrypt.client.plugins.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""" - - -NGINX_MOD_SSL_CONF = pkg_resources.resource_filename( - "letsencrypt.client.plugins.nginx", "options-ssl.conf") -"""Path to the Nginx mod_ssl config file found in the Let's Encrypt -distribution.""" - - CONFIG_DIRS_MODE = 0o755 """Directory mode for ``.IConfig.config_dir`` et al.""" diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index d396e1641..706e8bd7c 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -1,4 +1,5 @@ """Contains UI methods for LE user operations.""" +import logging import os import zope.component @@ -6,41 +7,107 @@ import zope.component from letsencrypt.client import interfaces from letsencrypt.client.display import util as display_util + # Define a helper function to avoid verbose code util = zope.component.getUtility # pylint: disable=invalid-name -def choose_authenticator(auths, errs): - """Allow the user to choose their authenticator. +def choose_plugin(prepared, question): + """Allow the user to choose ther plugin. - :param list auths: Where each of type - :class:`letsencrypt.client.interfaces.IAuthenticator` object - :param dict errs: Mapping IAuthenticator objects to error messages + :param list prepared: List of `~.PluginEntryPoint`. + :param str question: Question to be presented to the user. - :returns: Authenticator selected - :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` or `None` + :returns: Plugin entry point chosen by the user. + :rtype: `~.PluginEntryPoint` """ - descs = [auth.description if auth not in errs - else "%s (Misconfigured)" % auth.description - for auth in auths] + opts = [plugin_ep.description_with_name + + (" [Misconfigured]" if plugin_ep.misconfigured else "") + for plugin_ep in prepared] while True: code, index = util(interfaces.IDisplay).menu( - "How would you like to authenticate with the Let's Encrypt CA?", - descs, help_label="More Info") + question, opts, help_label="More Info") if code == display_util.OK: - return auths[index] + return prepared[index] elif code == display_util.HELP: - if auths[index] in errs: - msg = "Reported Error: %s" % errs[auths[index]] + if prepared[index].misconfigured: + msg = "Reported Error: %s" % prepared[index].prepare() else: - msg = auths[index].more_info() + msg = prepared[index].init().more_info() util(interfaces.IDisplay).notification( msg, height=display_util.HEIGHT) else: - return + return None + + +def pick_plugin(config, default, plugins, question, ifaces): + """Pick plugin. + + :param letsencrypt.client.interfaces.IConfig: Configuration + :param str default: Plugin name supplied by user or ``None``. + :param letsencrypt.client.plugins.disco.PluginsRegistry plugins: + All plugins registered as entry points. + :param str question: Question to be presented to the user in case + multiple candidates are found. + :param list ifaces: Interfaces that plugins must provide. + + :returns: Initialized plugin. + :rtype: IPlugin + + """ + if default is not None: + # throw more UX-friendly error if default not in plugins + filtered = plugins.filter(lambda p_ep: p_ep.name == default) + else: + filtered = plugins.ifaces(ifaces) + + filtered.init(config) + verified = filtered.verify(ifaces) + verified.prepare() + prepared = verified.available() + + if len(prepared) > 1: + logging.debug("Multiple candidate plugins: %s", prepared) + plugin_ep = choose_plugin(prepared.values(), question) + if plugin_ep is None: + return None + else: + return plugin_ep.init() + elif len(prepared) == 1: + plugin_ep = prepared.values()[0] + logging.debug("Single candidate plugin: %s", plugin_ep) + return plugin_ep.init() + else: + logging.debug("No candidate plugin") + return None + + +def pick_authenticator( + config, default, plugins, question="How would you " + "like to authenticate with Let's Encrypt CA?"): + """Pick authentication plugin.""" + return pick_plugin( + config, default, plugins, question, (interfaces.IAuthenticator,)) + + +def pick_installer(config, default, plugins, + question="How would you like to install certificates?"): + """Pick installer plugin.""" + return pick_plugin( + config, default, plugins, question, (interfaces.IInstaller,)) + + +def pick_configurator( + config, default, plugins, + question="How would you like to authenticate and install " + "certificates?"): + """Pick configurator plugin.""" + return pick_plugin( + config, default, plugins, question, + (interfaces.IAuthenticator, interfaces.IInstaller)) def choose_account(accounts): @@ -76,6 +143,7 @@ def choose_names(installer): """ if installer is None: + logging.debug("No installer, picking names manually") return _choose_names_manually() names = list(installer.get_all_names()) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 1d52d854c..b005eb02d 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -5,7 +5,88 @@ import zope.interface # pylint: disable=too-few-public-methods -class IAuthenticator(zope.interface.Interface): +class IPluginFactory(zope.interface.Interface): + """IPlugin factory. + + Objects providing this interface will be called without satisfying + any entry point "extras" (extra dependencies) you might have defined + for your plugin, e.g (excerpt from ``setup.py`` script):: + + setup( + ... + entry_points={ + 'letsencrypt.plugins': [ + 'name=example_project.plugin[plugin_deps]', + ], + }, + extras_require={ + 'plugin_deps': ['dep1', 'dep2'], + } + ) + + Therefore, make sure such objects are importable and usable without + extras. This is necessary, because CLI does the following operations + (in order): + + - loads an entry point, + - calls `inject_parser_options`, + - requires an entry point, + - creates plugin instance (`__call__`). + + """ + + description = zope.interface.Attribute("Short plugin description") + + def __call__(config, name): + """Create new `IPlugin`. + + :param IConfig config: Configuration. + :param str name: Unique plugin name. + + """ + + def inject_parser_options(parser, name): + """Inject argument parser options (flags). + + 1. Be nice and prepend all options and destinations with + `~.option_namespace` and `~.dest_namespace`. + + 2. Inject options (flags) only. Positional arguments are not + allowed, as this would break the CLI. + + :param ArgumentParser parser: (Almost) top-level CLI parser. + :param str name: Unique plugin name. + + """ + + +class IPlugin(zope.interface.Interface): + """Let's Encrypt plugin.""" + + def prepare(): + """Prepare the plugin. + + Finish up any additional initialization. + + :raises letsencrypt.client.errors.LetsEncryptMisconfigurationError: + when full initialization cannot be completed. Plugin will be + displayed on a list of available plugins. + :raises letsencrypt.client.errors.LetsEncryptNoInstallationError: + when the necessary programs/files cannot be located. Plugin + will NOT be displayed on a list of available plugins. + + """ + + def more_info(): + """Human-readable string to help the user. + + Should describe the steps taken and any relevant info to help the user + decide which plugin to use. + + """ + + +class IAuthenticator(IPlugin): """Generic Let's Encrypt Authenticator. Class represents all possible tools processes that have the @@ -13,22 +94,6 @@ class IAuthenticator(zope.interface.Interface): """ - description = zope.interface.Attribute( - "Short description of this authenticator. " - "Used in interactive configuration.") - - def prepare(): - """Prepare the authenticator. - - Finish up any additional initialization. - - :raises letsencrypt.client.errors.LetsEncryptMisconfigurationError: - when full initialization cannot be completed. - :raises letsencrypt.client.errors.LetsEncryptNoInstallationError: - when the necessary programs/files cannot be located. - - """ - def get_chall_pref(domain): """Return list of challenge preferences. @@ -74,14 +139,6 @@ class IAuthenticator(zope.interface.Interface): """ - def more_info(): - """Human-readable string to help the user. - - Should describe the steps taken and any relevant info to help the user - decide which Authenticator to use. - - """ - class IConfig(zope.interface.Interface): """Let's Encrypt user-supplied configuration. @@ -93,8 +150,6 @@ class IConfig(zope.interface.Interface): server = zope.interface.Attribute( "CA hostname (and optionally :port). The server certificate must " "be trusted in order to avoid further modifications to the client.") - authenticator = zope.interface.Attribute( - "Authenticator to use for responding to challenges.") email = zope.interface.Attribute( "Email used for registration and recovery contact.") rsa_key_size = zope.interface.Attribute("Size of the RSA key.") @@ -120,49 +175,17 @@ class IConfig(zope.interface.Interface): le_vhost_ext = zope.interface.Attribute( "SSL vhost configuration extension.") - cert_path = zope.interface.Attribute("Let's Encrypt certificate file.") - chain_path = zope.interface.Attribute("Let's Encrypt chain file.") - - apache_server_root = zope.interface.Attribute( - "Apache server root directory.") - apache_ctl = zope.interface.Attribute( - "Path to the 'apache2ctl' binary, used for 'configtest' and " - "retrieving Apache2 version number.") - apache_enmod = zope.interface.Attribute( - "Path to the Apache 'a2enmod' binary.") - apache_init_script = zope.interface.Attribute( - "Path to the Apache init script (used for server reload/restart).") - apache_mod_ssl_conf = zope.interface.Attribute( - "Contains standard Apache SSL directives.") - - nginx_server_root = zope.interface.Attribute( - "Nginx server root directory.") - nginx_ctl = zope.interface.Attribute( - "Path to the 'nginx' binary, used for 'configtest' and " - "retrieving nginx version number.") - nginx_mod_ssl_conf = zope.interface.Attribute( - "Contains standard nginx SSL directives.") + cert_path = zope.interface.Attribute("Let's Encrypt certificate file path.") + chain_path = zope.interface.Attribute("Let's Encrypt chain file path.") -class IInstaller(zope.interface.Interface): +class IInstaller(IPlugin): """Generic Let's Encrypt Installer Interface. Represents any server that an X509 certificate can be placed. """ - def prepare(): - """Prepare the installer. - - Finish up any additional initialization. - - :raises letsencrypt.client.errors.LetsEncryptMisconfigurationError`: - when full initialization cannot be completed. - :raises letsencrypt.errors.LetsEncryptNoInstallationError`: - when the necessary programs/files cannot be located. - - """ - def get_all_names(): """Returns all names that may be authenticated.""" diff --git a/letsencrypt/client/plugins/apache/configurator.py b/letsencrypt/client/plugins/apache/configurator.py index 33abad3c5..82d6f323c 100644 --- a/letsencrypt/client/plugins/apache/configurator.py +++ b/letsencrypt/client/plugins/apache/configurator.py @@ -13,11 +13,12 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import augeas_configurator -from letsencrypt.client import constants +from letsencrypt.client import constants as core_constants from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client.plugins.apache import constants from letsencrypt.client.plugins.apache import dvsni from letsencrypt.client.plugins.apache import obj from letsencrypt.client.plugins.apache import parser @@ -79,17 +80,34 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + zope.interface.classProvides(interfaces.IPluginFactory) description = "Apache Web Server" - def __init__(self, config, version=None): + @classmethod + def add_parser_arguments(cls, add): + add("server-root", default=constants.CLI_DEFAULTS["server_root"], + help="Apache server root directory.") + add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"], + help="Contains standard Apache SSL directives.") + add("ctl", default=constants.CLI_DEFAULTS["ctl"], + help="Path to the 'apache2ctl' binary, used for 'configtest' and " + "retrieving Apache2 version number.") + add("enmod", default=constants.CLI_DEFAULTS["enmod"], + help="Path to the Apache 'a2enmod' binary.") + add("init-script", default=constants.CLI_DEFAULTS["init_script"], + help="Path to the Apache init script (used for server " + "reload/restart).") + + def __init__(self, *args, **kwargs): """Initialize an Apache Configurator. :param tup version: version of Apache as a tuple (2, 4, 7) (used mostly for unittesting) """ - super(ApacheConfigurator, self).__init__(config) + version = kwargs.pop('version', None) + super(ApacheConfigurator, self).__init__(*args, **kwargs) # Verify that all directories and files exist with proper permissions if os.geteuid() == 0: @@ -109,8 +127,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def prepare(self): """Prepare the authenticator/installer.""" self.parser = parser.ApacheParser( - self.aug, self.config.apache_server_root, - self.config.apache_mod_ssl_conf) + self.aug, self.conf('server-root'), self.conf('mod-ssl-conf')) # Check for errors in parsing files with Augeas self.check_parsing_errors("httpd.aug") @@ -128,9 +145,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - temp_install(self.config.apache_mod_ssl_conf) + temp_install(self.conf('mod-ssl-conf')) - def deploy_cert(self, domain, cert, key, cert_chain=None): + def deploy_cert(self, domain, cert_path, key, chain_path=None): """Deploys certificate to specified virtual host. Currently tries to find the last directives to deploy the cert in @@ -146,25 +163,26 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): This shouldn't happen within letsencrypt though :param str domain: domain to deploy certificate - :param str cert: certificate filename + :param str cert_path: certificate filename :param str key: private key filename - :param str cert_chain: certificate chain filename + :param str chain_path: certificate chain filename """ vhost = self.choose_vhost(domain) + # TODO(jdkasten): vhost might be None path = {} - path["cert_file"] = self.parser.find_dir(parser.case_i( + path["cert_path"] = self.parser.find_dir(parser.case_i( "SSLCertificateFile"), None, vhost.path) path["cert_key"] = self.parser.find_dir(parser.case_i( "SSLCertificateKeyFile"), None, vhost.path) # Only include if a certificate chain is specified - if cert_chain is not None: - path["cert_chain"] = self.parser.find_dir( + if chain_path is not None: + path["chain_path"] = self.parser.find_dir( parser.case_i("SSLCertificateChainFile"), None, vhost.path) - if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: + if not path["cert_path"] or not path["cert_key"]: # Throw some can't find all of the directives error" logging.warn( "Cannot find a cert or key directive in %s", vhost.path) @@ -174,22 +192,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logging.info("Deploying Certificate to VirtualHost %s", vhost.filep) - self.aug.set(path["cert_file"][0], cert) + self.aug.set(path["cert_path"][0], cert_path) self.aug.set(path["cert_key"][0], key) - if cert_chain is not None: - if len(path["cert_chain"]) == 0: + if chain_path is not None: + if not path["chain_path"]: self.parser.add_dir( - vhost.path, "SSLCertificateChainFile", cert_chain) + vhost.path, "SSLCertificateChainFile", chain_path) else: - self.aug.set(path["cert_chain"][0], cert_chain) + self.aug.set(path["chain_path"][0], chain_path) self.save_notes += ("Changed vhost at %s with addresses of %s\n" % (vhost.filep, ", ".join(str(addr) for addr in vhost.addrs))) - self.save_notes += "\tSSLCertificateFile %s\n" % cert + self.save_notes += "\tSSLCertificateFile %s\n" % cert_path self.save_notes += "\tSSLCertificateKeyFile %s\n" % key - if cert_chain: - self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain + if chain_path is not None: + self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path # Make sure vhost is enabled if not vhost.enabled: @@ -386,10 +404,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): is appropriately listening on port 443. """ - if not mod_loaded("ssl_module", self.config.apache_ctl): + if not mod_loaded("ssl_module", self.conf('ctl')): logging.info("Loading mod_ssl into Apache Server") - enable_mod("ssl", self.config.apache_init_script, - self.config.apache_enmod) + enable_mod("ssl", self.conf('init-script'), + self.conf('enmod')) # Check for Listen 443 # Note: This could be made to also look for ip:443 combo @@ -572,9 +590,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :class:`~letsencrypt.client.plugins.apache.obj.VirtualHost`) """ - if not mod_loaded("rewrite_module", self.config.apache_ctl): - enable_mod("rewrite", self.config.apache_init_script, - self.config.apache_enmod) + if not mod_loaded("rewrite_module", self.conf('ctl')): + enable_mod("rewrite", self.conf('init-script'), self.conf('enmod')) general_v = self._general_vhost(ssl_vhost) if general_v is None: @@ -599,7 +616,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add directives to server self.parser.add_dir(general_v.path, "RewriteEngine", "On") self.parser.add_dir(general_v.path, "RewriteRule", - constants.APACHE_REWRITE_HTTPS_ARGS) + constants.REWRITE_HTTPS_ARGS) self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % (general_v.filep, ssl_vhost.filep)) self.save() @@ -638,10 +655,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if not rewrite_path: # "No existing redirection for virtualhost" return False, -1 - if len(rewrite_path) == len(constants.APACHE_REWRITE_HTTPS_ARGS): + if len(rewrite_path) == len(constants.REWRITE_HTTPS_ARGS): for idx, match in enumerate(rewrite_path): if (self.aug.get(match) != - constants.APACHE_REWRITE_HTTPS_ARGS[idx]): + constants.REWRITE_HTTPS_ARGS[idx]): # Not a letsencrypt https rewrite return True, 2 # Existing letsencrypt https rewrite rule is in place @@ -693,7 +710,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "LogLevel warn\n" "\n" % (servername, serveralias, - " ".join(constants.APACHE_REWRITE_HTTPS_ARGS))) + " ".join(constants.REWRITE_HTTPS_ARGS))) # Write out the file # This is the default name @@ -897,7 +914,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: bool """ - return apache_restart(self.config.apache_init_script) + return apache_restart(self.conf('init-script')) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Apache for errors. @@ -908,7 +925,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - [self.config.apache_ctl, "configtest"], + [self.conf('ctl'), "configtest"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -935,11 +952,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ uid = os.geteuid() le_util.make_or_verify_dir( - self.config.config_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.config_dir, core_constants.CONFIG_DIRS_MODE, uid) le_util.make_or_verify_dir( - self.config.work_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.work_dir, core_constants.CONFIG_DIRS_MODE, uid) le_util.make_or_verify_dir( - self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.backup_dir, core_constants.CONFIG_DIRS_MODE, uid) def get_version(self): """Return version of Apache Server. @@ -955,13 +972,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: proc = subprocess.Popen( - [self.config.apache_ctl, "-v"], + [self.conf('ctl'), "-v"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[0] except (OSError, ValueError): raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -v" % self.config.apache_ctl) + "Unable to run %s -v" % self.conf('ctl')) regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) matches = regex.findall(text) @@ -1165,4 +1182,4 @@ def temp_install(options_ssl): # Check to make sure options-ssl.conf is installed if not os.path.isfile(options_ssl): - shutil.copyfile(constants.APACHE_MOD_SSL_CONF, options_ssl) + shutil.copyfile(constants.MOD_SSL_CONF, options_ssl) diff --git a/letsencrypt/client/plugins/apache/constants.py b/letsencrypt/client/plugins/apache/constants.py new file mode 100644 index 000000000..d9f2a0b9d --- /dev/null +++ b/letsencrypt/client/plugins/apache/constants.py @@ -0,0 +1,22 @@ +"""Apache plugin constants.""" +import pkg_resources + + +CLI_DEFAULTS = dict( + server_root="/etc/apache2", + mod_ssl_conf="/etc/letsencrypt/options-ssl.conf", + ctl="apache2ctl", + enmod="a2enmod", + init_script="/etc/init.d/apache2", +) +"""CLI defaults.""" + + +MOD_SSL_CONF = pkg_resources.resource_filename( + "letsencrypt.client.plugins.apache", "options-ssl.conf") +"""Path to the Apache mod_ssl config file found in the Let's Encrypt +distribution.""" + +REWRITE_HTTPS_ARGS = [ + "^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"] +"""Apache rewrite rule arguments used for redirections to https vhost""" diff --git a/letsencrypt/client/plugins/apache/tests/util.py b/letsencrypt/client/plugins/apache/tests/util.py index 488ecffea..618b0975a 100644 --- a/letsencrypt/client/plugins/apache/tests/util.py +++ b/letsencrypt/client/plugins/apache/tests/util.py @@ -7,8 +7,8 @@ import unittest import mock -from letsencrypt.client import constants from letsencrypt.client.plugins.apache import configurator +from letsencrypt.client.plugins.apache import constants from letsencrypt.client.plugins.apache import obj @@ -49,7 +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") - shutil.copyfile(constants.APACHE_MOD_SSL_CONF, option_path) + shutil.copyfile(constants.MOD_SSL_CONF, option_path) return option_path @@ -64,7 +64,7 @@ def get_apache_configurator( # This just states that the ssl module is already loaded mock_popen().communicate.return_value = ("ssl_module", "") config = configurator.ApacheConfigurator( - mock.MagicMock( + config=mock.MagicMock( apache_server_root=config_path, apache_mod_ssl_conf=ssl_options, le_vhost_ext="-le-ssl.conf", @@ -73,7 +73,8 @@ def get_apache_configurator( temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), in_progress_dir=os.path.join(backups, "IN_PROGRESS"), work_dir=work_dir), - version) + name="apache", + version=version) config.prepare() diff --git a/letsencrypt/client/plugins/common.py b/letsencrypt/client/plugins/common.py new file mode 100644 index 000000000..60b868c37 --- /dev/null +++ b/letsencrypt/client/plugins/common.py @@ -0,0 +1,71 @@ +"""Plugin common functions.""" +import zope.interface + +from letsencrypt.acme.jose import util as jose_util + +from letsencrypt.client import interfaces + + +def option_namespace(name): + """ArgumentParser options namespace (prefix of all options).""" + return name + "-" + +def dest_namespace(name): + """ArgumentParser dest namespace (prefix of all destinations).""" + return name + "_" + + +class Plugin(object): + """Generic plugin.""" + zope.interface.implements(interfaces.IPlugin) + # classProvides is not inherited, subclasses must define it on their own + #zope.interface.classProvides(interfaces.IPluginFactory) + + def __init__(self, config, name): + self.config = config + self.name = name + + @property + def option_namespace(self): + """ArgumentParser options namespace (prefix of all options).""" + return option_namespace(self.name) + + @property + def dest_namespace(self): + """ArgumentParser dest namespace (prefix of all destinations).""" + return dest_namespace(self.name) + + def dest(self, var): + """Find a destination for given variable ``var``.""" + # this should do exactly the same what ArgumentParser(arg), + # does to "arg" to compute "dest" + return self.dest_namespace + var.replace("-", "_") + + def conf(self, var): + """Find a configuration value for variable ``var``.""" + return getattr(self.config, self.dest(var)) + + @classmethod + def inject_parser_options(cls, parser, name): + """Inject parser options. + + See `~.IPlugin.inject_parser_options` for docs. + + """ + # dummy function, doesn't check if dest.startswith(self.dest_namespace) + def add(arg_name_no_prefix, *args, **kwargs): + # pylint: disable=missing-docstring + return parser.add_argument( + "--{0}{1}".format(option_namespace(name), arg_name_no_prefix), + *args, **kwargs) + return cls.add_parser_arguments(add) + + @jose_util.abstractclassmethod + def add_parser_arguments(cls, add): + """Add plugin arguments to the CLI argument parser. + + :param callable add: Function that proxies calls to + `argparse.ArgumentParser.add_argument` prepending options + with unique plugin name prefix. + + """ diff --git a/letsencrypt/client/plugins/common_test.py b/letsencrypt/client/plugins/common_test.py new file mode 100644 index 000000000..bdb5f7f3c --- /dev/null +++ b/letsencrypt/client/plugins/common_test.py @@ -0,0 +1,61 @@ +"""Tests for letsencrypt.client.plugins.common.""" +import unittest + +import mock + + +class NamespaceFunctionsTest(unittest.TestCase): + """Tests for letsencrypt.client.plugins.common.*_namespace functions.""" + + def test_option_namespace(self): + from letsencrypt.client.plugins.common import option_namespace + self.assertEqual("foo-", option_namespace("foo")) + + def test_dest_namespace(self): + from letsencrypt.client.plugins.common import dest_namespace + self.assertEqual("foo_", dest_namespace("foo")) + + +class PluginTest(unittest.TestCase): + """Test for letsencrypt.client.plugins.common.Plugin.""" + + def setUp(self): + from letsencrypt.client.plugins.common import Plugin + + class MockPlugin(Plugin): # pylint: disable=missing-docstring + @classmethod + def add_parser_arguments(cls, add): + add("foo-bar", dest="different_to_foo_bar", x=1, y=None) + + self.plugin_cls = MockPlugin + self.config = mock.MagicMock() + self.plugin = MockPlugin(config=self.config, name="mock") + + def test_init(self): + self.assertEqual("mock", self.plugin.name) + self.assertEqual(self.config, self.plugin.config) + + def test_option_namespace(self): + self.assertEqual("mock-", self.plugin.option_namespace) + + def test_dest_namespace(self): + self.assertEqual("mock_", self.plugin.dest_namespace) + + def test_dest(self): + self.assertEqual("mock_foo_bar", self.plugin.dest("foo-bar")) + self.assertEqual("mock_foo_bar", self.plugin.dest("foo_bar")) + + def test_conf(self): + self.assertEqual(self.config.mock_foo_bar, self.plugin.conf("foo-bar")) + + def test_inject_parser_options(self): + parser = mock.MagicMock() + self.plugin_cls.inject_parser_options(parser, "mock") + # note that inject_parser_options doesn't check if dest has + # correct prefix + parser.add_argument.assert_called_once_with( + "--mock-foo-bar", dest="different_to_foo_bar", x=1, y=None) + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/plugins/disco.py b/letsencrypt/client/plugins/disco.py new file mode 100644 index 000000000..6ab110a20 --- /dev/null +++ b/letsencrypt/client/plugins/disco.py @@ -0,0 +1,224 @@ +"""Utilities for plugins discovery and selection.""" +import collections +import logging +import pkg_resources + +import zope.interface + +from letsencrypt.client import constants +from letsencrypt.client import errors +from letsencrypt.client import interfaces + + +class PluginEntryPoint(object): + """Plugin entry point.""" + + PREFIX_FREE_DISTRIBUTIONS = ["letsencrypt"] + """Distributions for which prefix will be omitted.""" + + # this object is mutable, don't allow it to be hashed! + __hash__ = None + + def __init__(self, entry_point): + self.name = self.entry_point_to_plugin_name(entry_point) + self.plugin_cls = entry_point.load() + self.entry_point = entry_point + self._initialized = None + self._prepared = None + + @classmethod + def entry_point_to_plugin_name(cls, entry_point): + """Unique plugin name for an ``entry_point``""" + if entry_point.dist.key in cls.PREFIX_FREE_DISTRIBUTIONS: + return entry_point.name + return entry_point.dist.key + ":" + entry_point.name + + @property + def description(self): + """Description of the plugin.""" + return self.plugin_cls.description + + @property + def description_with_name(self): + """Description with name. Handy for UI.""" + return "{0} ({1})".format(self.description, self.name) + + def ifaces(self, *ifaces_groups): + """Does plugin implements specified interface groups?""" + return not ifaces_groups or any( + all(iface.implementedBy(self.plugin_cls) + for iface in ifaces) + for ifaces in ifaces_groups) + + @property + def initialized(self): + """Has the plugin been initialized already?""" + return self._initialized is not None + + def init(self, config=None): + """Memoized plugin inititialization.""" + if not self.initialized: + self.entry_point.require() # fetch extras! + self._initialized = self.plugin_cls(config, self.name) + return self._initialized + + def verify(self, ifaces): + """Verify that the plugin conforms to the specified interfaces.""" + assert self.initialized + for iface in ifaces: # zope.interface.providedBy(plugin) + try: + zope.interface.verify.verifyObject(iface, self.init()) + except zope.interface.exceptions.BrokenImplementation: + if iface.implementedBy(self.plugin_cls): + logging.debug( + "%s implements %s but object does " + "not verify", self.plugin_cls, iface.__name__) + return False + return True + + @property + def prepared(self): + """Has the plugin been prepared already?""" + if not self.initialized: + logging.debug(".prepared called on uninitialized %r", self) + return self._prepared is not None + + def prepare(self): + """Memoized plugin preparation.""" + assert self.initialized + if self._prepared is None: + try: + self._initialized.prepare() + except errors.LetsEncryptMisconfigurationError as error: + logging.debug("Misconfigured %r: %s", self, error) + self._prepared = error + except errors.LetsEncryptNoInstallationError as error: + logging.debug("No installation (%r): %s", self, error) + self._prepared = error + else: + self._prepared = True + return self._prepared + + @property + def misconfigured(self): + """Is plugin misconfigured?""" + return isinstance( + self._prepared, errors.LetsEncryptMisconfigurationError) + + @property + def available(self): + """Is plugin available, i.e. prepared or misconfigured?""" + return self._prepared is True or self.misconfigured + + def __repr__(self): + return "PluginEntryPoint#{0}".format(self.name) + + def __str__(self): + lines = [ + "* {0}".format(self.name), + "Description: {0}".format(self.plugin_cls.description), + "Interfaces: {0}".format(", ".join( + iface.__name__ for iface in zope.interface.implementedBy( + self.plugin_cls))), + "Entry point: {0}".format(self.entry_point), + ] + + if self.initialized: + lines.append("Initialized: {0}".format(self.init())) + if self.prepared: + lines.append("Prep: {0}".format(self.prepare())) + + return "\n".join(lines) + + +class PluginsRegistry(collections.Mapping): + """Plugins registry.""" + + def __init__(self, plugins): + self._plugins = plugins + + @classmethod + def find_all(cls): + """Find plugins using setuptools entry points.""" + plugins = {} + for entry_point in pkg_resources.iter_entry_points( + constants.SETUPTOOLS_PLUGINS_ENTRY_POINT): + plugin_ep = PluginEntryPoint(entry_point) + assert plugin_ep.name not in plugins, ( + "PREFIX_FREE_DISTRIBUTIONS messed up") + # providedBy | pylint: disable=no-member + if interfaces.IPluginFactory.providedBy(plugin_ep.plugin_cls): + plugins[plugin_ep.name] = plugin_ep + else: # pragma: no cover + logging.warning( + "%r does not provide IPluginFactory, skipping", plugin_ep) + return cls(plugins) + + def __getitem__(self, name): + return self._plugins[name] + + def __iter__(self): + return iter(self._plugins) + + def __len__(self): + return len(self._plugins) + + def init(self, config): + """Initialize all plugins in the registry.""" + return [plugin_ep.init(config) for plugin_ep + in self._plugins.itervalues()] + + def filter(self, pred): + """Filter plugins based on predicate.""" + return type(self)(dict((name, plugin_ep) for name, plugin_ep + in self._plugins.iteritems() if pred(plugin_ep))) + + def ifaces(self, *ifaces_groups): + """Filter plugins based on interfaces.""" + # pylint: disable=star-args + return self.filter(lambda p_ep: p_ep.ifaces(*ifaces_groups)) + + def verify(self, ifaces): + """Filter plugins based on verification.""" + return self.filter(lambda p_ep: p_ep.verify(ifaces)) + + def prepare(self): + """Prepare all plugins in the registry.""" + return [plugin_ep.prepare() for plugin_ep in self._plugins.itervalues()] + + def available(self): + """Filter plugins based on availability.""" + return self.filter(lambda p_ep: p_ep.available) + # succefully prepared + misconfigured + + def find_init(self, plugin): + """Find an initialized plugin. + + This is particularly useful for finding a name for the plugin + (although `.IPluginFactory.__call__` takes ``name`` as one of + the arguments, ``IPlugin.name`` is not part of the interface):: + + # plugin is an instance providing IPlugin, initialized + # somewhere else in the code + plugin_registry.find_init(plugin).name + + Returns ``None`` if ``plugin`` is not found in the registry. + + """ + # use list instead of set beacse PluginEntryPoint is not hashable + candidates = [plugin_ep for plugin_ep in self._plugins.itervalues() + if plugin_ep.initialized and plugin_ep.init() is plugin] + assert len(candidates) <= 1 + if candidates: + return candidates[0] + else: + return None + + def __repr__(self): + return "{0}({1!r})".format( + self.__class__.__name__, set(self._plugins.itervalues())) + + def __str__(self): + if not self._plugins: + return "No plugins" + return "\n\n".join(str(p_ep) for p_ep in self._plugins.itervalues()) diff --git a/letsencrypt/client/plugins/disco_test.py b/letsencrypt/client/plugins/disco_test.py new file mode 100644 index 000000000..88aa1275c --- /dev/null +++ b/letsencrypt/client/plugins/disco_test.py @@ -0,0 +1,243 @@ +"""Tests for letsencrypt.client.plugins.disco.""" +import pkg_resources +import unittest + +import mock +import zope.interface + +from letsencrypt.client import errors +from letsencrypt.client import interfaces + +from letsencrypt.client.plugins.standalone import authenticator + +EP_SA = pkg_resources.EntryPoint( + "sa", "letsencrypt.client.plugins.standalone.authenticator", + attrs=("StandaloneAuthenticator",), + dist=mock.MagicMock(key="letsencrypt")) + + +class PluginEntryPointTest(unittest.TestCase): + """Tests for letsencrypt.client.plugins.disco.PluginEntryPoint.""" + + def setUp(self): + self.ep1 = pkg_resources.EntryPoint( + "ep1", "p1.ep1", dist=mock.MagicMock(key="p1")) + self.ep1prim = pkg_resources.EntryPoint( + "ep1", "p2.ep2", dist=mock.MagicMock(key="p2")) + # nested + self.ep2 = pkg_resources.EntryPoint( + "ep2", "p2.foo.ep2", dist=mock.MagicMock(key="p2")) + # project name != top-level package name + self.ep3 = pkg_resources.EntryPoint( + "ep3", "a.ep3", dist=mock.MagicMock(key="p3")) + + from letsencrypt.client.plugins.disco import PluginEntryPoint + self.plugin_ep = PluginEntryPoint(EP_SA) + + def test_entry_point_to_plugin_name(self): + from letsencrypt.client.plugins.disco import PluginEntryPoint + + names = { + self.ep1: "p1:ep1", + self.ep1prim: "p2:ep1", + self.ep2: "p2:ep2", + self.ep3: "p3:ep3", + EP_SA: "sa", + } + + for entry_point, name in names.iteritems(): + self.assertEqual( + name, PluginEntryPoint.entry_point_to_plugin_name(entry_point)) + + def test_description(self): + self.assertEqual("Standalone Authenticator", self.plugin_ep.description) + + def test_description_with_name(self): + self.plugin_ep.plugin_cls = mock.MagicMock(description="Desc") + self.assertEqual( + "Desc (sa)", self.plugin_ep.description_with_name) + + def test_ifaces(self): + self.assertTrue(self.plugin_ep.ifaces((interfaces.IAuthenticator,))) + self.assertFalse(self.plugin_ep.ifaces((interfaces.IInstaller,))) + self.assertFalse(self.plugin_ep.ifaces(( + interfaces.IInstaller, interfaces.IAuthenticator))) + + def test__init__(self): + self.assertFalse(self.plugin_ep.initialized) + self.assertFalse(self.plugin_ep.prepared) + self.assertFalse(self.plugin_ep.misconfigured) + self.assertFalse(self.plugin_ep.available) + self.assertTrue(self.plugin_ep.entry_point is EP_SA) + self.assertEqual("sa", self.plugin_ep.name) + + self.assertTrue( + self.plugin_ep.plugin_cls is authenticator.StandaloneAuthenticator) + + def test_init(self): + config = mock.MagicMock() + plugin = self.plugin_ep.init(config=config) + self.assertTrue(self.plugin_ep.initialized) + self.assertTrue(plugin.config is config) + # memoize! + self.assertTrue(self.plugin_ep.init() is plugin) + self.assertTrue(plugin.config is config) + # try to give different config + self.assertTrue(self.plugin_ep.init(123) is plugin) + self.assertTrue(plugin.config is config) + + self.assertFalse(self.plugin_ep.prepared) + self.assertFalse(self.plugin_ep.misconfigured) + self.assertFalse(self.plugin_ep.available) + + def test_verify(self): + iface1 = mock.MagicMock(__name__="iface1") + iface2 = mock.MagicMock(__name__="iface2") + iface3 = mock.MagicMock(__name__="iface3") + # pylint: disable=protected-access + self.plugin_ep._initialized = plugin = mock.MagicMock() + + exceptions = zope.interface.exceptions + with mock.patch("letsencrypt.client.plugins." + "disco.zope.interface") as mock_zope: + mock_zope.exceptions = exceptions + def verify_object(iface, obj): # pylint: disable=missing-docstring + assert obj is plugin + assert iface is iface1 or iface is iface2 or iface is iface3 + if iface is iface3: + raise mock_zope.exceptions.BrokenImplementation(None, None) + mock_zope.verify.verifyObject.side_effect = verify_object + self.assertTrue(self.plugin_ep.verify((iface1,))) + self.assertTrue(self.plugin_ep.verify((iface1, iface2))) + self.assertFalse(self.plugin_ep.verify((iface3,))) + self.assertFalse(self.plugin_ep.verify((iface1, iface3))) + + def test_prepare(self): + config = mock.MagicMock() + self.plugin_ep.init(config=config) + self.plugin_ep.prepare() + self.assertTrue(self.plugin_ep.prepared) + self.assertFalse(self.plugin_ep.misconfigured) + + # output doesn't matter that much, just test if it runs + str(self.plugin_ep) + + def test_prepare_misconfigured(self): + plugin = mock.MagicMock() + plugin.prepare.side_effect = errors.LetsEncryptMisconfigurationError + # pylint: disable=protected-access + self.plugin_ep._initialized = plugin + self.assertTrue(isinstance(self.plugin_ep.prepare(), + errors.LetsEncryptMisconfigurationError)) + self.assertTrue(self.plugin_ep.prepared) + self.assertTrue(self.plugin_ep.misconfigured) + self.assertTrue(self.plugin_ep.available) + + def test_prepare_no_installation(self): + plugin = mock.MagicMock() + plugin.prepare.side_effect = errors.LetsEncryptNoInstallationError + # pylint: disable=protected-access + self.plugin_ep._initialized = plugin + self.assertTrue(isinstance(self.plugin_ep.prepare(), + errors.LetsEncryptNoInstallationError)) + self.assertTrue(self.plugin_ep.prepared) + self.assertFalse(self.plugin_ep.misconfigured) + self.assertFalse(self.plugin_ep.available) + + def test_repr(self): + self.assertEqual("PluginEntryPoint#sa", repr(self.plugin_ep)) + + +class PluginsRegistryTest(unittest.TestCase): + """Tests for letsencrypt.client.plugins.disco.PluginsRegistry.""" + + def setUp(self): + from letsencrypt.client.plugins.disco import PluginsRegistry + self.plugin_ep = mock.MagicMock(name="mock") + self.plugins = {"mock": self.plugin_ep} + self.reg = PluginsRegistry(self.plugins) + + def test_find_all(self): + from letsencrypt.client.plugins.disco import PluginsRegistry + with mock.patch("letsencrypt.client.plugins.disco" + ".pkg_resources") as mock_pkg: + mock_pkg.iter_entry_points.return_value = iter([EP_SA]) + plugins = PluginsRegistry.find_all() + self.assertTrue(plugins["sa"].plugin_cls + is authenticator.StandaloneAuthenticator) + self.assertTrue(plugins["sa"].entry_point is EP_SA) + + def test_getitem(self): + self.assertEqual(self.plugin_ep, self.reg["mock"]) + + def test_iter(self): + self.assertEqual(["mock"], list(self.reg)) + + def test_len(self): + self.assertEqual(1, len(self.reg)) + self.plugins.clear() + self.assertEqual(0, len(self.reg)) + + def test_init(self): + self.plugin_ep.init.return_value = "baz" + self.assertEqual(["baz"], self.reg.init("bar")) + self.plugin_ep.init.assert_called_once_with("bar") + + def test_filter(self): + self.plugins.update({ + "foo": "bar", + "bar": "foo", + "baz": "boo", + }) + self.assertEqual( + {"foo": "bar", "baz": "boo"}, + self.reg.filter(lambda p_ep: str(p_ep).startswith("b"))) + + def test_ifaces(self): + self.plugin_ep.ifaces.return_value = True + # pylint: disable=protected-access + self.assertEqual(self.plugins, self.reg.ifaces()._plugins) + self.plugin_ep.ifaces.return_value = False + self.assertEqual({}, self.reg.ifaces()._plugins) + + def test_verify(self): + self.plugin_ep.verify.return_value = True + # pylint: disable=protected-access + self.assertEqual( + self.plugins, self.reg.verify(mock.MagicMock())._plugins) + self.plugin_ep.verify.return_value = False + self.assertEqual({}, self.reg.verify(mock.MagicMock())._plugins) + + def test_prepare(self): + self.plugin_ep.prepare.return_value = "baz" + self.assertEqual(["baz"], self.reg.prepare()) + self.plugin_ep.prepare.assert_called_once_with() + + def test_available(self): + self.plugin_ep.available = True + # pylint: disable=protected-access + self.assertEqual(self.plugins, self.reg.available()._plugins) + self.plugin_ep.available = False + self.assertEqual({}, self.reg.available()._plugins) + + def test_find_init(self): + self.assertTrue(self.reg.find_init(mock.Mock()) is None) + self.plugin_ep.initalized = True + self.assertTrue( + self.reg.find_init(self.plugin_ep.init()) is self.plugin_ep) + + def test_repr(self): + self.plugin_ep.__repr__ = lambda _: "PluginEntryPoint#mock" + self.assertEqual("PluginsRegistry(set([PluginEntryPoint#mock]))", + repr(self.reg)) + + def test_str(self): + self.plugin_ep.__str__ = lambda _: "Mock" + self.plugins["foo"] = "Mock" + self.assertEqual("Mock\n\nMock", str(self.reg)) + self.plugins.clear() + self.assertEqual("No plugins", str(self.reg)) + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py index 5f49ca8ee..49d5a6dd0 100644 --- a/letsencrypt/client/plugins/nginx/configurator.py +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -12,17 +12,20 @@ import zope.interface from letsencrypt.acme import challenges from letsencrypt.client import achallenges -from letsencrypt.client import constants +from letsencrypt.client import constants as core_constants from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import reverter +from letsencrypt.client.plugins import common + +from letsencrypt.client.plugins.nginx import constants from letsencrypt.client.plugins.nginx import dvsni from letsencrypt.client.plugins.nginx import parser -class NginxConfigurator(object): +class NginxConfigurator(common.Plugin): # pylint: disable=too-many-instance-attributes,too-many-public-methods """Nginx configurator. @@ -46,17 +49,29 @@ class NginxConfigurator(object): """ zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + zope.interface.classProvides(interfaces.IPluginFactory) description = "Nginx Web Server" - def __init__(self, config, version=None): + @classmethod + def add_parser_arguments(cls, add): + add("server-root", default=constants.CLI_DEFAULTS["server_root"], + help="Nginx server root directory.") + add("mod-ssl-conf", default=constants.CLI_DEFAULTS["mod_ssl_conf"], + help="Contains standard nginx SSL directives.") + add("ctl", default=constants.CLI_DEFAULTS["ctl"], help="Path to the " + "'nginx' binary, used for 'configtest' and retrieving nginx " + "version number.") + + def __init__(self, *args, **kwargs): """Initialize an Nginx Configurator. :param tup version: version of Nginx as a tuple (1, 4, 7) (used mostly for unittesting) """ - self.config = config + version = kwargs.pop("version", None) + super(NginxConfigurator, self).__init__(*args, **kwargs) # Verify that all directories and files exist with proper permissions if os.geteuid() == 0: @@ -74,21 +89,21 @@ class NginxConfigurator(object): self._enhance_func = {} # TODO: Support at least redirects # Set up reverter - self.reverter = reverter.Reverter(config) + self.reverter = reverter.Reverter(self.config) self.reverter.recovery_routine() # This is called in determine_authenticator and determine_installer def prepare(self): """Prepare the authenticator/installer.""" self.parser = parser.NginxParser( - self.config.nginx_server_root, - self.config.nginx_mod_ssl_conf) + self.conf('server-root'), + self.conf('mod-ssl-conf')) # Set Version if self.version is None: self.version = self.get_version() - temp_install(self.config.nginx_mod_ssl_conf) + temp_install(self.conf('mod-ssl-conf')) # Entry point in main.py for installing cert def deploy_cert(self, domain, cert, key, cert_chain=None): @@ -312,7 +327,7 @@ class NginxConfigurator(object): :rtype: bool """ - return nginx_restart(self.config.nginx_ctl) + return nginx_restart(self.conf('ctl')) def config_test(self): # pylint: disable=no-self-use """Check the configuration of Nginx for errors. @@ -323,7 +338,7 @@ class NginxConfigurator(object): """ try: proc = subprocess.Popen( - [self.config.nginx_ctl, "-t"], + [self.conf('ctl'), "-t"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) stdout, stderr = proc.communicate() @@ -350,11 +365,11 @@ class NginxConfigurator(object): """ uid = os.geteuid() le_util.make_or_verify_dir( - self.config.work_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.work_dir, core_constants.CONFIG_DIRS_MODE, uid) le_util.make_or_verify_dir( - self.config.backup_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.backup_dir, core_constants.CONFIG_DIRS_MODE, uid) le_util.make_or_verify_dir( - self.config.config_dir, constants.CONFIG_DIRS_MODE, uid) + self.config.config_dir, core_constants.CONFIG_DIRS_MODE, uid) def get_version(self): """Return version of Nginx Server. @@ -370,13 +385,13 @@ class NginxConfigurator(object): """ try: proc = subprocess.Popen( - [self.config.nginx_ctl, "-V"], + [self.conf('ctl'), "-V"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) text = proc.communicate()[1] # nginx prints output to stderr except (OSError, ValueError): raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -V" % self.config.nginx_ctl) + "Unable to run %s -V" % self.conf('ctl')) version_regex = re.compile(r"nginx/([0-9\.]*)", re.IGNORECASE) version_matches = version_regex.findall(text) @@ -561,4 +576,4 @@ def temp_install(options_ssl): # Check to make sure options-ssl.conf is installed if not os.path.isfile(options_ssl): - shutil.copyfile(constants.NGINX_MOD_SSL_CONF, options_ssl) + shutil.copyfile(constants.MOD_SSL_CONF, options_ssl) diff --git a/letsencrypt/client/plugins/nginx/constants.py b/letsencrypt/client/plugins/nginx/constants.py new file mode 100644 index 000000000..17d05f438 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/constants.py @@ -0,0 +1,16 @@ +"""nginx plugin constants.""" +import pkg_resources + + +CLI_DEFAULTS = dict( + server_root="/etc/nginx", + mod_ssl_conf="/etc/letsencrypt/options-ssl-nginx.conf", + ctl="nginx", +) +"""CLI defaults.""" + + +MOD_SSL_CONF = pkg_resources.resource_filename( + "letsencrypt.client.plugins.nginx", "options-ssl.conf") +"""Path to the Nginx mod_ssl config file found in the Let's Encrypt +distribution.""" diff --git a/letsencrypt/client/plugins/nginx/tests/util.py b/letsencrypt/client/plugins/nginx/tests/util.py index 58c5730cf..a1630b61f 100644 --- a/letsencrypt/client/plugins/nginx/tests/util.py +++ b/letsencrypt/client/plugins/nginx/tests/util.py @@ -7,7 +7,7 @@ import unittest import mock -from letsencrypt.client import constants +from letsencrypt.client.plugins.nginx import constants from letsencrypt.client.plugins.nginx import configurator @@ -54,7 +54,7 @@ def dir_setup(test_dir="debian_nginx/two_vhost_80"): def setup_nginx_ssl_options(config_dir): """Move the ssl_options into position and return the path.""" option_path = os.path.join(config_dir, "options-ssl.conf") - shutil.copyfile(constants.NGINX_MOD_SSL_CONF, option_path) + shutil.copyfile(constants.MOD_SSL_CONF, option_path) return option_path @@ -65,12 +65,13 @@ def get_nginx_configurator( backups = os.path.join(work_dir, "backups") config = configurator.NginxConfigurator( - mock.MagicMock( + config=mock.MagicMock( nginx_server_root=config_path, nginx_mod_ssl_conf=ssl_options, le_vhost_ext="-le-ssl.conf", backup_dir=backups, config_dir=config_dir, work_dir=work_dir, temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), in_progress_dir=os.path.join(backups, "IN_PROGRESS")), - version) + name="nginx", + version=version) config.prepare() return config diff --git a/letsencrypt/client/plugins/standalone/authenticator.py b/letsencrypt/client/plugins/standalone/authenticator.py index 9a812bdc7..46c2032e5 100644 --- a/letsencrypt/client/plugins/standalone/authenticator.py +++ b/letsencrypt/client/plugins/standalone/authenticator.py @@ -17,8 +17,10 @@ from letsencrypt.acme import challenges from letsencrypt.client import achallenges from letsencrypt.client import interfaces +from letsencrypt.client.plugins import common -class StandaloneAuthenticator(object): + +class StandaloneAuthenticator(common.Plugin): # pylint: disable=too-many-instance-attributes """Standalone authenticator. @@ -29,10 +31,12 @@ class StandaloneAuthenticator(object): """ zope.interface.implements(interfaces.IAuthenticator) + zope.interface.classProvides(interfaces.IPluginFactory) description = "Standalone Authenticator" - def __init__(self, unused_config): + def __init__(self, *args, **kwargs): + super(StandaloneAuthenticator, self).__init__(*args, **kwargs) self.child_pid = None self.parent_pid = os.getpid() self.subproc_state = None diff --git a/letsencrypt/client/plugins/standalone/tests/authenticator_test.py b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py index c69e5399e..288a04fcc 100644 --- a/letsencrypt/client/plugins/standalone/tests/authenticator_test.py +++ b/letsencrypt/client/plugins/standalone/tests/authenticator_test.py @@ -59,7 +59,7 @@ class ChallPrefTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) def test_chall_pref(self): self.assertEqual(self.authenticator.get_chall_pref("example.com"), @@ -71,7 +71,7 @@ class SNICallbackTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.cert = achallenges.DVSNI( challb=acme_util.DVSNI_P, domain="example.com", key=KEY).gen_cert_and_response()[0] @@ -109,7 +109,7 @@ class ClientSignalHandlerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 @@ -138,7 +138,7 @@ class SubprocSignalHandlerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.child_pid = 12345 self.authenticator.parent_pid = 23456 @@ -190,7 +190,7 @@ class AlreadyListeningTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) @mock.patch("letsencrypt.client.plugins.standalone.authenticator.psutil." "net_connections") @@ -297,7 +297,7 @@ class PerformTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.achall1 = achallenges.DVSNI( challb=acme_util.chall_to_challb( @@ -371,7 +371,7 @@ class StartListenerTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "Crypto.Random.atfork") @@ -406,7 +406,7 @@ class DoParentProcessTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) @mock.patch("letsencrypt.client.plugins.standalone.authenticator." "signal.signal") @@ -460,7 +460,7 @@ class DoChildProcessTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.cert = achallenges.DVSNI( challb=acme_util.chall_to_challb( challenges.DVSNI(r=("x" * 32), nonce="abcdef"), "pending"), @@ -549,7 +549,7 @@ class CleanupTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import \ StandaloneAuthenticator - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) self.achall = achallenges.DVSNI( challb=acme_util.chall_to_challb( challenges.DVSNI(r="whee", nonce="foononce"), "pending"), @@ -582,7 +582,7 @@ class MoreInfoTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) def test_more_info(self): """Make sure exceptions aren't raised.""" @@ -594,7 +594,7 @@ class InitTest(unittest.TestCase): def setUp(self): from letsencrypt.client.plugins.standalone.authenticator import ( StandaloneAuthenticator) - self.authenticator = StandaloneAuthenticator(None) + self.authenticator = StandaloneAuthenticator(config=None, name=None) def test_prepare(self): """Make sure exceptions aren't raised. diff --git a/letsencrypt/client/tests/cli_test.py b/letsencrypt/client/tests/cli_test.py new file mode 100644 index 000000000..bd25a9792 --- /dev/null +++ b/letsencrypt/client/tests/cli_test.py @@ -0,0 +1,35 @@ +"""Tests for letsencrypt.client.cli.""" +import itertools +import unittest + +import mock + + +class CLITest(unittest.TestCase): + """Tests for different commands.""" + + @classmethod + def _call(cls, args): + from letsencrypt.client import cli + args = ['--text'] + args + with mock.patch("letsencrypt.client.cli.sys.stdout") as stdout: + with mock.patch("letsencrypt.client.cli.sys.stderr") as stderr: + ret = cli.main(args) + return ret, stdout, stderr + + def test_no_flags(self): + self.assertRaises(SystemExit, self._call, []) + + def test_help(self): + self.assertRaises(SystemExit, self._call, ['--help']) + + def test_plugins(self): + flags = ['--init', '--prepare', '--authenticators', '--installers'] + for args in itertools.chain( + *(itertools.combinations(flags, r) + for r in xrange(len(flags)))): + self._call(['plugins',] + list(args)) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 2a50af93c..33530a083 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -8,7 +8,6 @@ import mock from letsencrypt.client import account from letsencrypt.client import configuration -from letsencrypt.client import errors from letsencrypt.client import le_util @@ -54,92 +53,6 @@ class DetermineAccountTest(unittest.TestCase): self.assertTrue(chosen_acc.email, test_acc.email) -class DetermineAuthenticatorTest(unittest.TestCase): - def setUp(self): - from letsencrypt.client.plugins.apache.configurator import ( - ApacheConfigurator) - from letsencrypt.client.plugins.standalone.authenticator import ( - StandaloneAuthenticator) - - self.mock_stand = mock.MagicMock( - spec=StandaloneAuthenticator, description="Apache Web Server") - self.mock_apache = mock.MagicMock( - spec=ApacheConfigurator, description="Standalone Authenticator") - - self.mock_config = mock.MagicMock( - spec=configuration.NamespaceConfig, authenticator=None) - - self.all_auths = { - 'apache': self.mock_apache, - 'standalone': self.mock_stand - } - - @classmethod - def _call(cls, all_auths, config): - from letsencrypt.client.client import determine_authenticator - return determine_authenticator(all_auths, config) - - @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") - def test_accept_two(self, mock_choose): - mock_choose.return_value = self.mock_stand() - self.assertEqual(self._call(self.all_auths, self.mock_config), - self.mock_stand()) - - def test_accept_one(self): - self.mock_apache.prepare.return_value = self.mock_apache - one_avail_auth = { - 'apache': self.mock_apache - } - self.assertEqual(self._call(one_avail_auth, self.mock_config), - self.mock_apache) - - def test_no_installation_one(self): - self.mock_apache.prepare.side_effect = ( - errors.LetsEncryptNoInstallationError) - - self.assertEqual(self._call(self.all_auths, self.mock_config), - self.mock_stand) - - def test_no_installations(self): - self.mock_apache.prepare.side_effect = ( - errors.LetsEncryptNoInstallationError) - self.mock_stand.prepare.side_effect = ( - errors.LetsEncryptNoInstallationError) - - self.assertRaises(errors.LetsEncryptClientError, - self._call, - self.all_auths, - self.mock_config) - - @mock.patch("letsencrypt.client.client.logging") - @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") - def test_misconfigured(self, mock_choose, unused_log): - self.mock_apache.prepare.side_effect = ( - errors.LetsEncryptMisconfigurationError) - mock_choose.return_value = self.mock_apache - - self.assertTrue(self._call(self.all_auths, self.mock_config) is None) - - def test_choose_valid_auth_from_cmd_line(self): - standalone_config = mock.MagicMock(spec=configuration.NamespaceConfig, - authenticator='standalone') - self.assertEqual(self._call(self.all_auths, standalone_config), - self.mock_stand) - - apache_config = mock.MagicMock(spec=configuration.NamespaceConfig, - authenticator='apache') - self.assertEqual(self._call(self.all_auths, apache_config), - self.mock_apache) - - def test_choose_invalid_auth_from_cmd_line(self): - invalid_config = mock.MagicMock(spec=configuration.NamespaceConfig, - authenticator='foobar') - self.assertRaises(errors.LetsEncryptClientError, - self._call, - self.all_auths, - invalid_config) - - class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): @@ -150,23 +63,22 @@ class RollbackTest(unittest.TestCase): @classmethod def _call(cls, checkpoints): from letsencrypt.client.client import rollback - rollback(checkpoints, mock.MagicMock()) + rollback(None, checkpoints, {}, mock.MagicMock()) - @mock.patch("letsencrypt.client.client.determine_installer") - def test_no_problems(self, mock_det): - mock_det.side_effect = self.m_install + @mock.patch("letsencrypt.client.client.display_ops.pick_installer") + def test_no_problems(self, mock_pick_installer): + mock_pick_installer.side_effect = self.m_install self._call(1) self.assertEqual(self.m_install().rollback_checkpoints.call_count, 1) self.assertEqual(self.m_install().restart.call_count, 1) - @mock.patch("letsencrypt.client.client.determine_installer") - def test_no_installer(self, mock_det): - mock_det.return_value = None - - # Just make sure no exceptions are raised + @mock.patch("letsencrypt.client.client.display_ops.pick_installer") + def test_no_installer(self, mock_pick_installer): + mock_pick_installer.return_value = None self._call(1) + # Just make sure no exceptions are raised if __name__ == "__main__": diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index de5745e8e..4716a5b11 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -8,34 +8,35 @@ import mock import zope.component from letsencrypt.client import account +from letsencrypt.client import interfaces from letsencrypt.client import le_util + from letsencrypt.client.display import util as display_util -class ChooseAuthenticatorTest(unittest.TestCase): - """Test choose_authenticator function.""" + +class ChoosePluginTest(unittest.TestCase): + """Tests for letsencrypt.client.display.ops.choose_plugin.""" + def setUp(self): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - self.mock_apache = mock.Mock() - self.mock_stand = mock.Mock() - self.mock_apache().more_info.return_value = "Apache Info" - self.mock_stand().more_info.return_value = "Standalone Info" + self.mock_apache = mock.Mock( + description_with_name="a", misconfigured=True) + self.mock_stand = mock.Mock( + description_with_name="s", misconfigured=False) + self.mock_stand.init().more_info.return_value = "standalone" + self.plugins = [ + self.mock_apache, + self.mock_stand, + ] - self.auths = [self.mock_apache, self.mock_stand] - - self.errs = {self.mock_apache: "This is an error message."} - - @classmethod - def _call(cls, auths, errs): - from letsencrypt.client.display.ops import choose_authenticator - return choose_authenticator(auths, errs) + def _call(self): + from letsencrypt.client.display.ops import choose_plugin + return choose_plugin(self.plugins, "Question?") @mock.patch("letsencrypt.client.display.ops.util") def test_successful_choice(self, mock_util): mock_util().menu.return_value = (display_util.OK, 0) - - ret = self._call(self.auths, {}) - - self.assertEqual(ret, self.mock_apache) + self.assertEqual(self.mock_apache, self._call()) @mock.patch("letsencrypt.client.display.ops.util") def test_more_info(self, mock_util): @@ -45,15 +46,100 @@ class ChooseAuthenticatorTest(unittest.TestCase): (display_util.OK, 1), ] - ret = self._call(self.auths, self.errs) - + self.assertEqual(self.mock_stand, self._call()) self.assertEqual(mock_util().notification.call_count, 2) - self.assertEqual(ret, self.mock_stand) @mock.patch("letsencrypt.client.display.ops.util") def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertTrue(self._call(self.auths, {}) is None) + self.assertTrue(self._call() is None) + + +class PickPluginTest(unittest.TestCase): + """Tests for letsencrypt.client.display.ops.pick_plugin.""" + + def setUp(self): + self.config = mock.Mock() + self.default = None + self.reg = mock.MagicMock() + self.question = "Question?" + self.ifaces = [] + + def _call(self): + from letsencrypt.client.display.ops import pick_plugin + return pick_plugin(self.config, self.default, self.reg, + self.question, self.ifaces) + + def test_default_provided(self): + self.default = "foo" + self._call() + self.reg.filter.assert_called_once() + + def test_no_default(self): + self._call() + self.reg.filter.assert_called_once() + + def test_no_candidate(self): + self.assertTrue(self._call() is None) + + def test_single(self): + plugin_ep = mock.MagicMock() + plugin_ep.init.return_value = "foo" + self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep} + self.assertEqual("foo", self._call()) + + def test_multiple(self): + plugin_ep = mock.MagicMock() + plugin_ep.init.return_value = "foo" + self.reg.ifaces().verify().available.return_value = { + "bar": plugin_ep, + "baz": plugin_ep, + } + with mock.patch("letsencrypt.client.display" + ".ops.choose_plugin") as mock_choose: + mock_choose.return_value = plugin_ep + self.assertEqual("foo", self._call()) + mock_choose.assert_called_once_with( + [plugin_ep, plugin_ep], self.question) + + def test_choose_plugin_none(self): + self.reg.ifaces().verify().available.return_value = { + "bar": None, + "baz": None, + } + + with mock.patch("letsencrypt.client.display" + ".ops.choose_plugin") as mock_choose: + mock_choose.return_value = None + self.assertTrue(self._call() is None) + + +class ConveniencePickPluginTest(unittest.TestCase): + """Tests for letsencrypt.client.display.ops.pick_*.""" + + def _test(self, fun, ifaces): + config = mock.Mock() + default = mock.Mock() + plugins = mock.Mock() + + with mock.patch("letsencrypt.client.display.ops.pick_plugin") as mock_p: + mock_p.return_value = "foo" + self.assertEqual("foo", fun(config, default, plugins, "Question?")) + mock_p.assert_called_once_with( + config, default, plugins, "Question?", ifaces) + + def test_authenticator(self): + from letsencrypt.client.display.ops import pick_authenticator + self._test(pick_authenticator, (interfaces.IAuthenticator,)) + + def test_installer(self): + from letsencrypt.client.display.ops import pick_installer + self._test(pick_installer, (interfaces.IInstaller,)) + + def test_configurator(self): + from letsencrypt.client.display.ops import pick_configurator + self._test(pick_configurator, ( + interfaces.IAuthenticator, interfaces.IInstaller)) class ChooseAccountTest(unittest.TestCase): diff --git a/letsencrypt/scripts/__init__.py b/letsencrypt/scripts/__init__.py deleted file mode 100644 index 3860534ca..000000000 --- a/letsencrypt/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Let's Encrypt scripts.""" diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py deleted file mode 100644 index 254df5bdd..000000000 --- a/letsencrypt/scripts/main.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Parse command line and call the appropriate functions. - -.. todo:: Sanity check all input. Be sure to avoid shell code etc... - -""" -import argparse -import logging -import os -import pkg_resources -import sys - -import confargparse -import zope.component -import zope.interface.exceptions -import zope.interface.verify - -import letsencrypt - -from letsencrypt.client import account -from letsencrypt.client import configuration -from letsencrypt.client import constants -from letsencrypt.client import client -from letsencrypt.client import errors -from letsencrypt.client import interfaces -from letsencrypt.client import le_util -from letsencrypt.client import log - -from letsencrypt.client.display import util as display_util -from letsencrypt.client.display import ops as display_ops - - -SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT = "letsencrypt.authenticators" -"""Setuptools entry point group name for Authenticator plugins.""" - - -def init_auths(config): - """Find (setuptools entry points) and initialize Authenticators.""" - # TODO: handle collisions in authenticator names. Or is this - # already handled for us by pkg_resources? - auths = {} - for entrypoint in pkg_resources.iter_entry_points( - SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): - auth_cls = entrypoint.load() - auth = auth_cls(config) - try: - zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) - except zope.interface.exceptions.BrokenImplementation: - logging.debug( - "%r object does not provide IAuthenticator, skipping", - entrypoint.name) - else: - auths[entrypoint.name] = auth - return auths - - -def create_parser(): - """Create parser.""" - parser = confargparse.ConfArgParser( - description="letsencrypt client %s" % letsencrypt.__version__) - - add = parser.add_argument - config_help = lambda name: interfaces.IConfig[name].__doc__ - - add("-d", "--domains", metavar="DOMAIN", nargs="+") - add("-s", "--server", - default="www.letsencrypt-demo.org/acme/new-reg", - help=config_help("server")) - - # TODO: we should generate the list of choices from the set of - # available authenticators, but that is tricky due to the - # dependency between init_auths and config. Hardcoding it for now. - add("-a", "--authenticator", dest="authenticator", - help=config_help("authenticator")) - - add("-k", "--authkey", type=read_file, - help="Path to the authorized key file") - add("-m", "--email", type=str, - help="Email address used for account registration.") - add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", - help=config_help("rsa_key_size")) - - add("-R", "--revoke", action="store_true", - help="Revoke a certificate from a menu.") - add("--revoke-certificate", dest="rev_cert", type=read_file, - help="Revoke a specific certificate.") - add("--revoke-key", dest="rev_key", type=read_file, - help="Revoke all certs generated by the provided authorized key.") - - 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("--no-confirm", dest="no_confirm", action="store_true", - help="Turn off confirmation screens, currently used for --revoke") - - add("-e", "--agree-tos", dest="tos", 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")) - - add("--nginx-server-root", default="/etc/nginx", - help=config_help("nginx_server_root")) - add("--nginx-mod-ssl-conf", - default="/etc/letsencrypt/options-ssl-nginx.conf", - help=config_help("nginx_mod_ssl_conf")) - add("--nginx-ctl", default="nginx", help=config_help("nginx_ctl")) - - return parser - - -def main(): # pylint: disable=too-many-branches, too-many-statements - """Command line argument parsing and main script execution.""" - # note: arg parser internally handles --help (and exits afterwards) - 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: - sys.exit( - "{0}Root is required to run letsencrypt. Please use sudo.{0}" - .format(os.linesep)) - - # Set up logging - logger = logging.getLogger() - logger.setLevel(logging.INFO) - if args.use_curses: - logger.addHandler(log.DialogHandler()) - displayer = display_util.NcursesDisplay() - else: - displayer = display_util.FileDisplay(sys.stdout) - - zope.component.provideUtility(displayer) - - if args.view_config_changes: - client.view_config_changes(config) - sys.exit() - - if args.revoke or args.rev_cert is not None or args.rev_key is not None: - # This depends on the renewal config and cannot be completed yet. - zope.component.getUtility(interfaces.IDisplay).notification( - "Revocation is not available with the new Boulder server yet.") - - # client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) - sys.exit() - - if args.rollback > 0: - client.rollback(args.rollback, config) - sys.exit() - - le_util.make_or_verify_dir( - config.config_dir, constants.CONFIG_DIRS_MODE, os.geteuid()) - - # Prepare for init of Client - if args.email is None: - acc = client.determine_account(config) - else: - try: - # The way to get the default would be args.email = "" - # First try existing account - acc = account.Account.from_existing_account(config, args.email) - except errors.LetsEncryptClientError: - try: - # Try to make an account based on the email address - acc = account.Account.from_email(config, args.email) - except errors.LetsEncryptClientError: - sys.exit(1) - - if acc is None: - sys.exit(0) - - all_auths = init_auths(config) - logging.debug('Initialized authenticators: %s', all_auths.keys()) - try: - auth = client.determine_authenticator(all_auths, config) - logging.debug("Selected authenticator: %s", auth) - except errors.LetsEncryptClientError as err: - logging.critical(str(err)) - sys.exit(1) - - if auth is None: - sys.exit(0) - - # Use the same object if possible - if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member - installer = auth - else: - # This is simple and avoids confusion right now. - installer = None - - if args.domains is None: - doms = display_ops.choose_names(installer) - else: - doms = args.domains - - if not doms: - sys.exit(0) - - acme = client.Client(config, acc, auth, installer) - - # Validate the key and csr - client.validate_key_csr(acc.key) - - # This more closely mimics the capabilities of the CLI - # It should be possible for reconfig only, install-only, no-install - # I am not sure the best way to handle all of the unimplemented abilities, - # but this code should be safe on all environments. - cert_file = None - if auth is not None: - if acc.regr is None: - try: - acme.register() - except errors.LetsEncryptClientError: - sys.exit(0) - cert_key, cert_file, chain_file = acme.obtain_certificate(doms) - if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, cert_key, cert_file, chain_file) - if installer is not None: - acme.enhance_config(doms, args.redirect) - - -def read_file(filename): - """Returns the given file's contents with universal new line support. - - :param str filename: Filename - - :returns: A tuple of filename and its contents - :rtype: tuple - - :raises argparse.ArgumentTypeError: File does not exist or is not readable. - - """ - try: - return filename, open(filename, "rU").read() - except IOError as exc: - raise argparse.ArgumentTypeError(exc.strerror) - - -if __name__ == "__main__": - main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..0f0223dab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# https://github.com/bw2/ConfigArgParse/issues/17 +-e git+https://github.com/kuba/ConfigArgParse.git@python2.6#egg=ConfigArgParse +-e . diff --git a/setup.py b/setup.py index d8728f5e2..a892937e2 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', - 'ConfArgParse', + 'ConfigArgParse', 'configobj', 'jsonschema', 'mock', @@ -112,7 +112,6 @@ setup( 'letsencrypt.client.plugins.standalone.tests', 'letsencrypt.client.tests', 'letsencrypt.client.tests.display', - 'letsencrypt.scripts', ], install_requires=install_requires, @@ -127,10 +126,10 @@ setup( entry_points={ 'console_scripts': [ - 'letsencrypt = letsencrypt.scripts.main:main', + 'letsencrypt = letsencrypt.client.cli:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', ], - 'letsencrypt.authenticators': [ + 'letsencrypt.plugins': [ 'apache = letsencrypt.client.plugins.apache.configurator' ':ApacheConfigurator', 'nginx = letsencrypt.client.plugins.nginx.configurator' diff --git a/tox.ini b/tox.ini index 47b509203..cd6f3c7b0 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist = py26,py27,cover,lint [testenv] commands = - pip install -e .[testing] + pip install -r requirements.txt -e .[testing] python setup.py test -q # -q does not suppress errors setenv =