From 168a70c273296307bc68c40e5a345fef0b87afd1 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 8 Feb 2015 00:46:16 -0800 Subject: [PATCH] refactor and enhance display, update revoker --- letsencrypt/client/apache/configurator.py | 69 ++-- letsencrypt/client/client.py | 61 +-- letsencrypt/client/display/display_util.py | 378 ++++++++++++++++++ letsencrypt/client/display/enhancements.py | 68 ++++ letsencrypt/client/display/ops.py | 124 ++++++ letsencrypt/client/display/revocation.py | 106 +++++ letsencrypt/client/interfaces.py | 60 ++- letsencrypt/client/log.py | 6 +- letsencrypt/client/reverter.py | 4 +- letsencrypt/client/revoker.py | 209 +++++----- .../client/tests/apache/parser_test.py | 4 +- .../client/tests/challenge_util_test.py | 2 +- letsencrypt/client/tests/client_test.py | 6 +- letsencrypt/client/tests/crypto_util_test.py | 73 ++-- .../client/tests/display/display_util_test.py | 105 +++++ letsencrypt/client/tests/display/ops_test.py | 159 ++++++++ letsencrypt/scripts/main.py | 53 +-- setup.py | 2 + 18 files changed, 1174 insertions(+), 315 deletions(-) create mode 100644 letsencrypt/client/display/display_util.py create mode 100644 letsencrypt/client/display/enhancements.py create mode 100644 letsencrypt/client/display/ops.py create mode 100644 letsencrypt/client/display/revocation.py create mode 100644 letsencrypt/client/tests/display/display_util_test.py create mode 100644 letsencrypt/client/tests/display/ops_test.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index ad6e54273..5333cc3b5 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -107,7 +107,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.check_parsing_errors("httpd.aug") # Set Version - self.version = self.get_version() if version is None else version + self.version = get_version() if version is None else version # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() @@ -911,37 +911,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return True - def get_version(self): # pylint: disable=no-self-use - """Return version of Apache Server. - - Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) - - :returns: version - :rtype: tuple - - :raises errors.LetsEncryptConfiguratorError: - Unable to find Apache version - - """ - try: - proc = subprocess.Popen( - [CONFIG.APACHE_CTL, '-v'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - text = proc.communicate()[0] - except (OSError, ValueError): - raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -v" % CONFIG.APACHE_CTL) - - regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) - matches = regex.findall(text) - - if len(matches) != 1: - raise errors.LetsEncryptConfiguratorError( - "Unable to find Apache version") - - return tuple([int(i) for i in matches[0].split('.')]) - def verify_setup(self): """Verify the setup to ensure safe operating environment. @@ -955,6 +924,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): le_util.make_or_verify_dir(self.direc["work"], 0o755, uid) le_util.make_or_verify_dir(self.direc["backup"], 0o755, uid) + @classmethod + def __str__(cls): + return "Apache version %s" % ".".join(get_version()) + ########################################################################### # Challenges Section ########################################################################### @@ -1012,6 +985,38 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() +def get_version(self): + """Return version of Apache Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) + + :returns: version + :rtype: tuple + + :raises errors.LetsEncryptConfiguratorError: + Unable to find Apache version + + """ + try: + proc = subprocess.Popen( + [CONFIG.APACHE_CTL, '-v'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate()[0] + except (OSError, ValueError): + raise errors.LetsEncryptConfiguratorError( + "Unable to run %s -v" % CONFIG.APACHE_CTL) + + regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) + matches = regex.findall(text) + + if len(matches) != 1: + raise errors.LetsEncryptConfiguratorError( + "Unable to find Apache version") + + return tuple([int(i) for i in matches[0].split('.')]) + + def enable_mod(mod_name): """Enables module in Apache. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 9f70bc19e..88f7160a1 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,8 +1,6 @@ """ACME protocol client class and helper functions.""" -import csv import logging import os -import shutil import sys import M2Crypto @@ -21,6 +19,7 @@ from letsencrypt.client import reverter from letsencrypt.client import revoker from letsencrypt.client.apache import configurator +from letsencrypt.client.display import ops class Client(object): @@ -103,7 +102,7 @@ class Client(object): cert_file, chain_file = self.save_certificate( certificate_dict, cert_path, chain_path) - self.store_cert_key(cert_file, False) + revoker.Revoker.store_cert_key(cert_file, False) return cert_file, chain_file @@ -194,8 +193,7 @@ class Client(object): # sites may have been enabled / final cleanup self.installer.restart() - zope.component.getUtility( - interfaces.IDisplay).success_installation(domains) + ops.success_installation(domains) def enhance_config(self, domains, redirect=None): """Enhance the configuration. @@ -225,52 +223,6 @@ class Client(object): if redirect: self.redirect_to_ssl(domains) - def store_cert_key(self, cert_file, encrypt=False): - """Store certificate key. (Used to allow quick revocation) - - :param str cert_file: Path to a certificate file. - - :param bool encrypt: Should the certificate key be encrypted? - - :returns: True if key file was stored successfully, False otherwise. - :rtype: bool - - """ - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - le_util.make_or_verify_dir(CONFIG.CERT_KEY_BACKUP, 0o700) - idx = 0 - - if encrypt: - logging.error( - "Unfortunately securely storing the certificates/" - "keys is not yet available. Stay tuned for the " - "next update!") - return False - - if os.path.isfile(list_file): - with open(list_file, 'r+b') as csvfile: - csvreader = csv.reader(csvfile) - for row in csvreader: - idx = int(row[0]) + 1 - csvwriter = csv.writer(csvfile) - csvwriter.writerow([str(idx), cert_file, self.authkey.file]) - - else: - with open(list_file, 'wb') as csvfile: - csvwriter = csv.writer(csvfile) - csvwriter.writerow(["0", cert_file, self.authkey.file]) - - shutil.copy2(self.authkey.file, - os.path.join( - CONFIG.CERT_KEY_BACKUP, - os.path.basename(self.authkey.file) + "_" + str(idx))) - shutil.copy2(cert_file, - os.path.join( - CONFIG.CERT_KEY_BACKUP, - os.path.basename(cert_file) + "_" + str(idx))) - - return True - def redirect_to_ssl(self, domains): """Redirect all traffic from HTTP to HTTPS @@ -389,10 +341,13 @@ def csr_pem_to_der(csr): # This should be controlled by commandline parameters def determine_authenticator(): """Returns a valid IAuthenticator.""" + auths = [] try: - return configurator.ApacheConfigurator() + auths.append(configurator.ApacheConfigurator()) except errors.LetsEncryptNoInstallationError: logging.info("Unable to determine a way to authenticate the server") + if len(auths) > 1: + return ops.choose_authenticator(auths) def determine_installer(): @@ -484,7 +439,7 @@ def revoke(server): installer = None revoc = revoker.Revoker(server, installer) - revoc.list_certs_keys() + revoc.display_menu() def view_config_changes(): diff --git a/letsencrypt/client/display/display_util.py b/letsencrypt/client/display/display_util.py new file mode 100644 index 000000000..194350ca3 --- /dev/null +++ b/letsencrypt/client/display/display_util.py @@ -0,0 +1,378 @@ +"""Lets Encrypt display.""" +import os +import textwrap + +import dialog +import zope.interface + +from letsencrypt.client import interfaces + + +WIDTH = 72 +HEIGHT = 20 + +# Display exit codes +OK = "ok" +"""Display exit code indicating user acceptance""" + +CANCEL = "cancel" +"""Display exit code for a user canceling the display""" + +HELP = "help" +"""Display exit code when for when the user requests more help.""" + + +class NcursesDisplay(object): + """Ncurses-based display.""" + + zope.interface.implements(interfaces.IDisplay) + + def __init__(self, width=WIDTH, height=HEIGHT): + super(NcursesDisplay, self).__init__() + self.dialog = dialog.Dialog() + self.width = width + self.height = height + + def notification(self, message, height=10, pause=False): + """Display a notification to the user and wait for user acceptance. + + :param str message: Message to display + :param int height: Height of the dialog box + :param bool pause: Not applicable to NcursesDisplay + + """ + self.dialog.msgbox(message, height, width=self.width) + + def menu(self, message, choices, + ok_label="OK", cancel_label="Cancel", help_label=""): + """Display a menu. + + :param str message: title of menu + + :param choices: menu lines + :type choices: list of tuples (tag, item) or + list of items (tags will be enumerated) + + :param str ok_label: label of the OK button + :param str help_label: label of the help button + + :returns: tuple of the form (`code`, `tag`) where + `code` - `str` display_util exit code + `tag` - `str` or `int` index corresponding to the item chosen + :rtype: tuple + + """ + if help_label: + help_button = True + else: + help_button = False + + # Can accept either tuples or just the actual choices + if choices and isinstance(choices[0], tuple): + code, selection = self.dialog.menu( + message, choices=choices, ok_label=ok_label, + cancel_label=cancel_label, + help_button=help_button, help_label=help_label, + width=self.width, height=self.height) + + return code, str(selection) + else: + choices = [ + (str(i), choice) for i, choice in enumerate(choices, 1) + ] + code, tag = self.dialog.menu( + message, choices=choices, ok_label=ok_label, + cancel_label=cancel_label, + help_button=help_button, help_label=help_label, + width=self.width, height=self.height) + + return code, int(tag) - 1 + + def input(self, message): + """Display an input box to the user. + + :param str message: Message to display that asks for input. + + :returns: tuple of the form (code, string) where + `code` - int display exit code + `string` - input entered by the user + + """ + return self.dialog.inputbox(message) + + def yesno(self, message, yes_label="Yes", no_label="No"): + """Display a Yes/No dialog box + + :param str message: message to display to user + :param str yes_label: label on the "yes" button + :param str no_label: label on the "no" button + + :returns: if yes_label was selected + :rtype: bool + + """ + return self.dialog.DIALOG_OK == self.dialog.yesno( + message, self.height, self.width, + yes_label=yes_label, no_label=no_label) + + def checklist(self, message, tags): + """Displays a checklist. + + :param message: Message to display before choices + :param list tags: where each is of type :class:`str` + + :returns: tuple of the form (code, list_tags) where + `code` - int display exit code + `list_tags` - list of str tags selected by the user + + """ + choices = [(tag, "", False) for tag in tags] + return self.dialog.checklist( + message, width=self.width, height=self.height, choices=choices) + +class FileDisplay(object): + """File-based display.""" + + zope.interface.implements(interfaces.IDisplay) + + def __init__(self, outfile): + super(FileDisplay, self).__init__() + self.outfile = outfile + + def notification(self, message, height=10, pause=True): + """Displays a notification and waits for user acceptance. + + :param str message: Message to display + :param int height: No effect for FileDisplay + :param bool pause: Whether or not the program should pause for the + user's confirmation + + """ + side_frame = "-" * 79 + message = self._wrap_lines(message) + self.outfile.write( + "{line}{frame}{line}{msg}{line}{frame}{line}".format( + line=os.linesep, frame=side_frame, msg=message)) + if pause: + raw_input("Press Enter to Continue") + + def menu( + self, message, choices, ok_label="", cancel_label="", help_label=""): + """Display a menu. + + :param str message: title of menu + :param choices: Menu lines + :type choices: list of tuples (tag, item) or + list of descriptions (tags will be enumerated) + + :returns: tuple of the form (code, tag) where + code - int display exit code + tag - str corresponding to the item chosen + :rtype: tuple + + """ + self._print_menu(message, choices) + + code, selection = self._get_valid_int_ans(len(choices)) + + return code, str(selection - 1) + + def input(self, message): + # pylint: disable=no-self-use + """Accept input from the user + + :param str message: message to display to the user + + :returns: tuple of (`code`, `input`) where + `code` - str display exit code + `input` - str of the user's input + :rtype: tuple + + """ + ans = raw_input( + textwrap.fill("%s (Enter c to cancel): " % message, 80)) + + if ans == "c" or ans == "C": + return CANCEL, "-1" + else: + return OK, ans + + def yesno(self, message, yes_label="Yes", no_label="No"): + """Query the user with a yes/no question. + + :param str message: question for the user + :param str yes_label: Label of the "Yes" parameter + :param str no_label: Label of the "No" parameter + + :returns: True for "Yes", False for "No" + :rtype: bool + + """ + side_frame = ("-" * 79) + os.linesep + + message = self._wrap_lines(message) + + self.outfile.write("{0}{frame}{msg}{0}{frame}".format( + os.linesep, frame=side_frame, msg=message)) + + yes_label = _parens_around_char(yes_label) + no_label = _parens_around_char(no_label) + + ans = raw_input("{yes}/{no}: ".format(yes=yes_label, no=no_label)) + + return (ans.startswith(yes_label[0].lower()) or + ans.startswith(yes_label[0].upper())) + + def checklist(self, message, tags): + """Display a checklist. + + :param str message: Message to display to user + :param list tags: `str` tags to select + + :returns: tuple of (`code`, `tags`) where + `code` - str display exit code + `tags` - list of selected tags + :rtype: tuple + + """ + while True: + self._print_menu(message, tags) + + code, ans = self.input("Select the appropriate numbers " + "separated by commas and/or spaces ") + + if code == OK: + indices = separate_list_input(ans) + selected_tags = self._scrub_checklist_input(indices, tags) + if selected_tags: + return code, selected_tags + else: + self.outfile.write( + "** Error - Invalid selection **%s" % os.linesep) + else: + return code, [] + + def _scrub_checklist_input(self, indices, tags): + """Validate input and transform indices to appropriate tags. + + :param list indices: Checklist input + :param list tags: Original tags of the checklist + + :returns: tags the user selected + :rtype: :class:`list` of :class:`str` + + """ + # They should all be of type int + try: + indices = [int(index) for index in indices] + except TypeError: + return [] + + # Remove duplicates + indices = list(set(indices)) + + # Check all input is within range + for index in indices: + if index < 1 or index > len(tags): + return [] + # Transform indices to appropriate tags + return [tags[index-1] for index in indices] + + + def _print_menu(self, message, choices): + """Print a menu on the screen. + + :param str message: title of menu + :param choices: Menu lines + :type choices: list of tuples (tag, item) or + list of descriptions (tags will be enumerated) + + """ + # Can take either tuples or single items in choices list + if choices and isinstance(choices[0], tuple): + choices = ["%s - %s" % (c[0], c[1]) for c in choices] + + self.outfile.write( + "{new}{msg}{new}".format(new=os.linesep, msg=message)) + side_frame = ("-" * 79) + os.linesep + self.outfile.write(side_frame) + + for i, tag in enumerate(choices, 1): + self.outfile.write( + textwrap.fill("{num}: {tag}".format(num=i, tag=tag), 80)) + + # Keep this outside of the textwrap + self.outfile.write(os.linesep) + + self.outfile.write(side_frame) + + def _wrap_lines(self, msg): # pylint: disable=no-self-use + """Format lines nicely to 80 chars + + :param str msg: Original message + + :returns: Formatted message + :rtype: str + + """ + lines = msg.splitlines() + fixed_l = [] + for line in lines: + fixed_l.append(textwrap.fill(line, 80)) + + return os.linesep.join(fixed_l) + + def _get_valid_int_ans(self, max): + """Get a numerical selection. + + :param int max: The maximum entry (len of choices) + + :returns: tuple of the form (`code`, `selection`) where + `code` - str display exit code ('ok' or cancel') + `selection` - int user's selection + :rtype: tuple + + """ + selection = -1 + if max > 1: + input_msg = ("Select the appropriate number " + "[1-{max}] then [enter] (press 'c' to " + "cancel){end}".format(max=max, end=os.linesep)) + else: + input_msg = ("Press 1 [enter] to confirm the selection " + "(press 'c' to cancel){0}".format(os.linesep)) + while selection < 1: + ans = raw_input(input_msg) + if ans.startswith("c") or ans.startswith("C"): + return CANCEL, -1 + try: + selection = int(ans) + if selection < 1 or selection > max: + raise ValueError + + except ValueError: + self.outfile.write( + "{0}** Invalid input **{0}".format(os.linesep)) + + return OK, selection + + +def separate_list_input(input): + """Separate a comma or space separated list. + + :param str input: input from the user + + :returns: strings + :rtype: list + + """ + no_commas = input.replace(",", " ") + return [string for string in no_commas.split()] + +def _parens_around_char(label): + """Place parens around first character of label. + + :param str label: Must contain at least one character + + """ + return "({first}){rest}".format(first=label[0], rest=label[1:]) diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py new file mode 100644 index 000000000..16e8bc520 --- /dev/null +++ b/letsencrypt/client/display/enhancements.py @@ -0,0 +1,68 @@ +"""Let's Encrypt Enhancement Display""" +import logging + +import zope.component + +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client.display import display_util + + +class EnhanceDisplay(object): + """Class used to display various enhancements. + + .. note::This is not a subclass of Display. It merely uses Display as a + component. + + :ivar displayer: Display singleton + :type displayer: :class:`letsencrypt.client.interfaces.IDisplay + + :ivar dict dispatch: Dict mapping enhancements to functions + + """ + def __init__(self): + self.displayer = zope.component.getUtility(interfaces.IDisplay) + + self.dispatch = { + "redirect": self.redirect_by_default, + } + + def ask(self, enhancement): + """Display the enhancement to the user. + + :param str enhancement: One of the + :class:`letsencrypt.client.CONFIG.ENHANCEMENTS` enhancements + + :returns: True if feature is desired, False otherwise + :rtype: bool + + :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If + the enhancement provided is not supported. + + """ + try: + return self.dispatch[enhancement] + except KeyError: + logging.error("Unsupported enhancement given to ask()") + raise errors.LetsEncryptClientError("Unsupported Enhancement") + + def redirect_by_default(self): + """Determines whether the user would like to redirect to HTTPS. + + :returns: True if redirect is desired, False otherwise + :rtype: bool + + """ + choices = [ + ("Easy", "Allow both HTTP and HTTPS access to these sites"), + ("Secure", "Make all requests redirect to secure HTTPS access")] + + result = self.displayer.menu( + "Please choose whether HTTPS access is required or optional.", + choices, "Please enter the appropriate number") + + if result[0] != display_util.OK: + return False + + # different answer for each type of display + return str(result[1]) == "Secure" or result[1] == 1 \ No newline at end of file diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py new file mode 100644 index 000000000..b566bc262 --- /dev/null +++ b/letsencrypt/client/display/ops.py @@ -0,0 +1,124 @@ +import logging +import os +import sys + +import zope.component + +from letsencrypt.client import interfaces +from letsencrypt.client.display import display_util + +# Define a helper function to avoid verbose code +util = zope.component.getUtility + + +def choose_authenticator(auths): + """Allow the user to choose their authenticator. + + :param list auths: Where each is a + :class:`letsencrypt.client.interfaces.IAuthenticator` object + + :returns: Authenticator selected + :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` + + """ + code, index = util(interfaces.IDisplay).menu( + "How would you like to authenticate with the Let's Encrypt CA?", + [str(auth.__class__) for auth in auths]) + + if code == display_util.OK: + return auths[index] + else: + sys.exit(0) + +def choose_names(installer): + """Display screen to select domains to validate. + + :param installer: An installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ + if installer is None: + return _choose_names_manually() + + names = list(installer.get_all_names()) + + if not names: + manual = 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}" + "If you do use the default vhost, you may specify the name " + "manually. Would you like to continue?{0}".format(os.linesep)) + + if manual: + return _choose_names_manually() + else: + sys.exit(0) + + code, names = _filter_names(names) + if code == display_util.OK and names: + return names + else: + sys.exit(0) + + +def _filter_names(names): + """Determine which names the user would like to select from a list. + + :param list names: domain names + + :returns: tuple of the form (`code`, `names`) where + `code` - str display exit code + `names` - list of names selected + :rtype: tuple + + """ + choices = [(n, "", 0) for n in names] + code, names = util(interfaces.IDisplay).checklist( + "Which names would you like to activate HTTPS for?", + choices=choices) + return code, [str(s) for s in names] + + +def _choose_names_manually(): + """Manualy input names for those without an installer.""" + + code, input = util(interfaces.IDisplay).input( + "Please enter in your domain name(s) (comma and/or space separated) ") + + if code == display_util.OK: + return display_util.separate_list_input(input) + + sys.exit(0) + + +def success_installation(domains): + """Display a box confirming the installation of HTTPS. + + :param list domains: domain names which were enabled + + """ + util(interfaces.IDisplay).notification( + "Congratulations! You have successfully enabled " + "%s!" % _gen_https_names(domains), pause=True) + + +def _gen_https_names(domains): + """Returns a string of the https domains. + + Domains are formatted nicely with https:// prepended to each. + + :param list domains: Each domain is a 'str' + + """ + if len(domains) == 1: + return "https://{0}".format(domains[0]) + elif len(domains) == 2: + return "https://{dom[0]} and https://{dom[1]}".format(dom=domains) + elif len(domains) > 2: + return "{0}{1}{2}".format( + ", ".join("https://" + dom for dom in domains[:-1]), + ", and https://", + domains[-1]) + + return "" diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py new file mode 100644 index 000000000..812b298b5 --- /dev/null +++ b/letsencrypt/client/display/revocation.py @@ -0,0 +1,106 @@ +import os + +import zope.component + +from letsencrypt.client import interfaces +from letsencrypt.client.display import display_util + +util = zope.component.getUtility + + +def choose_certs(certs): + """Display choose certificates menu. + + :param list certs: List of cert dicts. + + :returns: cert to revoke + :rtype: :class:`letsencrypt.client.revoker.Cert` + + """ + code, tag = display_certs(certs) + + if code == display_util.OK: + cert = certs[tag] + if confirm_revocation(cert): + return cert + else: + choose_certs(certs) + elif code == display_util.HELP: + cert = certs[tag] + more_info_cert(cert) + choose_certs(certs) + else: + exit(0) + + +def display_certs(certs): + """Display the certificates in a menu for revocation. + + :param list certs: each is a :class:`letsencrypt.client.revoker.Cert` + + :returns: tuple of the form (code, selection) where + code is a display exit code + selection is the user's int selection + :rtype: tuple + + """ + list_choices = [ + ("%s | %s | %s" % + (str(cert.get_cn().ljust(display_util.WIDTH - 39)), + cert.get_not_before().strftime("%m-%d-%y"), + "Installed" if cert.installed and cert.installed != ["Unknown"] + else "") + for cert in enumerate(certs)) + ] + + code, tag = util(interfaces.IDisplay).menu( + "Which certificates would you like to revoke?", + "Revoke number (c to cancel): ", + choices=list_choices, help_button=True, + help_label="More Info", ok_label="Revoke", + cancel_label="Exit") + if not tag: + tag = -1 + + return code, (int(tag) - 1) + + +def confirm_revocation(self, cert): + """Confirm revocation screen. + + :param cert: certificate object + :type cert: :class: + + :returns: True if user would like to revoke, False otherwise + :rtype: bool + + """ + text = ("{0}Are you sure you would like to revoke the following " + "certificate:{0}".format(os.linesep)) + text += cert.pretty_print() + text += "This action cannot be reversed!" + return display_util.OK == util(interfaces.IDisplay).yesno( + text, width=display_util.WIDTH, height=display_util.HEIGHT) + + +def more_info_cert(cert): + """Displays more info about the cert. + + :param dict cert: cert dict used throughout revoker.py + + """ + text = "{0}Certificate Information:{0}".format(os.linesep) + text += cert.pretty_print() + util(interfaces.IDisplay).notification(text, height=display_util.HEIGHT) + + +def success_revocation(cert): + """Display a success message. + + :param cert: cert that was revoked + :type cert: :class:`letsencrypt.client.revoker.Cert` + + """ + util(interfaces.IDisplay).notification( + "You have successfully revoked the certificate for " + "%s" % cert.get_cn()) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 1c6d4766f..757c1d705 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -45,19 +45,6 @@ class IAuthenticator(zope.interface.Interface): """Revert changes and shutdown after challenges complete.""" -class IChallenge(zope.interface.Interface): - """Let's Encrypt challenge.""" - - def perform(): - """Perform the challenge.""" - - def generate_response(): - """Generate response.""" - - def cleanup(): - """Cleanup.""" - - class IInstaller(zope.interface.Interface): """Generic Let's Encrypt Installer Interface. @@ -144,14 +131,17 @@ class IInstaller(zope.interface.Interface): class IDisplay(zope.interface.Interface): """Generic display.""" - def generic_notification(message): + def notification(message, height, pause): """Displays a string message :param str message: Message to display + :param int height: Height of dialog box if applicable + :param bool pause: Whether or not the application should pause for + confirmation (if available) """ - def generic_menu(message, choices, input_text=""): + def menu(message, choices, input_text="", ok_label="OK", help_label=""): """Displays a generic menu. :param str message: message to display @@ -163,29 +153,37 @@ class IDisplay(zope.interface.Interface): """ - def generic_input(message): - """Accept input from the user.""" + def input(message): + """Accept input from the user - def generic_yesno(message, yes_label="Yes", no_label="No"): - """A yes/no dialog.""" + :param str message: message to display to the user - def filter_names(names): - """Allow the user to select which names they would like to activate.""" + :returns: tuple of (`code`, `input`) where + `code` - str display exit code + `input` - str of the user's input + :rtype: tuple - def success_installation(domains): - """Display a congratulations message for new https domains.""" + """ - def display_certs(certs): - """Display a list of certificates.""" + def yesno(message, yes_label="Yes", no_label="No"): + """Query the user with a yes/no question. - def confirm_revocation(cert): - """Confirmation of revocation screen.""" + :param str message: question for the user - def more_info_cert(cert): - """Print out all information for a given certificate dict.""" + :returns: True for "Yes", False for "No" + :rtype: bool - def redirect_by_default(): - """Ask the user whether they would like to redirect to HTTPS.""" + """ + + def checkbox(message, choices): + """Allow for multiple selections from a menu. + + :param str message: message to display to the user + + :param choices: :param choices: choices + :type choices: :class:`list` of :func:`tuple` + + """ class IValidator(zope.interface.Interface): diff --git a/letsencrypt/client/log.py b/letsencrypt/client/log.py index 91319156b..90d923f76 100644 --- a/letsencrypt/client/log.py +++ b/letsencrypt/client/log.py @@ -3,7 +3,7 @@ import logging import dialog -from letsencrypt.client import display +from letsencrypt.client.display import display_util class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods @@ -19,8 +19,8 @@ class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods PADDING_HEIGHT = 2 PADDING_WIDTH = 4 - def __init__(self, level=logging.NOTSET, height=display.HEIGHT, - width=display.WIDTH - 4, d=None): + def __init__(self, level=logging.NOTSET, height=display_util.HEIGHT, + width=display_util.WIDTH - 4, d=None): # Handler not new-style -> no super logging.Handler.__init__(self, level) self.height = height diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 4bb2bd46c..f0bfd81b9 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -7,10 +7,10 @@ import time import zope.component from letsencrypt.client import CONFIG -from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client.display import display_util class Reverter(object): @@ -127,7 +127,7 @@ class Reverter(object): output.append(os.linesep) zope.component.getUtility(interfaces.IDisplay).generic_notification( - os.linesep.join(output), display.HEIGHT) + os.linesep.join(output), display_util.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): """Add files to temporary checkpoint diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index d16a4ee46..6978665b3 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -6,60 +6,74 @@ import os import shutil import M2Crypto -import zope.component from letsencrypt.client import acme from letsencrypt.client import CONFIG -from letsencrypt.client import display -from letsencrypt.client import interfaces +from letsencrypt.client import le_util from letsencrypt.client import network +from letsencrypt.client.display import display_util +from letsencrypt.client.display import revocation + class Revoker(object): """A revocation class for LE.""" + + list_path = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + def __init__(self, server, installer): self.network = network.Network(server) self.installer = installer - self.displayer = zope.component.getUtility(interfaces.IDisplay) def acme_revocation(self, cert): """Handle ACME "revocation" phase. - :param dict cert: TODO + :param cert: cert intended to be revoked + :type cert: :class:`letsencrypt.client.revoker.Cert` :returns: ACME "revocation" message. :rtype: dict """ cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() - with open(cert["backup_key_file"], "rU") as backup_key_file: + with open(cert.backup_key_path, "rU") as backup_key_file: key = backup_key_file.read() - revocation = self.network.send_and_receive_expected( + revoc = self.network.send_and_receive_expected( acme.revocation_request(cert_der, key), "revocation") - self.displayer.notification( - "You have successfully revoked the certificate for " - "%s" % cert["cn"]) + revocation.success_revocation(cert) self.remove_cert_key(cert) - self.list_certs_keys() + self.display_menu() - return revocation + return revoc - def list_certs_keys(self): + def display_menu(self): """List trusted Let's Encrypt certificates.""" - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - certs = [] - if not os.path.isfile(list_file): + if not os.path.isfile(Revoker.list_path): logging.info( "You don't have any certificates saved from letsencrypt") return csha1_vhlist = self._get_installed_locations() + certs = self._populate_saved_certs(csha1_vhlist) - with open(list_file, "rb") as csvfile: + if certs: + self._insert_installed_status(certs) + cert = self.choose_certs(certs) + self.acme_revocation(cert) + else: + logging.info( + "There are not any trusted Let's Encrypt " + "certificates for this server.") + + def _populate_saved_certs(self, csha1_vhlist): + """Populate a list of all the saved certs.""" + + certs = [] + with open(Revoker.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) # idx, orig_cert, orig_key for row in csvreader: @@ -78,16 +92,16 @@ class Revoker(object): cert.get_fingerprint, []) certs.append(cert) - if certs: - self._insert_installed_status(certs) - self.choose_certs(certs) - else: - self.displayer.notification( - "There are not any trusted Let's Encrypt " - "certificates for this server.") + + return certs def _get_installed_locations(self): - """Get installed locations of certificates""" + """Get installed locations of certificates + + :returns: cert sha1 fingerprint -> :class:`list` of vhosts where + the certificate is installed. + + """ csha1_vhlist = {} if self.installer is None: @@ -106,40 +120,27 @@ class Revoker(object): return csha1_vhlist - def choose_certs(self, certs): - """Display choose certificates menu. - - :param list certs: List of cert dicts. - - """ - code, tag = self.display_certs(certs) - - if code == display.OK: - cert = certs[tag] - if self.confirm_revocation(cert): - self.acme_revocation(cert) - else: - self.choose_certs(certs) - elif code == display.HELP: - cert = certs[tag] - self.displayer.more_info_cert(cert) - self.choose_certs(certs) - else: - exit(0) - def remove_cert_key(self, cert): # pylint: disable=no-self-use """Remove certificate and key. - :param cert: Cert dict used throughout revocation + :param cert: cert object + :type cert: :class:`letsencrypt.client.revoker.Cert` """ - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + self._remove_cert_from_list(cert) - with open(list_file, "rb") as orgfile: + # Remove files + os.remove(cert["backup_cert_file"]) + os.remove(cert["backup_key_file"]) + + def _remove_cert_from_list(self, cert): + """Remove a certificate from the LIST file.""" + list_path2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + + with open(Revoker.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) - with open(list_file2, "wb") as newfile: + with open(list_path2, "wb") as newfile: csvwriter = csv.writer(newfile) for row in csvreader: @@ -148,67 +149,62 @@ class Revoker(object): row[2] == cert.orig_key.path): csvwriter.writerow(row) - shutil.copy2(list_file2, list_file) - os.remove(list_file2) - os.remove(cert["backup_cert_file"]) - os.remove(cert["backup_key_file"]) + shutil.copy2(list_path2, Revoker.list_path) + os.remove(list_path2) - def display_certs(self, certs): - """Display the certificates in a menu for revocation. + @classmethod + def store_cert_key(cls, cert_path, key_path, encrypt=False): + """Store certificate key. (Used to allow quick revocation) - :param list certs: `list` of :class:`letsencrypt.client. + :param str cert_path: Path to a certificate file. + :param key_path: Authorized key for certificate + :type key_path: :class:`letsencrypt.client.le_util.Key` - :returns: tuple of the form (code, selection) where - code is a display exit code - selection is the user's int selection - :rtype: tuple + :param bool encrypt: Should the certificate key be encrypted? - """ - list_choices = [ - ("%s | %s | %s" % - (str(cert.get_cn().ljust(display.WIDTH - 39)), - cert.get_not_before().strftime("%m-%d-%y"), - "Installed" if cert.installed and cert.installed != ["Unknown"] - else "") - for cert in enumerate(certs)) - ] - - code, tag = self.displayer.menu( - "Which certificates would you like to revoke?", - "Revoke number (c to cancel): ", - choices=list_choices, help_button=True, - help_label="More Info", ok_label="Revoke") - if not tag: - tag = -1 - - return code, (int(tag) - 1) - - def confirm_revocation(self, cert): - """Confirm revocation screen. - - :param cert: certificate object - :type cert: :class: - - :returns: True if user would like to revoke, False otherwise + :returns: True if key file was stored successfully, False otherwise. :rtype: bool """ - text = ("{0}Are you sure you would like to revoke the following " - "certificate:{0}".format(os.linesep)) - text += cert.pretty_print() - text += "This action cannot be reversed!" - return display.OK == self.dialog.yesno( - text, width=self.width, height=self.height) + le_util.make_or_verify_dir(CONFIG.CERT_KEY_BACKUP, 0o700) + idx = 0 - def more_info_cert(self, cert): - """Displays more info about the cert. + if encrypt: + logging.error( + "Unfortunately securely storing the certificates/" + "keys is not yet available. Stay tuned for the " + "next update!") + return False - :param dict cert: cert dict used throughout revoker.py + cls._append_index_file(cert_path, key_path) - """ - text = "{0}Certificate Information:{0}".format(os.linesep) - text += cert.pretty_print() - self.notification(text, height=self.height) + shutil.copy2(key_path, + os.path.join( + CONFIG.CERT_KEY_BACKUP, + os.path.basename(key_path) + "_" + str(idx))) + shutil.copy2(cert_path, + os.path.join( + CONFIG.CERT_KEY_BACKUP, + os.path.basename(cert_path) + "_" + str(idx))) + + return True + + @classmethod + def _append_index_file(cls, cert_path, key_path): + if os.path.isfile(Revoker.list_path): + with open(Revoker.list_path, 'r+b') as csvfile: + csvreader = csv.reader(csvfile) + + # Find the highest index in the file + for row in csvreader: + idx = int(row[0]) + 1 + csvwriter = csv.writer(csvfile) + csvwriter.writerow([str(idx), cert_path, key_path]) + + else: + with open(Revoker.list_path, 'wb') as csvfile: + csvwriter = csv.writer(csvfile) + csvwriter.writerow(["0", cert_path, key_path]) class Cert(object): @@ -232,7 +228,7 @@ class Cert(object): PathStatus = collections.namedtuple("PathStatus", "path status") """Convenience container to hold path and status info""" - def __init__(self, cert_filepath): + def __init__(self, cert_path): """Cert initialization :param str cert_filepath: Name of file containing certificate in @@ -240,7 +236,7 @@ class Cert(object): """ try: - self.cert = M2Crypto.X509.load_cert(cert_filepath) + self.cert = M2Crypto.X509.load_cert(cert_path) except (IOError, M2Crypto.X509.X509Error): self.cert = None @@ -329,7 +325,6 @@ class Cert(object): return "" def __str__(self): - """Turn a Certinto a string.""" text = [] text.append("Subject: %s" % self.get_subject()) text.append("SAN: %s" % self.get_san()) @@ -344,8 +339,8 @@ class Cert(object): def pretty_print(self): """Nicely frames a cert str""" - text = "-" * (display.WIDTH - 4) + os.linesep + text = "-" * (display_util.WIDTH - 4) + os.linesep text += str(self) - text += "-" * (display.WIDTH - 4) + text += "-" * (display_util.WIDTH - 4) return text diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index 453952a19..00302327f 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -8,7 +8,7 @@ import augeas import mock import zope.component -from letsencrypt.client import display +from letsencrypt.client.display import display_util from letsencrypt.client import errors from letsencrypt.client.apache import parser @@ -21,7 +21,7 @@ class ApacheParserTest(util.ApacheTest): def setUp(self): super(ApacheParserTest, self).setUp() - zope.component.provideUtility(display.FileDisplay(sys.stdout)) + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) self.parser = parser.ApacheParser( augeas.Augeas(flags=augeas.Augeas.NONE), diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 88ec66a19..8fc327ad5 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -22,7 +22,7 @@ class DvsniGenCertTest(unittest.TestCase): r_b64 = le_util.jose_b64encode(dvsni_r) pem = pkg_resources.resource_string( __name__, os.path.join("testdata", "rsa256_key.pem")) - key = client.Client.Key("path", pem) + key = le_util.Client.Key("path", pem) nonce = "12345ABCDE" cert_pem, s_b64 = self._call(domain, r_b64, nonce, key) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index f7f97dc87..497bb8be0 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -31,7 +31,7 @@ class RollbackTest(unittest.TestCase): def test_misconfiguration_fixed(self, mock_det, mock_rev, mock_input): mock_det.side_effect = [errors.LetsEncryptMisconfigurationError, self.m_install] - self.m_input().yesno.return_value = True + mock_input().yesno.return_value = True self._call(1) @@ -50,7 +50,7 @@ class RollbackTest(unittest.TestCase): self, mock_det, mock_rev, mock_warn, mock_input): mock_det.side_effect = errors.LetsEncryptMisconfigurationError - self.m_input().yesno.return_value = True + mock_input().yesno.return_value = True self._call(1) @@ -70,7 +70,7 @@ class RollbackTest(unittest.TestCase): self, mock_det, mock_rev, mock_input): mock_det.side_effect = errors.LetsEncryptMisconfigurationError - self.m_input().yesno.return_value = False + mock_input().yesno.return_value = False self._call(1) diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 8b1a8ecd7..5bf804773 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -1,5 +1,4 @@ """Tests for letsencrypt.client.crypto_util.""" -import datetime import os import pkg_resources import unittest @@ -132,42 +131,42 @@ class MakeSSCertTest(unittest.TestCase): make_ss_cert(RSA256_KEY, ['example.com', 'www.example.com']) -class GetCertInfoTest(unittest.TestCase): - """Tests for letsencrypt.client.crypto_util.get_cert_info.""" - - def setUp(self): - self.cert_info = { - 'not_before': datetime.datetime( - 2014, 12, 11, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), - 'not_after': datetime.datetime( - 2014, 12, 18, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), - 'subject': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' - 'of Michigan and the EFF, CN=example.com', - 'cn': 'example.com', - 'issuer': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' - 'of Michigan and the EFF, CN=example.com', - 'serial': 1337L, - 'pub_key': 'RSA 512', - } - - def _call(self, name): - from letsencrypt.client.crypto_util import get_cert_info - self.assertEqual(get_cert_info(pkg_resources.resource_filename( - __name__, os.path.join('testdata', name))), self.cert_info) - - def test_single_domain(self): - self.cert_info.update({ - 'san': '', - 'fingerprint': '9F8CE01450D288467C3326AC0457E351939C72E', - }) - self._call('cert.pem') - - def test_san(self): - self.cert_info.update({ - 'san': 'DNS:example.com, DNS:www.example.com', - 'fingerprint': '62F7110431B8E8F55905DBE5592518F9634AC50A', - }) - self._call('cert-san.pem') +# class GetCertInfoTest(unittest.TestCase): +# """Tests for letsencrypt.client.crypto_util.get_cert_info.""" +# +# def setUp(self): +# self.cert_info = { +# 'not_before': datetime.datetime( +# 2014, 12, 11, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), +# 'not_after': datetime.datetime( +# 2014, 12, 18, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), +# 'subject': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' +# 'of Michigan and the EFF, CN=example.com', +# 'cn': 'example.com', +# 'issuer': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' +# 'of Michigan and the EFF, CN=example.com', +# 'serial': 1337L, +# 'pub_key': 'RSA 512', +# } +# +# def _call(self, name): +# from letsencrypt.client.crypto_util import get_cert_info +# self.assertEqual(get_cert_info(pkg_resources.resource_filename( +# __name__, os.path.join('testdata', name))), self.cert_info) +# +# def test_single_domain(self): +# self.cert_info.update({ +# 'san': '', +# 'fingerprint': '9F8CE01450D288467C3326AC0457E351939C72E', +# }) +# self._call('cert.pem') +# +# def test_san(self): +# self.cert_info.update({ +# 'san': 'DNS:example.com, DNS:www.example.com', +# 'fingerprint': '62F7110431B8E8F55905DBE5592518F9634AC50A', +# }) +# self._call('cert-san.pem') class B64CertToPEMTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/display_util_test.py b/letsencrypt/client/tests/display/display_util_test.py new file mode 100644 index 000000000..3c527fd87 --- /dev/null +++ b/letsencrypt/client/tests/display/display_util_test.py @@ -0,0 +1,105 @@ +import sys +import unittest + +import mock + +from letsencrypt.client.display import display_util + + +class DisplayT(unittest.TestCase): + def setUp(self): + self.choices = [("First", "Description1"), ("Second", "Description2")] + self.tags = ["tag1", "tag2", "tag3"] + + +def test_visual(displayer, choices): + """Visually test all of the display functions.""" + displayer.notification("Random notification!") + displayer.menu("Question?", choices, + ok_label="O", cancel_label="Can", help_label="??") + displayer.menu("Question?", [choice[1] for choice in choices], + ok_label="O", cancel_label="Can", help_label="??") + displayer.input("Input Message") + displayer.yesno( + "Yes/No Message", yes_label="Yessir", no_label="Nosir") + displayer.checklist( + "Checklist Message", [choice[0] for choice in choices]) + + +class NcursesDisplayTest(DisplayT): + """Test ncurses display.""" + def setUp(self): + super(NcursesDisplayTest, self).setUp() + self.displayer = display_util.NcursesDisplay() + + @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.msgbox") + def test_notification(self, mock_msgbox): + """Kind of worthless... one liner.""" + self.displayer.notification("message") + self.assertEqual(mock_msgbox.call_count, 1) + + @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.menu") + def test_menu(self, mock_menu): + pass + + def test_visual(self): + test_visual(self.displayer, self.choices) + + +class FileOutputDisplayTest(DisplayT): + """Test stdout display.""" + def setUp(self): + super(FileOutputDisplayTest, self).setUp() + self.displayer = display_util.FileDisplay(sys.stdout) + + def test_visual(self): + test_visual(self.displayer, self.choices) + + +class SeparateListInputTest(unittest.TestCase): + """Test Module functions.""" + def setUp(self): + self.exp = ["a", "b", "c", "test"] + + @classmethod + def _call(cls, input): + from letsencrypt.client.display.display_util import separate_list_input + return separate_list_input(input) + + def test_commas(self): + actual = self._call("a,b,c,test") + self.assertEqual(actual, self.exp) + + def test_spaces(self): + actual = self._call("a b c test") + self.assertEqual(actual, self.exp) + + def test_both(self): + actual = self._call("a, b, c, test") + self.assertEqual(actual, self.exp) + + def test_mess(self): + actual = [self._call(" a , b c \t test")] + actual.append(self._call(",a, ,, , b c test ")) + + for act in actual: + self.assertEqual(act, self.exp) + + +class PlaceParensTest(unittest.TestCase): + @classmethod + def _call(cls, label): + from letsencrypt.client.display.display_util import _parens_around_char + return _parens_around_char(label) + + def test_single_letter(self): + ret = self._call("a") + self.assertEqual("(a)", ret) + + def test_multiple(self): + ret = self._call("Label") + self.assertEqual("(L)abel", ret) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py new file mode 100644 index 000000000..bb105ce65 --- /dev/null +++ b/letsencrypt/client/tests/display/ops_test.py @@ -0,0 +1,159 @@ +import sys +import unittest + +import mock +import zope.component + +from letsencrypt.client.display import display_util + + +class ChooseAuthenticatorTest(unittest.TestCase): + """Test choose_authenticator function.""" + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + @classmethod + def _call(cls, auths): + from letsencrypt.client.display.ops import choose_authenticator + return choose_authenticator(auths) + + @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(["authenticator1", "auth2"]) + + self.assertEqual(ret, "authenticator1") + + @mock.patch("letsencrypt.client.display.ops.util") + def test_no_choice(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, 0) + + self.assertRaises(SystemExit, self._call, ["authenticator1"]) + + +class GenHttpsNamesTest(unittest.TestCase): + """Test _gen_https_names""" + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + @classmethod + def _call(cls, domains): + from letsencrypt.client.display.ops import _gen_https_names + return _gen_https_names(domains) + + def test_zero(self): + self.assertEqual(self._call([]), "") + + def test_one(self): + dom = "example.com" + self.assertEqual(self._call([dom]), "https://%s" % dom) + + def test_two(self): + doms = ["foo.bar.org", "bar.org"] + self.assertEqual( + self._call(doms), + "https://{dom[0]} and https://{dom[1]}".format(dom=doms)) + + def test_three(self): + doms = ["a.org", "b.org", "c.org"] + # We use an oxford comma + self.assertEqual( + self._call(doms), + "https://{dom[0]}, https://{dom[1]}, and https://{dom[2]}".format( + dom=doms)) + + def test_four(self): + doms = ["a.org", "b.org", "c.org", "d.org"] + exp = ("https://{dom[0]}, https://{dom[1]}, https://{dom[2]}, " + "and https://{dom[3]}".format(dom=doms)) + + self.assertEqual(self._call(doms), exp) + + +class ChooseNamesTest(unittest.TestCase): + """Test choose names.""" + def setUp(self): + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + self.mock_install = mock.MagicMock() + + @classmethod + def _call(cls, installer): + from letsencrypt.client.display.ops import choose_names + return choose_names(installer) + + @mock.patch("letsencrypt.client.display.ops._choose_names_manually") + def test_no_installer(self, mock_manual): + self._call(None) + self.assertEqual(mock_manual.call_count, 1) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_no_installer_cancel(self, mock_util): + mock_util().input.return_value = (display_util.CANCEL, []) + self.assertRaises(SystemExit, self._call, None) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_no_names_choose(self, mock_util): + self.mock_install().get_all_names.return_value = set() + mock_util().yesno.return_value = True + domain = "example.com" + mock_util().input.return_value = (display_util.OK, domain) + + actual_doms = self._call(self.mock_install) + self.assertEqual(mock_util().input.call_count, 1) + self.assertEqual(actual_doms, [domain]) + + @mock.patch("letsencrypt.client.display.ops.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.assertRaises(SystemExit, self._call, self.mock_install) + + @mock.patch("letsencrypt.client.display.ops.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"]) + + names = self._call(self.mock_install) + self.assertEqual(names, ["example.com"]) + self.assertEqual(mock_util().checklist.call_count, 1) + + @mock.patch("letsencrypt.client.display.ops.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.assertRaises(SystemExit, self._call, self.mock_install) + + @mock.patch("letsencrypt.client.display.ops.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 = (display_util.CANCEL, ["example.com"]) + + self.assertRaises(SystemExit, self._call, self.mock_install) + + +class SuccessInstallationTest(unittest.TestCase): + + @classmethod + def _call(cls, names): + from letsencrypt.client.display.ops import success_installation + success_installation(names) + + @mock.patch("letsencrypt.client.display.ops.util") + def test_success_installation(self, mock_util): + mock_util().notification.return_value = None + names = ["example.com", "abc.com"] + + self._call(names) + + self.assertEqual(mock_util().notification.call_count, 1) + arg = mock_util().notification.call_args_list[0][0][0] + + for name in names: + self.assertTrue(name in arg) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index ae6be821a..4645d5e3d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -15,11 +15,12 @@ import zope.interface import letsencrypt from letsencrypt.client import CONFIG from letsencrypt.client import client -from letsencrypt.client import display 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 display_util +from letsencrypt.client.display import ops def main(): # pylint: disable=too-many-statements,too-many-branches @@ -77,9 +78,9 @@ def main(): # pylint: disable=too-many-statements,too-many-branches logger.setLevel(logging.INFO) if args.use_curses: logger.addHandler(log.DialogHandler()) - displayer = display.NcursesDisplay() + displayer = display_util.NcursesDisplay() else: - displayer = display.FileDisplay(sys.stdout) + displayer = display_util.FileDisplay(sys.stdout) zope.component.provideUtility(displayer) if args.view_config_changes: @@ -100,11 +101,11 @@ def main(): # pylint: disable=too-many-statements,too-many-branches # Make sure we actually get an installer that is functioning properly # before we begin to try to use it. try: - installer = client.determine_installer() + installer = client.determine_authenticator() except errors.LetsEncryptMisconfigurationError as err: - logging.fatal("Please fix your configuration before proceeding. " - "The Installer exited with the following message: " - "%s", err) + logging.fatal("Please fix your configuration before proceeding.{0}" + "The Authenticator exited with the following message: " + "{1}".format(os.linesep, err)) sys.exit(1) # Use the same object if possible @@ -113,7 +114,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches else: auth = client.determine_authenticator() - domains = choose_names(installer) if args.domains is None else args.domains + domains = ops.choose_names(installer) if args.domains is None else args.domains # Prepare for init of Client if args.privkey is None: @@ -146,42 +147,6 @@ def display_eula(): sys.exit(0) -def choose_names(installer): - """Display screen to select domains to validate. - - :param installer: An installer object - :type installer: :class:`letsencrypt.client.interfaces.IInstaller` - - """ - # This function adds all names found in the installer configuration - # Then filters them based on user selection - code, names = zope.component.getUtility( - interfaces.IDisplay).filter_names(get_all_names(installer)) - if code == display.OK and names: - return names - else: - sys.exit(0) - - -def get_all_names(installer): - """Return all valid names in the configuration. - - :param installer: An installer object - :type installer: :class:`letsencrypt.client.interfaces.IInstaller` - - """ - names = list(installer.get_all_names()) - - if not names: - logging.fatal("No domain names were found in your installation") - logging.fatal("Either specify which names you would like " - "letsencrypt to validate or add server names " - "to your virtual hosts") - sys.exit(1) - - return names - - def read_file(filename): """Returns the given file's contents with universal new line support. diff --git a/setup.py b/setup.py index 5501c7dd6..14f70146c 100755 --- a/setup.py +++ b/setup.py @@ -61,8 +61,10 @@ setup( 'letsencrypt', 'letsencrypt.client', 'letsencrypt.client.apache', + 'letsencrypt.client.display', 'letsencrypt.client.tests', 'letsencrypt.client.tests.apache', + 'letsencrypt.client.tests.display', 'letsencrypt.scripts', ], install_requires=install_requires,