From 8bc55899e64a7b20ba6d8e2e00063865ee9f2d88 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Mon, 30 Mar 2015 12:34:22 +0000 Subject: [PATCH] Subparsers CLI --- examples/plugins/setup.py | 2 +- letsencrypt/client/constants.py | 4 + letsencrypt/scripts/main.py | 378 +++++++++++++++++++++----------- setup.py | 2 +- 4 files changed, 254 insertions(+), 132 deletions(-) diff --git a/examples/plugins/setup.py b/examples/plugins/setup.py index 845d6eb66..599d57020 100644 --- a/examples/plugins/setup.py +++ b/examples/plugins/setup.py @@ -9,7 +9,7 @@ setup( 'zope.interface', ], entry_points={ - 'letsencrypt.authenticators': [ + 'letsencrypt.plugins': [ 'example = letsencrypt_example_plugins:Authenticator', ], }, diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 8f2d083ef..9541aacac 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -1,4 +1,5 @@ """Let's Encrypt constants.""" +import logging import pkg_resources from letsencrypt.acme import challenges @@ -7,6 +8,9 @@ from letsencrypt.acme import challenges SETUPTOOLS_PLUGINS_ENTRY_POINT = "letsencrypt.plugins" """Setuptools entry point group name for plugins.""" +DEFAULT_VERBOSE_COUNT = -(logging.WARNING / 10) + + S_SIZE = 32 """Size (in bytes) of secret base64-encoded octet string "s" used in challenges.""" diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 3b4b7c10d..cc89510cf 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -1,11 +1,8 @@ -"""Parse command line and call the appropriate functions. - -.. todo:: Sanity check all input. Be sure to avoid shell code etc... - -""" +"""Let's Encrypt Client.""" +# TODO: Sanity check all input. Be sure to avoid shell code etc... import argparse +import collections import logging -import os import pkg_resources import sys @@ -17,46 +14,254 @@ import zope.interface.verify import letsencrypt 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.""" +from letsencrypt.client.plugins import disco as plugins_disco -def init_auths(config): - """Find (setuptools entry points) and initialize Authenticators.""" - 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) +def _common_run(args, config, authenticator, installer): + if args.domains is None: + doms = display_ops.choose_names(installer) + else: + doms = args.domains + + if not doms: + return + + # Prepare for init of Client + if args.authkey is None: + authkey = client.init_key(config.rsa_key_size, config.key_dir) + else: + authkey = le_util.Key(args.authkey[0], args.authkey[1]) + + acme = client.Client(config, authkey, authenticator, installer) + + # Validate the key and csr + client.validate_key_csr(authkey) + + return acme, doms, authkey + + +def run(args, config): + """Obtain a certificate and install.""" + if not args.eula: + display_eula() + + 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 = plugins_disco.pick_installer( + config, args.installer) + authenticator = plugins_disco.pick_authenticator( + config, args.authenticator) + else: + authenticator = installer = plugins_disco.pick_configurator( + config, args.configurator) + + if installer is None or authenticator is None: + return "Configurator could not be determined" + + acme, auth, installer, doms, auth_key = _common_run(args, config) + cert_file, chain_file = acme.obtain_certificate(doms) + acme.deploy_certificate(doms, authkey, cert_file, chain_file) + acme.enhance_config(doms, args.redirect) + + +def auth(args, config): + """Obtain a certificate (no install).""" + authenticator = plugins_disco.pick_authenticator(config, args.authenticator) + if authenticator is None: + return "Authenticator could not be determined" + + if args.installer is not None: + installer = plugins_disco.pick_installer(config, args.installer) + else: + installer = None + + if args.domains is None: + if args.installer is not None: + return ("--domains not set and provided --installer does not " + "help in autodiscovery") else: - auths[auth] = entrypoint.name - return auths + return ("Please specify --domains, or --installer that will " + "help in domain names autodiscovery") + + acme, doms, _ = _common_run( + args, config, authenticator=authenticator, installer=None) + acme.obtain_certificate(doms) + + +def install(args, config): + """Install (no auth).""" + installer = plugins_disco.pick_installer(config, args.installer) + if installer is None: + return "Installer could not be determined" + acme, doms, authkey = _common_run( + args, config, authenticator=None, installer=installer) + assert args.cert_file is not None and args.chain_file is not None + acme.deploy_certificate(doms, authkey, args.cert_file, args.chain_file) + acme.enhance_config(doms, args.redirect) + + +def revoke(args, config): + """Revoke.""" + if args.rev_cert is None and args.rev_key is None: + return "At least one of --certificate or --key is required" + client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) + + +def rollback(args, config): + """Rollback.""" + client.rollback(args.checkpoints, config) + + +def config_changes(args, config): + """View config changes. + + View checkpoints and associated configuration changes. + + """ + print args, config + client.config_changes(config) + + +def _print_plugins(filtered, plugins, names): + if not filtered: + print "No plugins found" + + for plugin_cls, content in filtered.iteritems(): + print "* {0}".format(names[plugin_cls]) + print "Description: {0}".format(plugin_cls.description) + print "Interfaces: {0}".format(", ".join( + iface.__name__ for iface in zope.interface.implementedBy( + plugin_cls))) + print "Entry points:" + for entry_point in plugins[plugin_cls]: + print "- {0.dist}: {0}".format(entry_point) + + # if filtered == prepared: + if isinstance(content, tuple) and content[1] is not None: + print content[1] # error + print + + +def plugins(args, config): + """List plugins.""" + plugins = plugins_disco.find_plugins() + logging.debug("Discovered plugins: %s", plugins) + + names = plugins_disco.name_plugins(plugins) + + ifaces = [] if args.ifaces is None else args.ifaces + filtered = plugins_disco.filter_plugins( + plugins, *((iface,) for iface in ifaces)) + logging.debug("Filtered plugins: %s", filtered) + + if not args.init and not args.prepare: + return _print_plugins(filtered, plugins, names) + + initialized = dict((plugin_cls, plugin_cls(config)) + for plugin_cls in filtered) + verified = plugins_disco.verify_plugins(initialized, ifaces) + logging.debug("Verified plugins: %s", initialized) + + if not args.prepare: + return _print_plugins(initialized, plugins, names) + + prepared = plugins_disco.prepare_plugins(initialized) + logging.debug("Prepared plugins: %s", plugins) + + _print_plugins(prepared, plugins, names) + plugins_disco + + +def display_eula(): + """Displays the end user agreement.""" + eula = pkg_resources.resource_string("letsencrypt", "EULA") + if not zope.component.getUtility(interfaces.IDisplay).yesno( + eula, "Agree", "Cancel"): + sys.exit(0) + + +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 create_parser(): """Create parser.""" - parser = confargparse.ConfArgParser( - description="letsencrypt client %s" % letsencrypt.__version__) + parser = confargparse.ConfArgParser(description=__doc__) + + # --help is automatically provided by argparse + parser.add_argument( + "--version", action="version", version="%(prog)s {0}".format( + letsencrypt.__version__)) + parser.add_argument( + "-v", "--verbose", dest="verbose_count", action="count", + default=constants.DEFAULT_VERBOSE_COUNT) + + subparsers = parser.add_subparsers(metavar="SUBCOMMAND") + def add_subparser(name, func): + subparser = subparsers.add_parser( + name, help=func.__doc__.splitlines()[0], description=func.__doc__) + subparser.set_defaults(func=func) + return subparser + + parser_run = add_subparser("run", run) + parser_auth = add_subparser("auth", auth) + parser_install = add_subparser("install", install) + parser_revoke = add_subparser("revoke", revoke) + parser_rollback = add_subparser("rollback", rollback) + parrser_config_changes = add_subparser("config_changes", config_changes) + + parser_plugins = add_subparser("plugins", plugins) + 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) add = parser.add_argument config_help = lambda name: interfaces.IConfig[name].__doc__ - add("-d", "--domains", metavar="DOMAIN", nargs="+") + parser_run.add_argument("--configurator") + for subparser in parser_run, parser_auth: + subparser.add_argument("-a", "--authenticator") + for subparser in parser_run, parser_auth, parser_install: + # parser_auth uses --installer for domains autodiscovery + subparser.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="letsencrypt-demo.org:443", help=config_help("server")) @@ -65,17 +270,16 @@ def create_parser(): 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, + parser_revoke.add_argument( + "--certificate", dest="rev_cert", type=read_file, metavar="CERT_PATH", help="Revoke a specific certificate.") - add("--revoke-key", dest="rev_key", type=read_file, + parser_revoke.add_argument( + "--key", dest="rev_key", type=read_file, metavar="KEY_PATH", help="Revoke all certs generated by the provided authorized key.") - add("-b", "--rollback", type=int, default=0, metavar="N", + parser_rollback.add_argument( + "--checkpoints", 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", @@ -127,112 +331,26 @@ def main(): # pylint: disable=too-many-branches, too-many-statements 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)) + #if not os.geteuid() == 0: + # return ( + # "{0}Root is required to run letsencrypt. Please use sudo.{0}" + # .format(os.linesep)) # Set up logging + level = -args.verbose_count * 10 logger = logging.getLogger() - logger.setLevel(logging.INFO) + logger.setLevel(level) + logging.debug("Logging level set at %d", level) + # displayer 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: - 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() - - if not args.eula: - display_eula() - - all_auths = init_auths(config) - logging.debug('Initialized authenticators: %s', all_auths.values()) - try: - auth = client.determine_authenticator(all_auths.keys()) - except errors.LetsEncryptClientError: - logging.critical("No authentication mechanisms were found on your " - "system.") - 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) - - # Prepare for init of Client - if args.authkey is None: - authkey = client.init_key(args.rsa_key_size, config.key_dir) - else: - authkey = le_util.Key(args.authkey[0], args.authkey[1]) - - acme = client.Client(config, authkey, auth, installer) - - # Validate the key and csr - client.validate_key_csr(authkey) - - # 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: - cert_file, chain_file = acme.obtain_certificate(doms) - if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, authkey, cert_file, chain_file) - if installer is not None: - acme.enhance_config(doms, args.redirect) - - -def display_eula(): - """Displays the end user agreement.""" - eula = pkg_resources.resource_string("letsencrypt", "EULA") - if not zope.component.getUtility(interfaces.IDisplay).yesno( - eula, "Agree", "Cancel"): - sys.exit(0) - - -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) + return args.func(args, config) if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/setup.py b/setup.py index ca7de3abb..413345125 100644 --- a/setup.py +++ b/setup.py @@ -122,7 +122,7 @@ setup( 'letsencrypt = letsencrypt.scripts.main:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', ], - 'letsencrypt.authenticators': [ + 'letsencrypt.plugins': [ 'apache = letsencrypt.client.plugins.apache.configurator' ':ApacheConfigurator', 'standalone = letsencrypt.client.plugins.standalone.authenticator'