diff --git a/Dockerfile b/Dockerfile index 71e217659..ccbb07b95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,10 +33,11 @@ RUN /opt/letsencrypt/src/letsencrypt-auto-source/letsencrypt-auto --os-packages- # Dockerfile we make sure we cache as much as possible -COPY setup.py README.rst CHANGES.rst MANIFEST.in /opt/letsencrypt/src/ +COPY setup.py README.rst CHANGES.rst MANIFEST.in letsencrypt-auto-source/pieces/pipstrap.py /opt/letsencrypt/src/ -# all above files are necessary for setup.py, however, package source -# code directory has to be copied separately to a subdirectory... +# all above files are necessary for setup.py and venv setup, however, +# package source code directory has to be copied separately to a +# subdirectory... # https://docs.docker.com/reference/builder/#copy: "If is a # directory, the entire contents of the directory are copied, # including filesystem metadata. Note: The directory itself is not @@ -49,7 +50,11 @@ COPY letsencrypt-apache /opt/letsencrypt/src/letsencrypt-apache/ COPY letsencrypt-nginx /opt/letsencrypt/src/letsencrypt-nginx/ -RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ +RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv + +# PATH is set now so pipstrap upgrades the correct (v)env +ENV PATH /opt/letsencrypt/venv/bin:$PATH +RUN /opt/letsencrypt/venv/bin/python /opt/letsencrypt/src/pipstrap.py && \ /opt/letsencrypt/venv/bin/pip install \ -e /opt/letsencrypt/src/acme \ -e /opt/letsencrypt/src \ @@ -61,6 +66,4 @@ RUN virtualenv --no-site-packages -p python2 /opt/letsencrypt/venv && \ # this might also help in debugging: you can "docker run --entrypoint # bash" and investigate, apply patches, etc. -ENV PATH /opt/letsencrypt/venv/bin:$PATH - ENTRYPOINT [ "letsencrypt" ] diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf deleted file mode 100644 index ab4ed412e..000000000 --- a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143.conf +++ /dev/null @@ -1,9 +0,0 @@ - -DocumentRoot /xxxx/ -ServerName noodles.net.nz -ServerAlias www.noodles.net.nz -CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined - - AllowOverride All - - diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf deleted file mode 100644 index 25655a07c..000000000 --- a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/failing/ipv6-1143b.conf +++ /dev/null @@ -1,21 +0,0 @@ - - -DocumentRoot /xxxx/ -ServerName noodles.net.nz -ServerAlias www.noodles.net.nz -CustomLog ${APACHE_LOG_DIR}/domlogs/noodles.log combined - - AllowOverride All - - - SSLEngine on - - SSLHonorCipherOrder On - SSLProtocol all -SSLv2 -SSLv3 - SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH +aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS" - - SSLCertificateFile /xxxx/noodles.net.nz.crt - SSLCertificateKeyFile /xxxx/noodles.net.nz.key - - Header set Strict-Transport-Security "max-age=31536000; preload" - diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143.conf new file mode 100644 index 000000000..ad988dc05 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143.conf @@ -0,0 +1,9 @@ + +DocumentRoot /tmp +ServerName example.com +ServerAlias www.example.com +CustomLog ${APACHE_LOG_DIR}/example.log combined + + AllowOverride All + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143b.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143b.conf new file mode 100644 index 000000000..e2b4fd3da --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143b.conf @@ -0,0 +1,18 @@ + +DocumentRoot /tmp +ServerName example.com +ServerAlias www.example.com +CustomLog ${APACHE_LOG_DIR}/example.log combined + + AllowOverride All + + + SSLEngine on + + SSLHonorCipherOrder On + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH +aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS" + + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143c.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143c.conf new file mode 100644 index 000000000..f2d2ecbea --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143c.conf @@ -0,0 +1,9 @@ + +DocumentRoot /tmp +ServerName example.com +ServerAlias www.example.com +CustomLog ${APACHE_LOG_DIR}/example.log combined + + AllowOverride All + + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143d.conf b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143d.conf new file mode 100644 index 000000000..f5b7a2b45 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/apache-conf-files/passing/ipv6-1143d.conf @@ -0,0 +1,18 @@ + +DocumentRoot /tmp +ServerName example.com +ServerAlias www.example.com +CustomLog ${APACHE_LOG_DIR}/example.log combined + + AllowOverride All + + + SSLEngine on + + SSLHonorCipherOrder On + SSLProtocol all -SSLv2 -SSLv3 + SSLCipherSuite "EECDH+ECDSA+AESGCM EECDH+aRSA+AESGCM EECDH+ECDSA+SHA384 EECDH+ECDSA+SHA256 EECDH+aRSA+SHA384 EECDH+aRSA+SHA256 EECDH+aRSA+RC4 EECDH EDH +aRSA RC4 !aNULL !eNULL !LOW !3DES !MD5 !EXP !PSK !SRP !DSS" + + SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem + SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key + diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/multiple_vhosts/apache2/sites-available/000-default.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/multiple_vhosts/apache2/sites-available/000-default.conf index c759768c5..2bd4e1fe9 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/multiple_vhosts/apache2/sites-available/000-default.conf +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/multiple_vhosts/apache2/sites-available/000-default.conf @@ -1,4 +1,4 @@ - + ServerName ip-172-30-0-17 ServerAdmin webmaster@localhost diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index 928084e3c..2fbfd70c6 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -133,8 +133,9 @@ def get_vh_truth(temp_dir, config_name): obj.VirtualHost( os.path.join(prefix, "000-default.conf"), os.path.join(aug_pre, "000-default.conf/VirtualHost"), - set([obj.Addr.fromstring("*:80")]), False, True, - "ip-172-30-0-17"), + set([obj.Addr.fromstring("*:80"), + obj.Addr.fromstring("[::]:80")]), + False, True, "ip-172-30-0-17"), obj.VirtualHost( os.path.join(prefix, "letsencrypt.conf"), os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 1af4ef898..256e0c801 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -21,9 +21,8 @@ from letsencrypt import errors from letsencrypt import interfaces from letsencrypt import le_util -from letsencrypt.display import ops as display_ops from letsencrypt.plugins import disco as plugins_disco - +import letsencrypt.plugins.selection as plugin_selection logger = logging.getLogger(__name__) @@ -33,9 +32,10 @@ helpful_parser = None # For help strings, figure out how the user ran us. # When invoked from letsencrypt-auto, sys.argv[0] is something like: # "/home/user/.local/share/letsencrypt/bin/letsencrypt" -# Note that this won't work if the user set VENV_PATH or XDG_DATA_HOME before running -# letsencrypt-auto (and sudo stops us from seeing if they did), so it should only be used -# for purposes where inability to detect letsencrypt-auto fails safely +# Note that this won't work if the user set VENV_PATH or XDG_DATA_HOME before +# running letsencrypt-auto (and sudo stops us from seeing if they did), so it +# should only be used for purposes where inability to detect letsencrypt-auto +# fails safely fragment = os.path.join(".local", "share", "letsencrypt") cli_command = "letsencrypt-auto" if fragment in sys.argv[0] else "letsencrypt" @@ -99,147 +99,6 @@ def usage_strings(plugins): return USAGE % (apache_doc, nginx_doc), SHORT_USAGE -def diagnose_configurator_problem(cfg_type, requested, plugins): - """ - Raise the most helpful error message about a plugin being unavailable - - :param str cfg_type: either "installer" or "authenticator" - :param str requested: the plugin that was requested - :param .PluginsRegistry plugins: available plugins - - :raises error.PluginSelectionError: if there was a problem - """ - - if requested: - if requested not in plugins: - msg = "The requested {0} plugin does not appear to be installed".format(requested) - else: - msg = ("The {0} plugin is not working; there may be problems with " - "your existing configuration.\nThe error was: {1!r}" - .format(requested, plugins[requested].problem)) - elif cfg_type == "installer": - if os.path.exists("/etc/debian_version"): - # Debian... installers are at least possible - msg = ('No installers seem to be present and working on your system; ' - 'fix that or try running letsencrypt with the "certonly" command') - else: - # XXX update this logic as we make progress on #788 and nginx support - msg = ('No installers are available on your OS yet; try running ' - '"letsencrypt-auto certonly" to get a cert you can install manually') - else: - msg = "{0} could not be determined or is not installed".format(cfg_type) - raise errors.PluginSelectionError(msg) - - -def set_configurator(previously, now): - """ - Setting configurators multiple ways is okay, as long as they all agree - :param str previously: previously identified request for the installer/authenticator - :param str requested: the request currently being processed - """ - if not now: - # we're not actually setting anything - return previously - if previously: - if previously != now: - msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}" - raise errors.PluginSelectionError(msg.format(repr(previously), repr(now))) - return now - - -def cli_plugin_requests(config): - """ - Figure out which plugins the user requested with CLI and config options - - :returns: (requested authenticator string or None, requested installer string or None) - :rtype: tuple - """ - req_inst = req_auth = config.configurator - req_inst = set_configurator(req_inst, config.installer) - req_auth = set_configurator(req_auth, config.authenticator) - if config.nginx: - req_inst = set_configurator(req_inst, "nginx") - req_auth = set_configurator(req_auth, "nginx") - if config.apache: - req_inst = set_configurator(req_inst, "apache") - req_auth = set_configurator(req_auth, "apache") - if config.standalone: - req_auth = set_configurator(req_auth, "standalone") - if config.webroot: - req_auth = set_configurator(req_auth, "webroot") - if config.manual: - req_auth = set_configurator(req_auth, "manual") - logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst) - return req_auth, req_inst - - -noninstaller_plugins = ["webroot", "manual", "standalone"] - - -def choose_configurator_plugins(config, plugins, verb): - """ - Figure out which configurator we're going to use, modifies - config.authenticator and config.istaller strings to reflect that choice if - necessary. - - :raises errors.PluginSelectionError if there was a problem - - :returns: (an `IAuthenticator` or None, an `IInstaller` or None) - :rtype: tuple - """ - - req_auth, req_inst = cli_plugin_requests(config) - - # Which plugins do we need? - if verb == "run": - need_inst = need_auth = True - if req_auth in noninstaller_plugins and not req_inst: - msg = ('With the {0} plugin, you probably want to use the "certonly" command, eg:{1}' - '{1} {2} certonly --{0}{1}{1}' - '(Alternatively, add a --installer flag. See https://eff.org/letsencrypt-plugins' - '{1} and "--help plugins" for more information.)'.format( - req_auth, os.linesep, cli_command)) - - raise errors.MissingCommandlineFlag(msg) - else: - need_inst = need_auth = False - if verb == "certonly": - need_auth = True - if verb == "install": - need_inst = True - if config.authenticator: - logger.warn("Specifying an authenticator doesn't make sense in install mode") - - # Try to meet the user's request and/or ask them to pick plugins - authenticator = installer = None - if verb == "run" and req_auth == req_inst: - # Unless the user has explicitly asked for different auth/install, - # only consider offering a single choice - authenticator = installer = display_ops.pick_configurator(config, req_inst, plugins) - else: - if need_inst or req_inst: - installer = display_ops.pick_installer(config, req_inst, plugins) - if need_auth: - authenticator = display_ops.pick_authenticator(config, req_auth, plugins) - logger.debug("Selected authenticator %s and installer %s", authenticator, installer) - - # Report on any failures - if need_inst and not installer: - diagnose_configurator_problem("installer", req_inst, plugins) - if need_auth and not authenticator: - diagnose_configurator_problem("authenticator", req_auth, plugins) - - record_chosen_plugins(config, plugins, authenticator, installer) - return installer, authenticator - - -def record_chosen_plugins(config, plugins, auth, inst): - "Update the config entries to reflect the plugins we actually selected." - cn = config.namespace - cn.authenticator = plugins.find_init(auth).name if auth else "None" - cn.installer = plugins.find_init(inst).name if inst else "None" - - def set_by_cli(var): """ Return True if a particular config variable has been set by the user @@ -256,7 +115,7 @@ def set_by_cli(var): detector = set_by_cli.detector = prepare_and_parse_args( plugins, reconstructed_args, detect_defaults=True) # propagate plugin requests: eg --standalone modifies config.authenticator - auth, inst = cli_plugin_requests(detector) + auth, inst = plugin_selection.cli_plugin_requests(detector) detector.authenticator = auth if auth else "" detector.installer = inst if inst else "" logger.debug("Default Detector is %r", detector) @@ -321,7 +180,7 @@ def flag_default(name): def config_help(name, hidden=False): - """Help message for `.IConfig` attribute.""" + """Extract the help message for an `.IConfig` attribute.""" if hidden: return argparse.SUPPRESS else: diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 42f087739..da2e1f086 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -27,6 +27,7 @@ from letsencrypt import storage from letsencrypt.display import ops as display_ops from letsencrypt.display import enhancements +from letsencrypt.plugins import selection as plugin_selection logger = logging.getLogger(__name__) @@ -534,7 +535,7 @@ def rollback(default_installer, checkpoints, config, plugins): """ # Misconfigurations are only a slight problems... allow the user to rollback - installer = display_ops.pick_installer( + installer = plugin_selection.pick_installer( config, default_installer, plugins, question="Which installer " "should be used for rollback?") diff --git a/letsencrypt/display/completer.py b/letsencrypt/display/completer.py new file mode 100644 index 000000000..fed476bb3 --- /dev/null +++ b/letsencrypt/display/completer.py @@ -0,0 +1,61 @@ +"""Provides Tab completion when prompting users for a path.""" +import glob +# readline module is not available on all systems +try: + import readline +except ImportError: + import letsencrypt.display.dummy_readline as readline + + +class Completer(object): + """Provides Tab completion when prompting users for a path. + + This class is meant to be used with readline to provide Tab + completion for users entering paths. The complete method can be + passed to readline.set_completer directly, however, this function + works best as a context manager. For example: + + with Completer(): + raw_input() + + In this example, Tab completion will be available during the call to + raw_input above, however, readline will be restored to its previous + state when exiting the body of the with statement. + + """ + + def __init__(self): + self._iter = self._original_completer = self._original_delims = None + + def complete(self, text, state): + """Provides path completion for use with readline. + + :param str text: text to offer completions for + :param int state: which completion to return + + :returns: possible completion for text or ``None`` if all + completions have been returned + :rtype: str + + """ + if state == 0: + self._iter = glob.iglob(text + '*') + return next(self._iter, None) + + def __enter__(self): + self._original_completer = readline.get_completer() + self._original_delims = readline.get_completer_delims() + + readline.set_completer(self.complete) + readline.set_completer_delims(' \t\n;') + + # readline can be implemented using GNU readline or libedit + # which have different configuration syntax + if 'libedit' in readline.__doc__: + readline.parse_and_bind('bind ^I rl_complete') + else: + readline.parse_and_bind('tab: complete') + + def __exit__(self, unused_type, unused_value, unused_traceback): + readline.set_completer_delims(self._original_delims) + readline.set_completer(self._original_completer) diff --git a/letsencrypt/display/dummy_readline.py b/letsencrypt/display/dummy_readline.py new file mode 100644 index 000000000..fb3d807bb --- /dev/null +++ b/letsencrypt/display/dummy_readline.py @@ -0,0 +1,21 @@ +"""A dummy module with no effect for use on systems without readline.""" + + +def get_completer(): + """An empty implementation of readline.get_completer.""" + + +def get_completer_delims(): + """An empty implementation of readline.get_completer_delims.""" + + +def parse_and_bind(unused_command): + """An empty implementation of readline.parse_and_bind.""" + + +def set_completer(unused_function=None): + """An empty implementation of readline.set_completer.""" + + +def set_completer_delims(unused_delims): + """An empty implementation of readline.set_completer_delims.""" diff --git a/letsencrypt/display/ops.py b/letsencrypt/display/ops.py index f0dec8b06..302051b1b 100644 --- a/letsencrypt/display/ops.py +++ b/letsencrypt/display/ops.py @@ -9,130 +9,10 @@ from letsencrypt import interfaces from letsencrypt import le_util from letsencrypt.display import util as display_util - logger = logging.getLogger(__name__) # Define a helper function to avoid verbose code -util = zope.component.getUtility - - -def choose_plugin(prepared, question): - """Allow the user to choose their plugin. - - :param list prepared: List of `~.PluginEntryPoint`. - :param str question: Question to be presented to the user. - - :returns: Plugin entry point chosen by the user. - :rtype: `~.PluginEntryPoint` - - """ - opts = [plugin_ep.description_with_name + - (" [Misconfigured]" if plugin_ep.misconfigured else "") - for plugin_ep in prepared] - - while True: - disp = util(interfaces.IDisplay) - code, index = disp.menu(question, opts, help_label="More Info") - - if code == display_util.OK: - plugin_ep = prepared[index] - if plugin_ep.misconfigured: - util(interfaces.IDisplay).notification( - "The selected plugin encountered an error while parsing " - "your server configuration and cannot be used. The error " - "was:\n\n{0}".format(plugin_ep.prepare()), - height=display_util.HEIGHT, pause=False) - else: - return plugin_ep - elif code == display_util.HELP: - if prepared[index].misconfigured: - msg = "Reported Error: %s" % prepared[index].prepare() - else: - msg = prepared[index].init().more_info() - util(interfaces.IDisplay).notification( - msg, height=display_util.HEIGHT) - else: - return None - - -def pick_plugin(config, default, plugins, question, ifaces): - """Pick plugin. - - :param letsencrypt.interfaces.IConfig: Configuration - :param str default: Plugin name supplied by user or ``None``. - :param letsencrypt.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: - if config.noninteractive_mode: - # it's really bad to auto-select the single available plugin in - # non-interactive mode, because an update could later add a second - # available plugin - raise errors.MissingCommandlineFlag( - "Missing command line flags. For non-interactive execution, " - "you will need to specify a plugin on the command line. Run " - "with '--help plugins' to see a list of options, and see " - "https://eff.org/letsencrypt-plugins for more detail on what " - "the plugins do and how to use them.") - - filtered = plugins.visible().ifaces(ifaces) - - filtered.init(config) - verified = filtered.verify(ifaces) - verified.prepare() - prepared = verified.available() - - if len(prepared) > 1: - logger.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] - logger.debug("Single candidate plugin: %s", plugin_ep) - if plugin_ep.misconfigured: - return None - return plugin_ep.init() - else: - logger.debug("No candidate plugin") - return None - - -def pick_authenticator( - config, default, plugins, question="How would you " - "like to authenticate with the 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)) +z_util = zope.component.getUtility def get_email(more=False, invalid=False): @@ -182,7 +62,7 @@ def choose_account(accounts): # Note this will get more complicated once we start recording authorizations labels = [acc.slug for acc in accounts] - code, index = util(interfaces.IDisplay).menu( + code, index = z_util(interfaces.IDisplay).menu( "Please choose an account", labels) if code == display_util.OK: return accounts[index] @@ -208,7 +88,7 @@ def choose_names(installer): names = get_valid_domains(domains) if not names: - manual = util(interfaces.IDisplay).yesno( + manual = z_util(interfaces.IDisplay).yesno( "No names were found in your configuration files.{0}You should " "specify ServerNames in your config files in order to allow for " "accurate installation of your certificate.{0}" @@ -256,7 +136,7 @@ def _filter_names(names): :rtype: tuple """ - code, names = util(interfaces.IDisplay).checklist( + code, names = z_util(interfaces.IDisplay).checklist( "Which names would you like to activate HTTPS for?", tags=names, cli_flag="--domains") return code, [str(s) for s in names] @@ -265,7 +145,7 @@ def _filter_names(names): def _choose_names_manually(): """Manually input names for those without an installer.""" - code, input_ = util(interfaces.IDisplay).input( + code, input_ = z_util(interfaces.IDisplay).input( "Please enter in your domain name(s) (comma and/or space separated) ", cli_flag="--domains") @@ -300,7 +180,7 @@ def _choose_names_manually(): if retry_message: # We had error in input - retry = util(interfaces.IDisplay).yesno(retry_message) + retry = z_util(interfaces.IDisplay).yesno(retry_message) if retry: return _choose_names_manually() else: @@ -316,7 +196,7 @@ def success_installation(domains): :param list domains: domain names which were enabled """ - util(interfaces.IDisplay).notification( + z_util(interfaces.IDisplay).notification( "Congratulations! You have successfully enabled {0}{1}{1}" "You should test your configuration at:{1}{2}".format( _gen_https_names(domains), @@ -335,7 +215,7 @@ def success_renewal(domains, action): :param str action: can be "reinstall" or "renew" """ - util(interfaces.IDisplay).notification( + z_util(interfaces.IDisplay).notification( "Your existing certificate has been successfully {3}ed, and the " "new certificate has been installed.{1}{1}" "The new certificate covers the following domains: {0}{1}{1}" diff --git a/letsencrypt/display/util.py b/letsencrypt/display/util.py index 84049c47c..20c6be156 100644 --- a/letsencrypt/display/util.py +++ b/letsencrypt/display/util.py @@ -7,10 +7,18 @@ import zope.interface from letsencrypt import interfaces from letsencrypt import errors +from letsencrypt.display import completer WIDTH = 72 HEIGHT = 20 +DSELECT_HELP = ( + "Use the arrow keys or Tab to move between window elements. Space can be " + "used to complete the input path with the selected element in the " + "directory window. Pressing enter will select the currently highlighted " + "button.") +"""Help text on how to use dialog's dselect.""" + # Display exit codes OK = "ok" """Display exit code indicating user acceptance.""" @@ -21,6 +29,7 @@ CANCEL = "cancel" HELP = "help" """Display exit code when for when the user requests more help.""" + def _wrap_lines(msg): """Format lines nicely to 80 chars. @@ -36,6 +45,7 @@ def _wrap_lines(msg): fixed_l.append(textwrap.fill(line, 80)) return os.linesep.join(fixed_l) + @zope.interface.implementer(interfaces.IDisplay) class NcursesDisplay(object): """Ncurses-based display.""" @@ -118,7 +128,6 @@ class NcursesDisplay(object): return code, int(index) - 1 - def input(self, message, **unused_kwargs): """Display an input box to the user. @@ -132,11 +141,10 @@ class NcursesDisplay(object): """ sections = message.split("\n") # each section takes at least one line, plus extras if it's longer than self.width - wordlines = [1 + (len(section)/self.width) for section in sections] + wordlines = [1 + (len(section) / self.width) for section in sections] height = 6 + sum(wordlines) + len(sections) return self.dialog.inputbox(message, width=self.width, height=height) - def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs): """Display a Yes/No dialog box. @@ -174,6 +182,21 @@ class NcursesDisplay(object): return self.dialog.checklist( message, width=self.width, height=self.height, choices=choices) + def directory_select(self, message, **unused_kwargs): + """Display a directory selection screen. + + :param str message: prompt to give the user + + :returns: tuple of the form (`code`, `string`) where + `code` - int display exit code + `string` - input entered by the user + + """ + root_directory = os.path.abspath(os.sep) + return self.dialog.dselect( + filepath=root_directory, width=self.width, + height=self.height, help_button=True, title=message) + @zope.interface.implementer(interfaces.IDisplay) class FileDisplay(object): @@ -317,6 +340,19 @@ class FileDisplay(object): else: return code, [] + def directory_select(self, message, **unused_kwargs): + """Display a directory selection screen. + + :param str message: prompt to give the user + + :returns: tuple of the form (`code`, `string`) where + `code` - int display exit code + `string` - input entered by the user + + """ + with completer.Completer(): + return self.input(message) + def _scrub_checklist_input(self, indices, tags): # pylint: disable=no-self-use """Validate input and transform indices to appropriate tags. @@ -373,7 +409,6 @@ class FileDisplay(object): self.outfile.write(side_frame) - def _get_valid_int_ans(self, max_): """Get a numerical selection. @@ -409,6 +444,7 @@ class FileDisplay(object): return OK, selection + @zope.interface.implementer(interfaces.IDisplay) class NoninteractiveDisplay(object): """An iDisplay implementation that never asks for interactive user input""" @@ -483,7 +519,6 @@ class NoninteractiveDisplay(object): else: return OK, default - def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None): # pylint: disable=unused-argument """Decide Yes or No, without asking anybody @@ -520,6 +555,25 @@ class NoninteractiveDisplay(object): else: return OK, default + def directory_select(self, message, default=None, cli_flag=None): + """Simulate prompting the user for a directory. + + This function returns default if it is not ``None``, otherwise, + an exception is raised explaining the problem. If cli_flag is + not ``None``, the error message will include the flag that can + be used to set this value with the CLI. + + :param str message: prompt to give the user + :param default: default value to return (if one exists) + :param str cli_flag: option used to set this value with the CLI + + :returns: tuple of the form (`code`, `string`) where + `code` - int display exit code + `string` - input entered by the user + + """ + return self.input(message, default, cli_flag) + def separate_list_input(input_): """Separate a comma or space separated list. diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 1921b1e54..2fba11869 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -443,6 +443,22 @@ class IDisplay(zope.interface.Interface): """ + def directory_select(self, message, default=None, cli_flag=None): + """Display a directory selection screen. + + :param str message: prompt to give the user + :param default: the default value to return, if one exists, when + using the NoninteractiveDisplay + :param str cli_flag: option used to set this value with the CLI, + if one exists, to be included in error messages given by + NoninteractiveDisplay + + :returns: tuple of the form (`code`, `string`) where + `code` - int display exit code + `string` - input entered by the user + + """ + class IValidator(zope.interface.Interface): """Configuration validator.""" diff --git a/letsencrypt/main.py b/letsencrypt/main.py index fc6540dcf..0afccc85e 100644 --- a/letsencrypt/main.py +++ b/letsencrypt/main.py @@ -32,6 +32,7 @@ from letsencrypt import storage from letsencrypt.display import util as display_util, ops as display_ops from letsencrypt.plugins import disco as plugins_disco +from letsencrypt.plugins import selection as plug_sel logger = logging.getLogger(__name__) @@ -406,7 +407,7 @@ def install(config, plugins): # this function ... try: - installer, _ = cli.choose_configurator_plugins(config, plugins, "install") + installer, _ = plug_sel.choose_configurator_plugins(config, plugins, "install") except errors.PluginSelectionError as e: return e.message @@ -481,7 +482,7 @@ def run(config, plugins): # pylint: disable=too-many-branches,too-many-locals # TODO: Make run as close to auth + install as possible # Possible difficulties: config.csr was hacked into auth try: - installer, authenticator = cli.choose_configurator_plugins(config, plugins, "run") + installer, authenticator = plug_sel.choose_configurator_plugins(config, plugins, "run") except errors.PluginSelectionError as e: return e.message @@ -511,7 +512,7 @@ def obtain_cert(config, plugins, lineage=None): # pylint: disable=too-many-locals try: # installers are used in auth mode to determine domain names - installer, authenticator = cli.choose_configurator_plugins(config, plugins, "certonly") + installer, authenticator = plug_sel.choose_configurator_plugins(config, plugins, "certonly") except errors.PluginSelectionError as e: logger.info("Could not choose appropriate plugin: %s", e) raise diff --git a/letsencrypt/plugins/common.py b/letsencrypt/plugins/common.py index f6a2c3d76..a9410d514 100644 --- a/letsencrypt/plugins/common.py +++ b/letsencrypt/plugins/common.py @@ -104,14 +104,24 @@ class Addr(object): :param str port: port number or \*, or "" """ - def __init__(self, tup): + def __init__(self, tup, ipv6=False): self.tup = tup + self.ipv6 = ipv6 @classmethod def fromstring(cls, str_addr): """Initialize Addr from string.""" - tup = str_addr.partition(':') - return cls((tup[0], tup[2])) + if str_addr.startswith('['): + # ipv6 addresses starts with [ + endIndex = str_addr.rfind(']') + host = str_addr[:endIndex + 1] + port = '' + if len(str_addr) > endIndex + 2 and str_addr[endIndex + 1] == ':': + port = str_addr[endIndex + 2:] + return cls((host, port), ipv6=True) + else: + tup = str_addr.partition(':') + return cls((tup[0], tup[2])) def __str__(self): if self.tup[1]: @@ -120,7 +130,16 @@ class Addr(object): def __eq__(self, other): if isinstance(other, self.__class__): - return self.tup == other.tup + if self.ipv6: + # compare normalized to take different + # styles of representation into account + return (other.ipv6 and + self._normalize_ipv6(self.tup[0]) == + self._normalize_ipv6(other.tup[0]) and + self.tup[1] == other.tup[1]) + else: + return self.tup == other.tup + return False def __hash__(self): @@ -136,7 +155,44 @@ class Addr(object): def get_addr_obj(self, port): """Return new address object with same addr and new port.""" - return self.__class__((self.tup[0], port)) + return self.__class__((self.tup[0], port), self.ipv6) + + def _normalize_ipv6(self, addr): + """Return IPv6 address in normalized form, helper function""" + addr = addr.lstrip("[") + addr = addr.rstrip("]") + return self._explode_ipv6(addr) + + def get_ipv6_exploded(self): + """Return IPv6 in normalized form""" + if self.ipv6: + return ":".join(self._normalize_ipv6(self.tup[0])) + return "" + + def _explode_ipv6(self, addr): + """Explode IPv6 address for comparison""" + result = ['0', '0', '0', '0', '0', '0', '0', '0'] + addr_list = addr.split(":") + if len(addr_list) > len(result): + # too long, truncate + addr_list = addr_list[0:len(result)] + append_to_end = False + for i in range(0, len(addr_list)): + block = addr_list[i] + if len(block) == 0: + # encountered ::, so rest of the blocks should be + # appended to the end + append_to_end = True + continue + elif len(block) > 1: + # remove leading zeros + block = block.lstrip("0") + if not append_to_end: + result[i] = str(block) + else: + # count the location from the end using negative indices + result[i-len(addr_list)] = str(block) + return result class TLSSNI01(object): diff --git a/letsencrypt/plugins/common_test.py b/letsencrypt/plugins/common_test.py index 55319f0a0..a4292151e 100644 --- a/letsencrypt/plugins/common_test.py +++ b/letsencrypt/plugins/common_test.py @@ -81,6 +81,11 @@ class AddrTest(unittest.TestCase): self.addr1 = Addr.fromstring("192.168.1.1") self.addr2 = Addr.fromstring("192.168.1.1:*") self.addr3 = Addr.fromstring("192.168.1.1:80") + self.addr4 = Addr.fromstring("[fe00::1]") + self.addr5 = Addr.fromstring("[fe00::1]:*") + self.addr6 = Addr.fromstring("[fe00::1]:80") + self.addr7 = Addr.fromstring("[fe00::1]:5") + self.addr8 = Addr.fromstring("[fe00:1:2:3:4:5:6:7:8:9]:8080") def test_fromstring(self): self.assertEqual(self.addr1.get_addr(), "192.168.1.1") @@ -89,22 +94,49 @@ class AddrTest(unittest.TestCase): self.assertEqual(self.addr2.get_port(), "*") self.assertEqual(self.addr3.get_addr(), "192.168.1.1") self.assertEqual(self.addr3.get_port(), "80") + self.assertEqual(self.addr4.get_addr(), "[fe00::1]") + self.assertEqual(self.addr4.get_port(), "") + self.assertEqual(self.addr5.get_addr(), "[fe00::1]") + self.assertEqual(self.addr5.get_port(), "*") + self.assertEqual(self.addr6.get_addr(), "[fe00::1]") + self.assertEqual(self.addr6.get_port(), "80") + self.assertEqual(self.addr6.get_ipv6_exploded(), + "fe00:0:0:0:0:0:0:1") + self.assertEqual(self.addr1.get_ipv6_exploded(), + "") + self.assertEqual(self.addr7.get_port(), "5") + self.assertEqual(self.addr8.get_ipv6_exploded(), + "fe00:1:2:3:4:5:6:7") def test_str(self): self.assertEqual(str(self.addr1), "192.168.1.1") self.assertEqual(str(self.addr2), "192.168.1.1:*") self.assertEqual(str(self.addr3), "192.168.1.1:80") + self.assertEqual(str(self.addr4), "[fe00::1]") + self.assertEqual(str(self.addr5), "[fe00::1]:*") + self.assertEqual(str(self.addr6), "[fe00::1]:80") def test_get_addr_obj(self): self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443") self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1") self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*") + self.assertEqual(str(self.addr4.get_addr_obj("443")), "[fe00::1]:443") + self.assertEqual(str(self.addr5.get_addr_obj("")), "[fe00::1]") + self.assertEqual(str(self.addr4.get_addr_obj("*")), "[fe00::1]:*") def test_eq(self): self.assertEqual(self.addr1, self.addr2.get_addr_obj("")) self.assertNotEqual(self.addr1, self.addr2) self.assertFalse(self.addr1 == 3333) + self.assertEqual(self.addr4, self.addr4.get_addr_obj("")) + self.assertNotEqual(self.addr4, self.addr5) + self.assertFalse(self.addr4 == 3333) + from letsencrypt.plugins.common import Addr + self.assertEqual(self.addr4, Addr.fromstring("[fe00:0:0::1]")) + self.assertEqual(self.addr4, Addr.fromstring("[fe00:0::0:0:1]")) + + def test_set_inclusion(self): from letsencrypt.plugins.common import Addr set_a = set([self.addr1, self.addr2]) @@ -114,6 +146,13 @@ class AddrTest(unittest.TestCase): self.assertEqual(set_a, set_b) + set_c = set([self.addr4, self.addr5]) + addr4b = Addr.fromstring("[fe00::1]") + addr5b = Addr.fromstring("[fe00::1]:*") + set_d = set([addr4b, addr5b]) + + self.assertEqual(set_c, set_d) + class TLSSNI01Test(unittest.TestCase): """Tests for letsencrypt.plugins.common.TLSSNI01.""" diff --git a/letsencrypt/plugins/selection.py b/letsencrypt/plugins/selection.py new file mode 100644 index 000000000..20f6ac512 --- /dev/null +++ b/letsencrypt/plugins/selection.py @@ -0,0 +1,273 @@ +"""Decide which plugins to use for authentication & installation""" +from __future__ import print_function + +import os +import logging + +import zope.component + +from letsencrypt import errors +from letsencrypt import interfaces + +from letsencrypt.display import util as display_util + +logger = logging.getLogger(__name__) +z_util = zope.component.getUtility + +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 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_authenticator( + config, default, plugins, question="How would you " + "like to authenticate with the Let's Encrypt CA?"): + """Pick authentication plugin.""" + return pick_plugin( + config, default, plugins, question, (interfaces.IAuthenticator,)) + + +def pick_plugin(config, default, plugins, question, ifaces): + """Pick plugin. + + :param letsencrypt.interfaces.IConfig: Configuration + :param str default: Plugin name supplied by user or ``None``. + :param letsencrypt.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: + if config.noninteractive_mode: + # it's really bad to auto-select the single available plugin in + # non-interactive mode, because an update could later add a second + # available plugin + raise errors.MissingCommandlineFlag( + "Missing command line flags. For non-interactive execution, " + "you will need to specify a plugin on the command line. Run " + "with '--help plugins' to see a list of options, and see " + "https://eff.org/letsencrypt-plugins for more detail on what " + "the plugins do and how to use them.") + + filtered = plugins.visible().ifaces(ifaces) + + filtered.init(config) + verified = filtered.verify(ifaces) + verified.prepare() + prepared = verified.available() + + if len(prepared) > 1: + logger.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] + logger.debug("Single candidate plugin: %s", plugin_ep) + if plugin_ep.misconfigured: + return None + return plugin_ep.init() + else: + logger.debug("No candidate plugin") + return None + + +def choose_plugin(prepared, question): + """Allow the user to choose their plugin. + + :param list prepared: List of `~.PluginEntryPoint`. + :param str question: Question to be presented to the user. + + :returns: Plugin entry point chosen by the user. + :rtype: `~.PluginEntryPoint` + + """ + opts = [plugin_ep.description_with_name + + (" [Misconfigured]" if plugin_ep.misconfigured else "") + for plugin_ep in prepared] + + while True: + disp = z_util(interfaces.IDisplay) + code, index = disp.menu(question, opts, help_label="More Info") + + if code == display_util.OK: + plugin_ep = prepared[index] + if plugin_ep.misconfigured: + z_util(interfaces.IDisplay).notification( + "The selected plugin encountered an error while parsing " + "your server configuration and cannot be used. The error " + "was:\n\n{0}".format(plugin_ep.prepare()), + height=display_util.HEIGHT, pause=False) + else: + return plugin_ep + elif code == display_util.HELP: + if prepared[index].misconfigured: + msg = "Reported Error: %s" % prepared[index].prepare() + else: + msg = prepared[index].init().more_info() + z_util(interfaces.IDisplay).notification( + msg, height=display_util.HEIGHT) + else: + return None + +noninstaller_plugins = ["webroot", "manual", "standalone"] + +def record_chosen_plugins(config, plugins, auth, inst): + "Update the config entries to reflect the plugins we actually selected." + cn = config.namespace + cn.authenticator = plugins.find_init(auth).name if auth else "None" + cn.installer = plugins.find_init(inst).name if inst else "None" + + +def choose_configurator_plugins(config, plugins, verb): + """ + Figure out which configurator we're going to use, modifies + config.authenticator and config.installer strings to reflect that choice if + necessary. + + :raises errors.PluginSelectionError if there was a problem + + :returns: (an `IAuthenticator` or None, an `IInstaller` or None) + :rtype: tuple + """ + + req_auth, req_inst = cli_plugin_requests(config) + + # Which plugins do we need? + if verb == "run": + need_inst = need_auth = True + from letsencrypt.cli import cli_command + if req_auth in noninstaller_plugins and not req_inst: + msg = ('With the {0} plugin, you probably want to use the "certonly" command, eg:{1}' + '{1} {2} certonly --{0}{1}{1}' + '(Alternatively, add a --installer flag. See https://eff.org/letsencrypt-plugins' + '{1} and "--help plugins" for more information.)'.format( + req_auth, os.linesep, cli_command)) + + raise errors.MissingCommandlineFlag(msg) + else: + need_inst = need_auth = False + if verb == "certonly": + need_auth = True + if verb == "install": + need_inst = True + if config.authenticator: + logger.warn("Specifying an authenticator doesn't make sense in install mode") + + # Try to meet the user's request and/or ask them to pick plugins + authenticator = installer = None + if verb == "run" and req_auth == req_inst: + # Unless the user has explicitly asked for different auth/install, + # only consider offering a single choice + authenticator = installer = pick_configurator(config, req_inst, plugins) + else: + if need_inst or req_inst: + installer = pick_installer(config, req_inst, plugins) + if need_auth: + authenticator = pick_authenticator(config, req_auth, plugins) + logger.debug("Selected authenticator %s and installer %s", authenticator, installer) + + # Report on any failures + if need_inst and not installer: + diagnose_configurator_problem("installer", req_inst, plugins) + if need_auth and not authenticator: + diagnose_configurator_problem("authenticator", req_auth, plugins) + + record_chosen_plugins(config, plugins, authenticator, installer) + return installer, authenticator + + +def set_configurator(previously, now): + """ + Setting configurators multiple ways is okay, as long as they all agree + :param str previously: previously identified request for the installer/authenticator + :param str requested: the request currently being processed + """ + if not now: + # we're not actually setting anything + return previously + if previously: + if previously != now: + msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}" + raise errors.PluginSelectionError(msg.format(repr(previously), repr(now))) + return now + + +def cli_plugin_requests(config): + """ + Figure out which plugins the user requested with CLI and config options + + :returns: (requested authenticator string or None, requested installer string or None) + :rtype: tuple + """ + req_inst = req_auth = config.configurator + req_inst = set_configurator(req_inst, config.installer) + req_auth = set_configurator(req_auth, config.authenticator) + if config.nginx: + req_inst = set_configurator(req_inst, "nginx") + req_auth = set_configurator(req_auth, "nginx") + if config.apache: + req_inst = set_configurator(req_inst, "apache") + req_auth = set_configurator(req_auth, "apache") + if config.standalone: + req_auth = set_configurator(req_auth, "standalone") + if config.webroot: + req_auth = set_configurator(req_auth, "webroot") + if config.manual: + req_auth = set_configurator(req_auth, "manual") + logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst) + return req_auth, req_inst + + +def diagnose_configurator_problem(cfg_type, requested, plugins): + """ + Raise the most helpful error message about a plugin being unavailable + + :param str cfg_type: either "installer" or "authenticator" + :param str requested: the plugin that was requested + :param .PluginsRegistry plugins: available plugins + + :raises error.PluginSelectionError: if there was a problem + """ + + if requested: + if requested not in plugins: + msg = "The requested {0} plugin does not appear to be installed".format(requested) + else: + msg = ("The {0} plugin is not working; there may be problems with " + "your existing configuration.\nThe error was: {1!r}" + .format(requested, plugins[requested].problem)) + elif cfg_type == "installer": + if os.path.exists("/etc/debian_version"): + # Debian... installers are at least possible + msg = ('No installers seem to be present and working on your system; ' + 'fix that or try running letsencrypt with the "certonly" command') + else: + # XXX update this logic as we make progress on #788 and nginx support + msg = ('No installers are available on your OS yet; try running ' + '"letsencrypt-auto certonly" to get a cert you can install manually') + else: + msg = "{0} could not be determined or is not installed".format(cfg_type) + raise errors.PluginSelectionError(msg) diff --git a/letsencrypt/plugins/selection_test.py b/letsencrypt/plugins/selection_test.py new file mode 100644 index 000000000..0beaab076 --- /dev/null +++ b/letsencrypt/plugins/selection_test.py @@ -0,0 +1,149 @@ +"""Tests for letsenecrypt.plugins.selection""" +import sys +import unittest + +import mock +import zope.component + +from letsencrypt.display import util as display_util +from letsencrypt import interfaces + + +class ConveniencePickPluginTest(unittest.TestCase): + """Tests for letsencrypt.plugins.selection.pick_*.""" + + def _test(self, fun, ifaces): + config = mock.Mock() + default = mock.Mock() + plugins = mock.Mock() + + with mock.patch("letsencrypt.plugins.selection.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.plugins.selection import pick_authenticator + self._test(pick_authenticator, (interfaces.IAuthenticator,)) + + def test_installer(self): + from letsencrypt.plugins.selection import pick_installer + self._test(pick_installer, (interfaces.IInstaller,)) + + def test_configurator(self): + from letsencrypt.plugins.selection import pick_configurator + self._test(pick_configurator, + (interfaces.IAuthenticator, interfaces.IInstaller)) + + +class PickPluginTest(unittest.TestCase): + """Tests for letsencrypt.plugins.selection.pick_plugin.""" + + def setUp(self): + self.config = mock.Mock(noninteractive_mode=False) + self.default = None + self.reg = mock.MagicMock() + self.question = "Question?" + self.ifaces = [] + + def _call(self): + from letsencrypt.plugins.selection 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.assertEqual(1, self.reg.filter.call_count) + + def test_no_default(self): + self._call() + self.assertEqual(1, self.reg.visible().ifaces.call_count) + + 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" + plugin_ep.misconfigured = False + + self.reg.visible().ifaces().verify().available.return_value = { + "bar": plugin_ep} + self.assertEqual("foo", self._call()) + + def test_single_misconfigured(self): + plugin_ep = mock.MagicMock() + plugin_ep.init.return_value = "foo" + plugin_ep.misconfigured = True + + self.reg.visible().ifaces().verify().available.return_value = { + "bar": plugin_ep} + self.assertTrue(self._call() is None) + + def test_multiple(self): + plugin_ep = mock.MagicMock() + plugin_ep.init.return_value = "foo" + self.reg.visible().ifaces().verify().available.return_value = { + "bar": plugin_ep, + "baz": plugin_ep, + } + with mock.patch("letsencrypt.plugins.selection.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.visible().ifaces().verify().available.return_value = { + "bar": None, + "baz": None, + } + + with mock.patch("letsencrypt.plugins.selection.choose_plugin") as mock_choose: + mock_choose.return_value = None + self.assertTrue(self._call() is None) + + +class ChoosePluginTest(unittest.TestCase): + """Tests for letsencrypt.plugins.selection.choose_plugin.""" + + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + 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, + ] + + def _call(self): + from letsencrypt.plugins.selection import choose_plugin + return choose_plugin(self.plugins, "Question?") + + @mock.patch("letsencrypt.plugins.selection.z_util") + def test_selection(self, mock_util): + mock_util().menu.side_effect = [(display_util.OK, 0), + (display_util.OK, 1)] + self.assertEqual(self.mock_stand, self._call()) + self.assertEqual(mock_util().notification.call_count, 1) + + @mock.patch("letsencrypt.plugins.selection.z_util") + def test_more_info(self, mock_util): + mock_util().menu.side_effect = [ + (display_util.HELP, 0), + (display_util.HELP, 1), + (display_util.OK, 1), + ] + + self.assertEqual(self.mock_stand, self._call()) + self.assertEqual(mock_util().notification.call_count, 2) + + @mock.patch("letsencrypt.plugins.selection.z_util") + def test_no_choice(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, 0) + self.assertTrue(self._call() is None) diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 80412098f..04b5a2f3c 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -203,12 +203,12 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self.assertEqual(args.chain_path, os.path.abspath(chain)) self.assertEqual(args.fullchain_path, os.path.abspath(fullchain)) - @mock.patch('letsencrypt.main.cli.record_chosen_plugins') - @mock.patch('letsencrypt.main.cli.display_ops') - def test_installer_selection(self, mock_display_ops, _rec): + @mock.patch('letsencrypt.main.plug_sel.record_chosen_plugins') + @mock.patch('letsencrypt.main.plug_sel.pick_installer') + def test_installer_selection(self, mock_pick_installer, _rec): self._call(['install', '--domains', 'foo.bar', '--cert-path', 'cert', '--key-path', 'key', '--chain-path', 'chain']) - self.assertEqual(mock_display_ops.pick_installer.call_count, 1) + self.assertEqual(mock_pick_installer.call_count, 1) @mock.patch('letsencrypt.le_util.exe_exists') def test_configurator_selection(self, mock_exe_exists): @@ -499,7 +499,7 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self._webroot_map_test(simple_map, "/tmp2", "eg2.com,eg.com", expected_map, domains) # test inclusion of interactively specified domains in the webroot map - with mock.patch('letsencrypt.cli.display_ops.choose_names') as mock_choose: + with mock.patch('letsencrypt.display.ops.choose_names') as mock_choose: mock_choose.return_value = domains expected_map["eg2.com"] = "/tmp" self._webroot_map_test(None, "/tmp", None, expected_map, domains) diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 16a744b9a..ed4e5def0 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -460,9 +460,8 @@ class RollbackTest(unittest.TestCase): @classmethod def _call(cls, checkpoints, side_effect): from letsencrypt.client import rollback - with mock.patch("letsencrypt.client" - ".display_ops.pick_installer") as mock_pick_installer: - mock_pick_installer.side_effect = side_effect + with mock.patch("letsencrypt.client.plugin_selection.pick_installer") as mpi: + mpi.side_effect = side_effect rollback(None, checkpoints, {}, mock.MagicMock()) def test_no_problems(self): diff --git a/letsencrypt/tests/display/completer_test.py b/letsencrypt/tests/display/completer_test.py new file mode 100644 index 000000000..3c181c925 --- /dev/null +++ b/letsencrypt/tests/display/completer_test.py @@ -0,0 +1,102 @@ +"""Test letsencrypt.display.completer.""" +import os +import readline +import shutil +import string +import sys +import tempfile +import unittest + +import mock +from six.moves import reload_module # pylint: disable=import-error + + +class CompleterTest(unittest.TestCase): + """Test letsencrypt.display.completer.Completer.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + + # directories must end with os.sep for completer to + # search inside the directory for possible completions + if self.temp_dir[-1] != os.sep: + self.temp_dir += os.sep + + self.paths = [] + # create some files and directories in temp_dir + for c in string.ascii_lowercase: + path = os.path.join(self.temp_dir, c) + self.paths.append(path) + if ord(c) % 2: + os.mkdir(path) + else: + with open(path, 'w'): + pass + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_complete(self): + from letsencrypt.display import completer + my_completer = completer.Completer() + num_paths = len(self.paths) + + for i in range(num_paths): + completion = my_completer.complete(self.temp_dir, i) + self.assertTrue(completion in self.paths) + self.paths.remove(completion) + + self.assertFalse(self.paths) + completion = my_completer.complete(self.temp_dir, num_paths) + self.assertEqual(completion, None) + + def test_import_error(self): + original_readline = sys.modules['readline'] + sys.modules['readline'] = None + + self.test_context_manager_with_unmocked_readline() + + sys.modules['readline'] = original_readline + + def test_context_manager_with_unmocked_readline(self): + from letsencrypt.display import completer + reload_module(completer) + + original_completer = readline.get_completer() + original_delims = readline.get_completer_delims() + + with completer.Completer(): + pass + + self.assertEqual(readline.get_completer(), original_completer) + self.assertEqual(readline.get_completer_delims(), original_delims) + + @mock.patch('letsencrypt.display.completer.readline', autospec=True) + def test_context_manager_libedit(self, mock_readline): + mock_readline.__doc__ = 'libedit' + self._test_context_manager_with_mock_readline(mock_readline) + + @mock.patch('letsencrypt.display.completer.readline', autospec=True) + def test_context_manager_readline(self, mock_readline): + mock_readline.__doc__ = 'GNU readline' + self._test_context_manager_with_mock_readline(mock_readline) + + def _test_context_manager_with_mock_readline(self, mock_readline): + from letsencrypt.display import completer + + mock_readline.parse_and_bind.side_effect = enable_tab_completion + + with completer.Completer(): + pass + + self.assertTrue(mock_readline.parse_and_bind.called) + + +def enable_tab_completion(unused_command): + """Enables readline tab completion using the system specific syntax.""" + libedit = 'libedit' in readline.__doc__ + command = 'bind ^I rl_complete' if libedit else 'tab: complete' + readline.parse_and_bind(command) + +if __name__ == "__main__": + unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/display/ops_test.py b/letsencrypt/tests/display/ops_test.py index 9a39e1538..0dacdfea8 100644 --- a/letsencrypt/tests/display/ops_test.py +++ b/letsencrypt/tests/display/ops_test.py @@ -22,146 +22,6 @@ from letsencrypt.tests import test_util KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) -class ChoosePluginTest(unittest.TestCase): - """Tests for letsencrypt.display.ops.choose_plugin.""" - - def setUp(self): - zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) - 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, - ] - - def _call(self): - from letsencrypt.display.ops import choose_plugin - return choose_plugin(self.plugins, "Question?") - - @mock.patch("letsencrypt.display.ops.util") - def test_selection(self, mock_util): - mock_util().menu.side_effect = [(display_util.OK, 0), - (display_util.OK, 1)] - self.assertEqual(self.mock_stand, self._call()) - self.assertEqual(mock_util().notification.call_count, 1) - - @mock.patch("letsencrypt.display.ops.util") - def test_more_info(self, mock_util): - mock_util().menu.side_effect = [ - (display_util.HELP, 0), - (display_util.HELP, 1), - (display_util.OK, 1), - ] - - self.assertEqual(self.mock_stand, self._call()) - self.assertEqual(mock_util().notification.call_count, 2) - - @mock.patch("letsencrypt.display.ops.util") - def test_no_choice(self, mock_util): - mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertTrue(self._call() is None) - - -class PickPluginTest(unittest.TestCase): - """Tests for letsencrypt.display.ops.pick_plugin.""" - - def setUp(self): - self.config = mock.Mock(noninteractive_mode=False) - self.default = None - self.reg = mock.MagicMock() - self.question = "Question?" - self.ifaces = [] - - def _call(self): - from letsencrypt.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.assertEqual(1, self.reg.filter.call_count) - - def test_no_default(self): - self._call() - self.assertEqual(1, self.reg.visible().ifaces.call_count) - - 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" - plugin_ep.misconfigured = False - - self.reg.visible().ifaces().verify().available.return_value = { - "bar": plugin_ep} - self.assertEqual("foo", self._call()) - - def test_single_misconfigured(self): - plugin_ep = mock.MagicMock() - plugin_ep.init.return_value = "foo" - plugin_ep.misconfigured = True - - self.reg.visible().ifaces().verify().available.return_value = { - "bar": plugin_ep} - self.assertTrue(self._call() is None) - - def test_multiple(self): - plugin_ep = mock.MagicMock() - plugin_ep.init.return_value = "foo" - self.reg.visible().ifaces().verify().available.return_value = { - "bar": plugin_ep, - "baz": plugin_ep, - } - with mock.patch("letsencrypt.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.visible().ifaces().verify().available.return_value = { - "bar": None, - "baz": None, - } - - with mock.patch("letsencrypt.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.display.ops.pick_*.""" - - def _test(self, fun, ifaces): - config = mock.Mock() - default = mock.Mock() - plugins = mock.Mock() - - with mock.patch("letsencrypt.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.display.ops import pick_authenticator - self._test(pick_authenticator, (interfaces.IAuthenticator,)) - - def test_installer(self): - from letsencrypt.display.ops import pick_installer - self._test(pick_installer, (interfaces.IInstaller,)) - - def test_configurator(self): - from letsencrypt.display.ops import pick_configurator - self._test(pick_configurator, ( - interfaces.IAuthenticator, interfaces.IInstaller)) - - class GetEmailTest(unittest.TestCase): """Tests for letsencrypt.display.ops.get_email.""" @@ -241,17 +101,17 @@ class ChooseAccountTest(unittest.TestCase): from letsencrypt.display import ops return ops.choose_account(accounts) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_one(self, mock_util): mock_util().menu.return_value = (display_util.OK, 0) self.assertEqual(self._call([self.acc1]), self.acc1) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_two(self, mock_util): mock_util().menu.return_value = (display_util.OK, 1) self.assertEqual(self._call([self.acc1, self.acc2]), self.acc2) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_cancel(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 1) self.assertTrue(self._call([self.acc1, self.acc2]) is None) @@ -339,12 +199,12 @@ class ChooseNamesTest(unittest.TestCase): self._call(None) self.assertEqual(mock_manual.call_count, 1) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_no_installer_cancel(self, mock_util): mock_util().input.return_value = (display_util.CANCEL, []) self.assertEqual(self._call(None), []) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_no_names_choose(self, mock_util): self.mock_install().get_all_names.return_value = set() mock_util().yesno.return_value = True @@ -355,14 +215,14 @@ class ChooseNamesTest(unittest.TestCase): self.assertEqual(mock_util().input.call_count, 1) self.assertEqual(actual_doms, [domain]) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_no_names_quit(self, mock_util): self.mock_install().get_all_names.return_value = set() mock_util().yesno.return_value = False self.assertEqual(self._call(self.mock_install), []) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_filter_names_valid_return(self, mock_util): self.mock_install.get_all_names.return_value = set(["example.com"]) mock_util().checklist.return_value = (display_util.OK, ["example.com"]) @@ -371,14 +231,14 @@ class ChooseNamesTest(unittest.TestCase): self.assertEqual(names, ["example.com"]) self.assertEqual(mock_util().checklist.call_count, 1) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_filter_names_nothing_selected(self, mock_util): self.mock_install.get_all_names.return_value = set(["example.com"]) mock_util().checklist.return_value = (display_util.OK, []) self.assertEqual(self._call(self.mock_install), []) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_filter_names_cancel(self, mock_util): self.mock_install.get_all_names.return_value = set(["example.com"]) mock_util().checklist.return_value = ( @@ -397,7 +257,7 @@ class ChooseNamesTest(unittest.TestCase): self.assertEqual(get_valid_domains(all_invalid), []) self.assertEqual(len(get_valid_domains(two_valid)), 2) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_choose_manually(self, mock_util): from letsencrypt.display.ops import _choose_names_manually # No retry @@ -445,7 +305,7 @@ class SuccessInstallationTest(unittest.TestCase): from letsencrypt.display.ops import success_installation success_installation(names) - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_success_installation(self, mock_util): mock_util().notification.return_value = None names = ["example.com", "abc.com"] @@ -467,7 +327,7 @@ class SuccessRenewalTest(unittest.TestCase): from letsencrypt.display.ops import success_renewal success_renewal(names, "renew") - @mock.patch("letsencrypt.display.ops.util") + @mock.patch("letsencrypt.display.ops.z_util") def test_success_renewal(self, mock_util): mock_util().notification.return_value = None names = ["example.com", "abc.com"] diff --git a/letsencrypt/tests/display/util_test.py b/letsencrypt/tests/display/util_test.py index a16eb544e..bae0d582a 100644 --- a/letsencrypt/tests/display/util_test.py +++ b/letsencrypt/tests/display/util_test.py @@ -123,6 +123,11 @@ class NcursesDisplayTest(unittest.TestCase): "message", width=display_util.WIDTH, height=display_util.HEIGHT, choices=choices) + @mock.patch("letsencrypt.display.util.dialog.Dialog.dselect") + def test_directory_select(self, mock_dselect): + self.displayer.directory_select("message") + self.assertEqual(mock_dselect.call_count, 1) + class FileOutputDisplayTest(unittest.TestCase): """Test stdout display. @@ -227,6 +232,15 @@ class FileOutputDisplayTest(unittest.TestCase): self.displayer._scrub_checklist_input(list_, TAGS)) self.assertEqual(set_tags, exp[i]) + @mock.patch("letsencrypt.display.util.FileDisplay.input") + def test_directory_select(self, mock_input): + message = "msg" + result = (display_util.OK, "/var/www/html",) + mock_input.return_value = result + + self.assertEqual(self.displayer.directory_select(message), result) + mock_input.assert_called_once_with(message) + def test_scrub_checklist_input_invalid(self): # pylint: disable=protected-access indices = [ @@ -280,6 +294,7 @@ class FileOutputDisplayTest(unittest.TestCase): self.displayer._get_valid_int_ans(3), (display_util.CANCEL, -1)) + class NoninteractiveDisplayTest(unittest.TestCase): """Test non-interactive display. @@ -320,6 +335,15 @@ class NoninteractiveDisplayTest(unittest.TestCase): self.assertEqual(ret, (display_util.OK, d)) self.assertRaises(errors.MissingCommandlineFlag, self.displayer.checklist, "message", TAGS) + def test_directory_select(self): + default = "/var/www/html" + expected = (display_util.OK, default) + actual = self.displayer.directory_select("msg", default) + self.assertEqual(expected, actual) + + self.assertRaises( + errors.MissingCommandlineFlag, self.displayer.directory_select, "msg") + class SeparateListInputTest(unittest.TestCase): """Test Module functions."""