From 21147a816338ca801219daa8b00715ab491a081a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 2 Feb 2015 16:17:57 -0800 Subject: [PATCH 01/46] initial display revision --- letsencrypt/client/client.py | 13 +- letsencrypt/client/crypto_util.py | 74 ++--- letsencrypt/client/display.py | 299 ++++++++++-------- letsencrypt/client/enhance_display.py | 67 ++++ letsencrypt/client/recovery_token.py | 2 +- letsencrypt/client/revoker.py | 268 ++++++++++++++-- letsencrypt/client/tests/client_test.py | 6 +- .../client/tests/recovery_token_test.py | 2 +- letsencrypt/scripts/main.py | 2 +- 9 files changed, 506 insertions(+), 227 deletions(-) create mode 100644 letsencrypt/client/enhance_display.py diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 0f5166399..ccd957885 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -379,7 +379,6 @@ def init_key(key_size): def init_csr(privkey, names): """Initialize a CSR with the given private key.""" - csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) # Save CSR @@ -494,7 +493,7 @@ def rollback(checkpoints): def _misconfigured_rollback(checkpoints): """Handles the case where the Installer is misconfigured.""" - yes = zope.component.getUtility(interfaces.IDisplay).generic_yesno( + yes = zope.component.getUtility(interfaces.IDisplay).yesno( "Oh, no! The web server is currently misconfigured.{0}{0}" "Would you still like to rollback the " "configuration?".format(os.linesep)) @@ -533,20 +532,12 @@ def revoke(server): try: installer = determine_installer() except errors.LetsEncryptMisconfigurationError: - zope.component.getUtility(interfaces.IDisplay).generic_notification( + zope.component.getUtility(interfaces.IDisplay).notification( "The web server is currently misconfigured. Some " "abilities like seeing which certificates are currently " "installed may not be available.") installer = None - # This is a temporary fix to avoid errors. The Revoker is not fully - # developed. - if installer is None: - zope.component.getUtility(interfaces.IDisplay).generic_notification( - "The Let's Encrypt Revoker module does not currently support " - "revocation without a valid installer. This feature should come " - "soon.") - return revoc = revoker.Revoker(server, installer) revoc.list_certs_keys() diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index c1f59aa45..13d52c4fb 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -1,4 +1,9 @@ -"""Let's Encrypt client crypto utility functions""" +"""Let's Encrypt client crypto utility functions + +.. todo:: Make the transition to use PSS rather than PKCS1_v1_5 when the server + is capable of handling the signatures. + +""" import binascii import logging import time @@ -17,16 +22,14 @@ from letsencrypt.client import le_util def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): """Create signature with nonce prepended to the message. - .. todo:: Change this over to M2Crypto... PKey - .. todo:: Protect against crypto unicode errors... is this sufficient? Do I need to escape? + :param str msg: Message to be signed + :param str key_str: Key in string form. Accepted formats are the same as for `Crypto.PublicKey.RSA.importKey`. - :param str msg: Message to be signed - :param nonce: Nonce to be used. If None, nonce of `nonce_len` size will be randomly generated. :type nonce: str or None @@ -184,71 +187,38 @@ def make_ss_cert(key_str, domains, not_before=None, pubkey = M2Crypto.EVP.PKey() pubkey.assign_rsa(rsa_key) - cert = M2Crypto.X509.X509() - cert.set_pubkey(pubkey) - cert.set_serial_number(1337) - cert.set_version(2) + m2_cert = M2Crypto.X509.X509() + m2_cert.set_pubkey(pubkey) + m2_cert.set_serial_number(1337) + m2_cert.set_version(2) current_ts = long(time.time() if not_before is None else not_before) current = M2Crypto.ASN1.ASN1_UTCTIME() current.set_time(current_ts) expire = M2Crypto.ASN1.ASN1_UTCTIME() expire.set_time(current_ts + validity) - cert.set_not_before(current) - cert.set_not_after(expire) + m2_cert.set_not_before(current) + m2_cert.set_not_after(expire) - subject = cert.get_subject() + subject = m2_cert.get_subject() subject.C = "US" subject.ST = "Michigan" subject.L = "Ann Arbor" subject.O = "University of Michigan and the EFF" subject.CN = domains[0] - cert.set_issuer(cert.get_subject()) + m2_cert.set_issuer(m2_cert.get_subject()) if len(domains) > 1: - cert.add_ext(M2Crypto.X509.new_extension( + m2_cert.add_ext(M2Crypto.X509.new_extension( 'basicConstraints', 'CA:FALSE')) - # cert.add_ext(M2Crypto.X509.new_extension( - # 'extendedKeyUsage', 'TLS Web Server Authentication')) - cert.add_ext(M2Crypto.X509.new_extension( + m2_cert.add_ext(M2Crypto.X509.new_extension( 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) - cert.sign(pubkey, 'sha256') - assert cert.verify(pubkey) - assert cert.verify() + m2_cert.sign(pubkey, 'sha256') + assert m2_cert.verify(pubkey) + assert m2_cert.verify() # print check_purpose(,0 - return cert.as_pem() - - -def get_cert_info(filename): - """Get certificate info. - - .. todo:: Pub key is assumed to be RSA... find a good solution to allow EC. - - :param str filename: Name of file containing certificate in PEM format. - - :rtype: dict - - """ - # M2Crypto Library only supports RSA right now - cert = M2Crypto.X509.load_cert(filename) - - try: - san = cert.get_ext("subjectAltName").get_value() - except LookupError: - san = "" - - return { - "not_before": cert.get_not_before().get_datetime(), - "not_after": cert.get_not_after().get_datetime(), - "subject": cert.get_subject().as_text(), - "cn": cert.get_subject().CN, - "issuer": cert.get_issuer().as_text(), - "fingerprint": cert.get_fingerprint(md='sha1'), - "san": san, - "serial": cert.get_serial_number(), - "pub_key": "RSA " + str(cert.get_pubkey().size() * 8), - } + return m2_cert.as_pem() def b64_cert_to_pem(b64_der_cert): diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 867675495..6d76cdfeb 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -1,4 +1,5 @@ """Lets Encrypt display.""" +import os import textwrap import dialog @@ -11,26 +12,7 @@ WIDTH = 72 HEIGHT = 20 -class CommonDisplayMixin(object): # pylint: disable=too-few-public-methods - """Mixin with methods common to classes implementing IDisplay.""" - - def redirect_by_default(self): # pylint: disable=missing-docstring - choices = [ - ("Easy", "Allow both HTTP and HTTPS access to these sites"), - ("Secure", "Make all requests redirect to secure HTTPS access")] - - result = self.generic_menu( - "Please choose whether HTTPS access is required or optional.", - choices, "Please enter the appropriate number") - - if result[0] != OK: - return False - - # different answer for each type of display - return str(result[1]) == "Secure" or result[1] == 1 - - -class NcursesDisplay(CommonDisplayMixin): +class NcursesDisplay(object): """Ncurses-based display.""" zope.interface.implements(interfaces.IDisplay) @@ -41,34 +23,90 @@ class NcursesDisplay(CommonDisplayMixin): self.width = width self.height = height - def generic_notification(self, message): - # pylint: disable=missing-docstring - self.dialog.msgbox(message, width=self.width) + def notification(self, message, height=10): + """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 + + """ + self.dialog.msgbox(message, height, width=self.width) + + def menu(self, message, choices, unused_input_text="", + ok_label="OK", 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) + + :returns: tuple of the form (code, tag) where + code is a display exit code + tag is the tag string corresponding to the item chosen + :rtype: tuple + + """ + if help_label: + help_button = True + else: + help_button = False - def generic_menu(self, message, choices, unused_input_text=""): - # pylint: disable=missing-docstring # Can accept either tuples or just the actual choices if choices and isinstance(choices[0], tuple): code, selection = self.dialog.menu( - message, choices=choices, width=self.width, height=self.height) + message, choices=choices, ok_label=ok_label, + help_button=help_button, help_label=help_label, + width=self.width, height=self.height) + return code, str(selection) else: choices = list(enumerate(choices, 1)) code, tag = self.dialog.menu( - message, choices=choices, width=self.width, height=self.height) + message, choices=choices, ok_label=ok_label, + help_button=help_button, help_label=help_label, + width=self.width, height=self.height) - return code(int(tag) - 1) + return code, int(tag) - 1 - def generic_input(self, message): # pylint: disable=missing-docstring + 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 is a display exit code + string is the input entered by the user + + """ return self.dialog.inputbox(message) - def generic_yesno(self, message, yes_label="Yes", no_label="No"): - # pylint: disable=missing-docstring + 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 filter_names(self, names): # pylint: disable=missing-docstring + def filter_names(self, 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 is a display exit code + names is a list of names selected + :rtype: tuple + + """ choices = [(n, "", 0) for n in names] code, names = self.dialog.checklist( "Which names would you like to activate HTTPS for?", @@ -76,44 +114,17 @@ class NcursesDisplay(CommonDisplayMixin): return code, [str(s) for s in names] def success_installation(self, domains): - # pylint: disable=missing-docstring + """Display a box confirming the installation of HTTPS. + + :param list domains: domain names which were enabled + + """ self.dialog.msgbox( "\nCongratulations! You have successfully enabled " + gen_https_names(domains) + "!", width=self.width) - def display_certs(self, certs): # pylint: disable=missing-docstring - list_choices = [ - (str(i+1), "%s | %s | %s" % - (str(c["cn"].ljust(self.width - 39)), - c["not_before"].strftime("%m-%d-%y"), - "Installed" if c["installed"] else "")) - for i, c in enumerate(certs)] - code, tag = self.dialog.menu( - "Which certificates would you like to revoke?", - choices=list_choices, help_button=True, - help_label="More Info", ok_label="Revoke", - width=self.width, height=self.height) - if not tag: - tag = -1 - return code, (int(tag) - 1) - - def confirm_revocation(self, cert): # pylint: disable=missing-docstring - text = ("Are you sure you would like to revoke the following " - "certificate:\n") - text += cert_info_frame(cert) - text += "This action cannot be reversed!" - return self.dialog.DIALOG_OK == self.dialog.yesno( - text, width=self.width, height=self.height) - - def more_info_cert(self, cert): # pylint: disable=missing-docstring - text = "Certificate Information:\n" - text += cert_info_frame(cert) - print text - self.dialog.msgbox(text, width=self.width, height=self.height) - - -class FileDisplay(CommonDisplayMixin): +class FileDisplay(object): """File-based display.""" zope.interface.implements(interfaces.IDisplay) @@ -122,15 +133,37 @@ class FileDisplay(CommonDisplayMixin): super(FileDisplay, self).__init__() self.outfile = outfile - def generic_notification(self, message): - # pylint: disable=missing-docstring + def notification(self, message, unused_height): + """Displays a notification and waits for user acceptance. + + :param str message: Message to display + + """ side_frame = '-' * 79 - msg = textwrap.fill(message, 80) - self.outfile.write("\n%s\n%s\n%s\n" % (side_frame, msg, side_frame)) + lines = message.splitlines() + fixed_l = [] + for line in lines: + fixed_l.append(textwrap.fill(line, 80)) + self.outfile.write( + "{0}{1}{0}{2}{0}{1}{0}".format( + os.linesep, side_frame, os.linesep.join(fixed_l))) raw_input("Press Enter to Continue") - def generic_menu(self, message, choices, input_text=""): - # pylint: disable=missing-docstring + def menu(self, message, choices, input_text="", + unused_ok_label = "", unused_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) + + :returns: tuple of the form (code, tag) where + code is a display exit code + tag is the tag string corresponding to the item chosen + :rtype: tuple + + """ # 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] @@ -145,50 +178,86 @@ class FileDisplay(CommonDisplayMixin): self.outfile.write("%s\n" % side_frame) - code, selection = self.__get_valid_int_ans( + code, selection = self._get_valid_int_ans( "%s (c to cancel): " % input_text) return code, (selection - 1) - def generic_input(self, message): - # pylint: disable=no-self-use,missing-docstring + 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 is a display exit code + input is a str of the user's input + :rtype: tuple + + """ ans = raw_input("%s (Enter c to cancel)\n" % message) - if ans.startswith('c') or ans.startswith('C'): - return CANCEL, -1 + if ans == 'c' or ans == 'C': + return CANCEL, "-1" else: return OK, ans - def generic_yesno(self, message, unused_yes_label="", unused_no_label=""): - # pylint: disable=missing-docstring + def yesno(self, message, unused_yes_label="", unused_no_label=""): + """Query the user with a yes/no question. + + :param str message: question for the user + + :returns: True for 'Yes', False for 'No" + :rtype: bool + + """ self.outfile.write("\n%s\n" % textwrap.fill(message, 80)) ans = raw_input("y/n: ") return ans.startswith('y') or ans.startswith('Y') - def filter_names(self, names): # pylint: disable=missing-docstring - code, tag = self.generic_menu( + def filter_names(self, 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 is a display exit code + names is a list of names selected + :rtype: tuple + + """ + code, tag = self.menu( "Choose the names would you like to upgrade to HTTPS?", names, "Select the number of the name: ") # Make sure to return a list... return code, [names[tag]] - def display_certs(self, certs): # pylint: disable=missing-docstring - menu_choices = [(str(i+1), str(c["cn"]) + " - " + c["pub_key"] + - " - " + str(c["not_before"])[:-6]) - for i, c in enumerate(certs)] + def success_installation(self, domains): + """Display a box confirming the installation of HTTPS. - self.outfile.write("Which certificate would you like to revoke?\n") - for choice in menu_choices: - self.outfile.write(textwrap.fill( - "%s: %s - %s Signed (UTC): %s\n" % choice[:4])) + :param list domains: domain names which were enabled - return self.__get_valid_int_ans("Revoke Number (c to cancel): ") - 1 + """ + side_frame = '*' * 79 + msg = textwrap.fill("Congratulations! You have successfully " + "enabled %s!" % gen_https_names(domains)) + self.outfile.write("%s\n%s\n%s\n" % (side_frame, msg, side_frame)) - def __get_valid_int_ans(self, input_string): + + def _get_valid_int_ans(self, input_string): + """Get a numerical selection. + + :param str input_string: Instructions for the user to make a selection. + + :returns: tuple of the form (code, selection) where + code is a display exit code + selection is the user's int selection + :rtype: tuple + + """ valid_ans = False - - e_msg = "Please input a number or the letter c to cancel\n" + e_msg = "Make a selection by inputting the appropriate number.\n" while not valid_ans: ans = raw_input(input_string) @@ -210,54 +279,24 @@ class FileDisplay(CommonDisplayMixin): return code, selection - def success_installation(self, domains): - # pylint: disable=missing-docstring - side_frame = '*' * 79 - msg = textwrap.fill("Congratulations! You have successfully " - "enabled %s!" % gen_https_names(domains)) - self.outfile.write("%s\n%s\n%s\n" % (side_frame, msg, side_frame)) - - def confirm_revocation(self, cert): # pylint: disable=missing-docstring - self.outfile.write("Are you sure you would like to revoke " - "the following certificate:\n") - self.outfile.write(cert_info_frame(cert)) - self.outfile("This action cannot be reversed!\n") - ans = raw_input("y/n") - return ans.startswith('y') or ans.startswith('Y') - - def more_info_cert(self, cert): # pylint: disable=missing-docstring - self.outfile.write("\nCertificate Information:\n") - self.outfile.write(cert_info_frame(cert)) +# Display exit codes OK = "ok" +"""Display exit code indicating user acceptance""" + CANCEL = "cancel" +"""Display exit code for a user canceling the display""" + HELP = "help" - - -def cert_info_frame(cert): # pylint: disable=missing-docstring - text = "-" * (WIDTH - 4) + "\n" - text += cert_info_string(cert) - text += "-" * (WIDTH - 4) - return text - - -def cert_info_string(cert): # pylint: disable=missing-docstring - text = "Subject: %s\n" % cert["subject"] - text += "SAN: %s\n" % cert["san"] - text += "Issuer: %s\n" % cert["issuer"] - text += "Public Key: %s\n" % cert["pub_key"] - text += "Not Before: %s\n" % str(cert["not_before"]) - text += "Not After: %s\n" % str(cert["not_after"]) - text += "Serial Number: %s\n" % cert["serial"] - text += "SHA1: %s\n" % cert["fingerprint"] - text += "Installed: %s\n" % cert["installed"] - return text +"""Display exit code when for when the user requests more help.""" def gen_https_names(domains): """Returns a string of the https domains. Domains are formatted nicely with https:// prepended to each. + .. todo:: This should not use +=, rewrite this with unittests + """ result = "" if len(domains) > 2: diff --git a/letsencrypt/client/enhance_display.py b/letsencrypt/client/enhance_display.py new file mode 100644 index 000000000..9de58127b --- /dev/null +++ b/letsencrypt/client/enhance_display.py @@ -0,0 +1,67 @@ +"""Let's Encrypt Enhancement Display""" +import logging + +import zope.component + +from letsencrypt.client import errors +from letsencrypt.client import interfaces + + +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] != 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/recovery_token.py b/letsencrypt/client/recovery_token.py index 2da8a9c3f..bf3bad6e1 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -35,7 +35,7 @@ class RecoveryToken(object): return self.generate_response(token_fd.read()) cancel, token = zope.component.getUtility( - interfaces.IDisplay).generic_input( + interfaces.IDisplay).input( "%s - Input Recovery Token: " % chall.domain) if cancel != 1: return self.generate_response(token) diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index f8b75b39c..236533327 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -1,4 +1,5 @@ """Revoker module to enable LE revocations.""" +import collections import csv import logging import os @@ -9,7 +10,6 @@ import zope.component from letsencrypt.client import acme from letsencrypt.client import CONFIG -from letsencrypt.client import crypto_util from letsencrypt.client import display from letsencrypt.client import interfaces from letsencrypt.client import network @@ -20,6 +20,7 @@ class Revoker(object): 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. @@ -37,7 +38,7 @@ class Revoker(object): revocation = self.network.send_and_receive_expected( acme.revocation_request(cert_der, key), "revocation") - zope.component.getUtility(interfaces.IDisplay).generic_notification( + self.displayer.notification( "You have successfully revoked the certificate for " "%s" % cert["cn"]) @@ -56,58 +57,72 @@ class Revoker(object): "You don't have any certificates saved from letsencrypt") return - c_sha1_vh = {} - for (cert, _, path) in self.installer.get_all_certs_keys(): - try: - c_sha1_vh[M2Crypto.X509.load_cert( - cert).get_fingerprint(md='sha1')] = path - except M2Crypto.X509.X509Error: - continue + csha1_vhlist = self._get_installed_locations() with open(list_file, 'rb') as csvfile: csvreader = csv.reader(csvfile) + # idx, orig_cert, orig_key for row in csvreader: - cert = crypto_util.get_cert_info(row[1]) - + # Generate backup key/cert names b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, os.path.basename(row[2]) + "_" + row[0]) b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, os.path.basename(row[1]) + "_" + row[0]) - cert.update({ - "orig_key_file": row[2], - "orig_cert_file": row[1], - "idx": int(row[0]), - "backup_key_file": b_k, - "backup_cert_file": b_c, - "installed": c_sha1_vh.get(cert["fingerprint"], ""), - }) + cert = Cert(b_c) + # Set the meta data + cert.add_meta(int(row[0]), row[1], row[2], b_c, b_k) + # If we were able to find the cert installed... update status + if self.installer is not None: + cert.installed = csha1_vhlist.get( + cert.get_fingerprint, []) + certs.append(cert) if certs: + self._insert_installed_status(certs) self.choose_certs(certs) else: - zope.component.getUtility(interfaces.IDisplay).generic_notification( + self.displayer.notification( "There are not any trusted Let's Encrypt " "certificates for this server.") + def _get_installed_locations(self): + """Get installed locations of certificates""" + csha1_vhlist = {} + + if self.installer is None: + return csha1_vhlist + + for (cert_path, _, path) in self.installer.get_all_certs_keys(): + try: + cert_sha1 = M2Crypto.X509.load_cert( + cert_path).get_fingerprint(md='sha1') + if cert_sha1 in csha1_vhlist: + csha1_vhlist[cert_sha1].append(path) + else: + csha1_vhlist[cert_sha1] = [path] + except (IOError, M2Crypto.X509.X509Error): + continue + + return csha1_vhlist + def choose_certs(self, certs): """Display choose certificates menu. :param list certs: List of cert dicts. """ - displayer = zope.component.getUtility(interfaces.IDisplay) - code, tag = displayer.display_certs(certs) + code, tag = self.display_certs(certs) if code == display.OK: cert = certs[tag] - if displayer.confirm_revocation(cert): + if self.confirm_revocation(cert): self.acme_revocation(cert) else: self.choose_certs(certs) elif code == display.HELP: cert = certs[tag] - displayer.more_info_cert(cert) + self.displayer.more_info_cert(cert) self.choose_certs(certs) else: exit(0) @@ -115,7 +130,7 @@ class Revoker(object): def remove_cert_key(self, cert): # pylint: disable=no-self-use """Remove certificate and key. - :param dict cert: Cert dict used throughout revocation + :param cert: Cert dict used throughout revocation """ list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") @@ -128,12 +143,209 @@ class Revoker(object): csvwriter = csv.writer(newfile) for row in csvreader: - if not (row[0] == str(cert["idx"]) and - row[1] == cert["orig_cert_file"] and - row[2] == cert["orig_key_file"]): + if not (row[0] == str(cert.idx) and + row[1] == cert.orig.path and + 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"]) + + def display_certs(self, certs): + """Display the certificates in a menu for revocation. + + :param list certs: `list` of :class:`letsencrypt.client. + + :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.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 + :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) + + def more_info_cert(self, 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() + self.notification(text, height=self.height) + + +class Cert(object): + """Cert object used for convenience. + + :ivar cert: M2Crypto X509 cert + :type cert: :class:`M2Crypto.X509` + + :ivar int idx: convenience index used for listing + :ivar orig: (`str` original certificate filepath, `str` status) + :type orig: PathStatus + :ivar orig_key: named tuple with(`str` original auth key path, `str` status) + :type orig_key: :class:`PathStatus` + :ivar str backup_path: backup filepath of the certificate + :ivar str backup_key_path: backup filepath of the authorized key + + :ivar list installed: `list` of `str` describing all locations the cert + is installed + + """ + PathStatus = collections.namedtuple("PathStatus", "path status") + """Convenience container to hold path and status info""" + + def __init__(self, cert_filepath): + """Cert initialization + + :param str cert_filepath: Name of file containing certificate in + PEM format. + + """ + try: + self.cert = M2Crypto.X509.load_cert(cert_filepath) + except (IOError, M2Crypto.X509.X509Error): + self.cert = None + + self.idx = -1 + + self.orig = None + self.orig_key = None + self.backup_path = "" + self.backup_key_path = "" + + self.installed = ["Unknown"] + + + def add_meta(self, idx, orig, orig_key, backup, backup_key): + """Add meta data to cert + + :param int idx: convenience index for revoker + :param tuple orig: (`str` original certificate filepath, `str` status) + :param tuple orig_key: (`str` original auth key path, `str` status) + :param str backup: backup certificate filepath + :param str backup_key: backup key filepath + + """ + DELETED_MSG = "This file has been moved or deleted" + CHANGED_MSG = "This file has changed" + status = "" + key_status = "" + + # Verify original cert path + if not os.path.isfile(orig): + status = DELETED_MSG + else: + o_cert = M2Crypto.X509.load_cert(orig) + if self.get_fingerprint() != o_cert.get_fingerprint(md='sha1'): + status = CHANGED_MSG + + # Verify original key path + if not os.path.isfile(orig_key): + key_status = DELETED_MSG + else: + with open(orig_key, 'r') as fd: + key_pem = fd.read() + with open(backup_key, 'r') as fd: + backup_key_pem = fd.read() + if key_pem != backup_key_pem: + key_status = CHANGED_MSG + + self.idx = idx + self.orig = Cert.PathStatus(orig, status) + self.orig_key = Cert.PathStatus(orig_key, key_status) + self.backup_path = backup + self.backup_key_path = backup_key + + def get_installed_msg(self): + return ", ".join(self.installed) + + def get_subject(self): + return self.cert.get_subject().as_text() + + def get_cn(self): + return self.cert.get_subject().CN + + def get_issuer(self): + return self.cert.get_issuer().as_text() + + def get_fingerprint(self): + return self.cert.get_fingerprint(md='sha1') + + def get_not_before(self): + return self.cert.get_not_before().get_datetime() + + def get_not_after(self): + return self.cert.get_not_after().get_datetime() + + def get_serial(self): + self.cert.get_serial_number() + + def get_pub_key(self): + # .. todo:: M2Crypto doesn't support ECC, this will have to be updated + return "RSA " + str(self.cert.get_pubkey().size() * 8) + + def get_san(self): + try: + return self.cert.get_ext("subjectAltName").get_value() + except LookupError: + return "" + + def __str__(self): + """Turn a Certinto a string.""" + text = [] + text.append("Subject: %s" % self.get_subject()) + text.append("SAN: %s" % self.get_san()) + text.append("Issuer: %s" % self.get_issuer()) + text.append("Public Key: %s" % self.get_pub_key()) + text.append("Not Before: %s" % str(self.get_not_before())) + text.append("Not After: %s" % str(self.get_not_after())) + text.append("Serial Number: %s" % self.get_serial()) + text.append("SHA1: %s" % self.get_fingerprint()) + text.append("Installed: %s" % self.get_installed_msg()) + return os.linesep.join(text) + + def pretty_print(self): + """Nicely frames a cert str""" + text = "-" * (display.WIDTH - 4) + os.linesep + text += str(self) + text += "-" * (display.WIDTH - 4) + return text + diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index bad6eee26..5961b227e 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -31,7 +31,7 @@ class RollbackTest(unittest.TestCase): from letsencrypt.client.errors import LetsEncryptMisconfigurationError mock_det.side_effect = [LetsEncryptMisconfigurationError, self.m_install] - self.m_input().generic_yesno.return_value = True + self.m_input().yesno.return_value = True self._call(1) @@ -49,7 +49,7 @@ class RollbackTest(unittest.TestCase): from letsencrypt.client.errors import LetsEncryptMisconfigurationError mock_det.side_effect = LetsEncryptMisconfigurationError - self.m_input().generic_yesno.return_value = True + self.m_input().yesno.return_value = True self._call(1) @@ -68,7 +68,7 @@ class RollbackTest(unittest.TestCase): from letsencrypt.client.errors import LetsEncryptMisconfigurationError mock_det.side_effect = LetsEncryptMisconfigurationError - self.m_input().generic_yesno.return_value = False + self.m_input().yesno.return_value = False self._call(1) diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index d3d82e8ad..44905789d 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -52,7 +52,7 @@ class RecoveryTokenTest(unittest.TestCase): def test_perform_not_stored(self, mock_input): from letsencrypt.client.challenge_util import RecTokenChall - mock_input().generic_input.side_effect = [(0, "555"), (1, "000")] + mock_input().input.side_effect = [(0, "555"), (1, "000")] response = self.rec_token.perform(RecTokenChall("example5.com")) self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 95c5cc8b6..ae623b640 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -135,7 +135,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches def display_eula(): """Displays the end user agreement.""" with open('EULA') as eula_file: - if not zope.component.getUtility(interfaces.IDisplay).generic_yesno( + if not zope.component.getUtility(interfaces.IDisplay).yesno( eula_file.read(), "Agree", "Cancel"): sys.exit(0) From 4540b85ade0504f20b56e47facd1c11d8424c322 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 2 Feb 2015 17:28:34 -0800 Subject: [PATCH 02/46] formatting... --- letsencrypt/client/challenge_util.py | 4 ++-- letsencrypt/client/crypto_util.py | 10 +++++----- letsencrypt/client/display.py | 20 +++++++++---------- letsencrypt/client/recovery_token.py | 2 +- letsencrypt/client/revoker.py | 18 ++++++++--------- letsencrypt/client/tests/client_test.py | 2 +- .../client/tests/recovery_token_test.py | 2 +- letsencrypt/scripts/main.py | 4 ++-- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index b5198217d..1338089ab 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -53,7 +53,7 @@ def dvsni_gen_cert(filepath, name, r_b64, nonce, key): cert_pem = crypto_util.make_ss_cert( key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) - with open(filepath, 'w') as chall_cert_file: + with open(filepath, "w") as chall_cert_file: chall_cert_file.write(cert_pem) return le_util.jose_b64encode(dvsni_s) @@ -69,7 +69,7 @@ def _dvsni_gen_ext(dvsni_r, dvsni_s): :rtype: str """ - z_base = hashlib.new('sha256') + z_base = hashlib.new("sha256") z_base.update(dvsni_r) z_base.update(dvsni_s) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 13d52c4fb..a2e4d27be 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -102,7 +102,7 @@ def make_csr(key_str, domains): extstack.push(ext) csr.add_extensions(extstack) - csr.sign(pubkey, 'sha256') + csr.sign(pubkey, "sha256") assert csr.verify(pubkey) pubkey2 = csr.get_pubkey() assert csr.verify(pubkey2) @@ -156,7 +156,7 @@ def make_key(bits): :rtype: str """ - return Crypto.PublicKey.RSA.generate(bits).exportKey(format='PEM') + return Crypto.PublicKey.RSA.generate(bits).exportKey(format="PEM") def valid_privkey(privkey): @@ -210,11 +210,11 @@ def make_ss_cert(key_str, domains, not_before=None, if len(domains) > 1: m2_cert.add_ext(M2Crypto.X509.new_extension( - 'basicConstraints', 'CA:FALSE')) + "basicConstraints", "CA:FALSE")) m2_cert.add_ext(M2Crypto.X509.new_extension( - 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) + "subjectAltName", ", ".join(["DNS:%s" % d for d in domains]))) - m2_cert.sign(pubkey, 'sha256') + m2_cert.sign(pubkey, "sha256") assert m2_cert.verify(pubkey) assert m2_cert.verify() # print check_purpose(,0 diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index 6d76cdfeb..7f2f67a21 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -85,8 +85,8 @@ class NcursesDisplay(object): """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 + :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 @@ -139,7 +139,7 @@ class FileDisplay(object): :param str message: Message to display """ - side_frame = '-' * 79 + side_frame = "-" * 79 lines = message.splitlines() fixed_l = [] for line in lines: @@ -169,12 +169,12 @@ class FileDisplay(object): choices = ["%s - %s" % (c[0], c[1]) for c in choices] self.outfile.write("\n%s\n" % message) - side_frame = '-' * 79 + side_frame = "-" * 79 self.outfile.write("%s\n" % side_frame) for i, choice in enumerate(choices, 1): self.outfile.write(textwrap.fill( - "%d: %s" % (i, choice), 80) + '\n') + "%d: %s" % (i, choice), 80) + "\n") self.outfile.write("%s\n" % side_frame) @@ -197,7 +197,7 @@ class FileDisplay(object): """ ans = raw_input("%s (Enter c to cancel)\n" % message) - if ans == 'c' or ans == 'C': + if ans == "c" or ans == "C": return CANCEL, "-1" else: return OK, ans @@ -207,13 +207,13 @@ class FileDisplay(object): :param str message: question for the user - :returns: True for 'Yes', False for 'No" + :returns: True for "Yes", False for "No" :rtype: bool """ self.outfile.write("\n%s\n" % textwrap.fill(message, 80)) ans = raw_input("y/n: ") - return ans.startswith('y') or ans.startswith('Y') + return ans.startswith("y") or ans.startswith("Y") def filter_names(self, names): """Determine which names the user would like to select from a list. @@ -252,7 +252,7 @@ class FileDisplay(object): :returns: tuple of the form (code, selection) where code is a display exit code - selection is the user's int selection + selection is the user"s int selection :rtype: tuple """ @@ -261,7 +261,7 @@ class FileDisplay(object): while not valid_ans: ans = raw_input(input_string) - if ans.startswith('c') or ans.startswith('C'): + if ans.startswith("c") or ans.startswith("C"): code = CANCEL selection = -1 valid_ans = True diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index bf3bad6e1..2c328a46d 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -75,5 +75,5 @@ class RecoveryToken(object): """ le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid()) - with open(os.path.join(self.token_dir, domain), 'w') as token_fd: + with open(os.path.join(self.token_dir, domain), "w") as token_fd: token_fd.write(str(token)) diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 236533327..d16a4ee46 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -32,7 +32,7 @@ class Revoker(object): """ 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_file"], "rU") as backup_key_file: key = backup_key_file.read() revocation = self.network.send_and_receive_expected( @@ -59,7 +59,7 @@ class Revoker(object): csha1_vhlist = self._get_installed_locations() - with open(list_file, 'rb') as csvfile: + with open(list_file, "rb") as csvfile: csvreader = csv.reader(csvfile) # idx, orig_cert, orig_key for row in csvreader: @@ -96,7 +96,7 @@ class Revoker(object): for (cert_path, _, path) in self.installer.get_all_certs_keys(): try: cert_sha1 = M2Crypto.X509.load_cert( - cert_path).get_fingerprint(md='sha1') + cert_path).get_fingerprint(md="sha1") if cert_sha1 in csha1_vhlist: csha1_vhlist[cert_sha1].append(path) else: @@ -136,10 +136,10 @@ class Revoker(object): list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") - with open(list_file, 'rb') as orgfile: + with open(list_file, "rb") as orgfile: csvreader = csv.reader(orgfile) - with open(list_file2, 'wb') as newfile: + with open(list_file2, "wb") as newfile: csvwriter = csv.writer(newfile) for row in csvreader: @@ -274,16 +274,16 @@ class Cert(object): status = DELETED_MSG else: o_cert = M2Crypto.X509.load_cert(orig) - if self.get_fingerprint() != o_cert.get_fingerprint(md='sha1'): + if self.get_fingerprint() != o_cert.get_fingerprint(md="sha1"): status = CHANGED_MSG # Verify original key path if not os.path.isfile(orig_key): key_status = DELETED_MSG else: - with open(orig_key, 'r') as fd: + with open(orig_key, "r") as fd: key_pem = fd.read() - with open(backup_key, 'r') as fd: + with open(backup_key, "r") as fd: backup_key_pem = fd.read() if key_pem != backup_key_pem: key_status = CHANGED_MSG @@ -307,7 +307,7 @@ class Cert(object): return self.cert.get_issuer().as_text() def get_fingerprint(self): - return self.cert.get_fingerprint(md='sha1') + return self.cert.get_fingerprint(md="sha1") def get_not_before(self): return self.cert.get_not_before().get_datetime() diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 5961b227e..a7d4d1148 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -84,5 +84,5 @@ class RollbackTest(unittest.TestCase): self._call(1) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py index 44905789d..5c419da39 100644 --- a/letsencrypt/client/tests/recovery_token_test.py +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -60,5 +60,5 @@ class RecoveryTokenTest(unittest.TestCase): self.assertTrue(response is None) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index ae623b640..4f78de968 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -134,7 +134,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches def display_eula(): """Displays the end user agreement.""" - with open('EULA') as eula_file: + with open("EULA") as eula_file: if not zope.component.getUtility(interfaces.IDisplay).yesno( eula_file.read(), "Agree", "Cancel"): sys.exit(0) @@ -190,7 +190,7 @@ def read_file(filename): """ try: - return filename, open(filename, 'rU').read() + return filename, open(filename, "rU").read() except IOError as exc: raise argparse.ArgumentTypeError(exc.strerror) From 5698bc3e20cc2d9fdb62708bc2c1440766bdcb3d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 2 Feb 2015 18:11:48 -0800 Subject: [PATCH 03/46] refactor client.namedtuples to le_util --- letsencrypt/client/apache/dvsni.py | 2 +- letsencrypt/client/auth_handler.py | 4 +-- letsencrypt/client/challenge_util.py | 2 +- letsencrypt/client/client.py | 17 +++++------- letsencrypt/client/le_util.py | 26 ++++++++++++------- .../client/tests/apache/configurator_test.py | 4 +-- letsencrypt/client/tests/apache/dvsni_test.py | 4 +-- .../client/tests/challenge_util_test.py | 3 +-- letsencrypt/scripts/main.py | 5 ++-- 9 files changed, 34 insertions(+), 33 deletions(-) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index cf5c3bdb0..668cdc76c 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -17,7 +17,7 @@ class ApacheDvsni(object): :ivar dvsni_chall: Data required for challenges. where DvsniChall tuples have the following fields `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `key` (:class:`letsencrypt.client.client.Client.Key`) + `key` (:class:`letsencrypt.client.le_util.Key`) :type dvsni_chall: `list` of :class:`letsencrypt.client.challenge_util.DvsniChall` diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index b85996818..c2d94ecec 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -23,7 +23,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar list domains: list of str domains to get authorization :ivar dict authkey: Authorized Keys for each domain. - values are of type :class:`letsencrypt.client.client.Client.Key` + values are of type :class:`letsencrypt.client.le_util.Key` :ivar dict responses: keys: domain, values: list of dict responses :ivar dict msgs: ACME Challenge messages with domain as a key :ivar dict paths: optimal path for authorization. eg. paths[domain] @@ -54,7 +54,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :param dict msg: ACME challenge message :param authkey: authorized key for the challenge - :type authkey: :class:`letsencrypt.client.client.Client.Key` + :type authkey: :class:`letsencrypt.client.le_util.Key` """ if domain in self.domains: diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 1338089ab..b30eb651f 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -37,7 +37,7 @@ def dvsni_gen_cert(filepath, name, r_b64, nonce, key): :param str nonce: hex value of nonce :param key: Key to perform challenge - :type key: :class:`letsencrypt.client.client.Client.Key` + :type key: :class:`letsencrypt.client.le_util.Key` :returns: dvsni s value jose base64 encoded :rtype: str diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index ccd957885..37dcc2ead 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,5 +1,4 @@ """ACME protocol client class and helper functions.""" -import collections import csv import logging import os @@ -38,7 +37,7 @@ class Client(object): :type network: :class:`letsencrypt.client.network.Network` :ivar authkey: Authorization Key - :type authkey: :class:`letsencrypt.client.client.Client.Key` + :type authkey: :class:`letsencrypt.client.le_util.Key` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a client_authenticator @@ -50,10 +49,6 @@ class Client(object): """ zope.interface.implements(interfaces.IAuthenticator) - Key = collections.namedtuple("Key", "file pem") - # Note: form is the type of data, "pem" or "der" - CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, server, authkey, dv_auth, installer): """Initialize a client. @@ -185,7 +180,7 @@ class Client(object): :param list domains: list of domains to install the certificate :param privkey: private key for certificate - :type privkey: :class:`Key` + :type privkey: :class:`letsencrypt.client.le_util.Key` :param str cert_file: certificate file path :param str chain_file: chain file path @@ -312,7 +307,7 @@ def validate_key_csr(privkey, csr=None): If csr is left as None, only the key will be validated. :param privkey: Key associated with CSR - :type privkey: :class:`letsencrypt.client.client.Client.Key` + :type privkey: :class:`letsencrypt.client.le_util.Key` :param csr: CSR :type csr: :class:`letsencrypt.client.client.Client.CSR` @@ -374,7 +369,7 @@ def init_key(key_size): logging.info("Generating key (%d bits): %s", key_size, key_filename) - return Client.Key(key_filename, key_pem) + return le_util.Key(key_filename, key_pem) def init_csr(privkey, names): @@ -390,14 +385,14 @@ def init_csr(privkey, names): logging.info("Creating CSR: %s", csr_filename) - return Client.CSR(csr_filename, csr_der, "der") + return le_util.CSR(csr_filename, csr_der, "der") def csr_pem_to_der(csr): """Convert pem CSR to der.""" csr_obj = M2Crypto.X509.load_request_string(csr.data) - return Client.CSR(csr.file, csr_obj.as_der(), "der") + return le_util.CSR(csr.file, csr_obj.as_der(), "der") def sanity_check_names(names): diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 59b581a45..9087ff7a3 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -1,5 +1,6 @@ -"""Utilities for all Let's Encrypt.""" +"""Utilities for all Let"s Encrypt.""" import base64 +import collections import errno import os import stat @@ -7,6 +8,11 @@ import stat from letsencrypt.client import errors +Key = collections.namedtuple("Key", "file pem") +# Note: form is the type of data, "pem" or "der" +CSR = collections.namedtuple("CSR", "file data form") + + def make_or_verify_dir(directory, mode=0o755, uid=0): """Make sure directory exists with proper permissions. @@ -28,8 +34,8 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): if exception.errno == errno.EEXIST: if not check_permissions(directory, mode, uid): raise errors.LetsEncryptClientError( - '%s exists, but does not have the proper ' - 'permissions or owner' % directory) + "%s exists, but does not have the proper " + "permissions or owner" % directory) else: raise @@ -64,7 +70,7 @@ def unique_file(path, mode=0o777): fname = os.path.join(path, "%04d_%s" % (count, tail)) try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) - return os.fdopen(file_d, 'w'), fname + return os.fdopen(file_d, "w"), fname except OSError: pass count += 1 @@ -92,8 +98,8 @@ def jose_b64encode(data): """ if not isinstance(data, str): - raise TypeError('argument should be str or bytearray') - return base64.urlsafe_b64encode(data).rstrip('=') + raise TypeError("argument should be str or bytearray") + return base64.urlsafe_b64encode(data).rstrip("=") def jose_b64decode(data): @@ -111,11 +117,11 @@ def jose_b64decode(data): """ if isinstance(data, unicode): try: - data = data.encode('ascii') + data = data.encode("ascii") except UnicodeEncodeError: raise ValueError( - 'unicode argument should contain only ASCII characters') + "unicode argument should contain only ASCII characters") elif not isinstance(data, str): - raise TypeError('argument should be a str or unicode') + raise TypeError("argument should be a str or unicode") - return base64.urlsafe_b64decode(data + '=' * (4 - (len(data) % 4))) + return base64.urlsafe_b64decode(data + "=" * (4 - (len(data) % 4))) diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index fc71dfbee..9ed56f89d 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -7,8 +7,8 @@ import unittest import mock from letsencrypt.client import challenge_util -from letsencrypt.client import client from letsencrypt.client import errors +from letsencrypt.client import le_util from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj @@ -164,7 +164,7 @@ class TwoVhost80Test(util.ApacheTest): def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded - auth_key = client.Client.Key(self.rsa256_file, self.rsa256_pem) + auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) chall1 = challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index 68ffa283b..8f6593113 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -6,8 +6,8 @@ import shutil import mock from letsencrypt.client import challenge_util -from letsencrypt.client import client from letsencrypt.client import CONFIG +from letsencrypt.client import le_util from letsencrypt.client.tests.apache import util @@ -33,7 +33,7 @@ class DvsniPerformTest(util.ApacheTest): rsa256_pem = pkg_resources.resource_string( "letsencrypt.client.tests", 'testdata/rsa256_key.pem') - auth_key = client.Client.Key(rsa256_file, rsa256_pem) + auth_key = le_util.Key(rsa256_file, rsa256_pem) self.challs = [] self.challs.append(challenge_util.DvsniChall( "encryption-example.demo", diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 759ee34ce..7ce4d5aef 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -8,7 +8,6 @@ import M2Crypto import mock from letsencrypt.client import challenge_util -from letsencrypt.client import client from letsencrypt.client import CONFIG from letsencrypt.client import le_util @@ -32,7 +31,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.Key("path", pem) nonce = "12345ABCDE" s_b64 = self._call("tmp.crt", domain, r_b64, nonce, key) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 4f78de968..c133c98f3 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -11,8 +11,9 @@ import zope.interface from letsencrypt.client import CONFIG from letsencrypt.client import client from letsencrypt.client import display -from letsencrypt.client import interfaces from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util from letsencrypt.client import log @@ -113,7 +114,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches if args.privkey is None: privkey = client.init_key(args.key_size) else: - privkey = client.Client.Key(args.privkey[0], args.privkey[1]) + privkey = le_util.Key(args.privkey[0], args.privkey[1]) acme = client.Client(args.server, privkey, auth, installer) From 168a70c273296307bc68c40e5a345fef0b87afd1 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 8 Feb 2015 00:46:16 -0800 Subject: [PATCH 04/46] 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, From 01899f387e06d4f1a57918d55deadc55db216fa1 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 8 Feb 2015 21:02:23 -0800 Subject: [PATCH 05/46] Unittests for display_util --- letsencrypt/client/display.py | 311 ------------------ letsencrypt/client/display/display_util.py | 54 +-- letsencrypt/client/enhance_display.py | 67 ---- letsencrypt/client/interfaces.py | 21 +- .../client/tests/display/display_util_test.py | 254 +++++++++++++- 5 files changed, 292 insertions(+), 415 deletions(-) delete mode 100644 letsencrypt/client/display.py delete mode 100644 letsencrypt/client/enhance_display.py diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py deleted file mode 100644 index 7f2f67a21..000000000 --- a/letsencrypt/client/display.py +++ /dev/null @@ -1,311 +0,0 @@ -"""Lets Encrypt display.""" -import os -import textwrap - -import dialog -import zope.interface - -from letsencrypt.client import interfaces - - -WIDTH = 72 -HEIGHT = 20 - - -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): - """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 - - """ - self.dialog.msgbox(message, height, width=self.width) - - def menu(self, message, choices, unused_input_text="", - ok_label="OK", 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) - - :returns: tuple of the form (code, tag) where - code is a display exit code - tag is the tag string 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, - help_button=help_button, help_label=help_label, - width=self.width, height=self.height) - - return code, str(selection) - else: - choices = list(enumerate(choices, 1)) - code, tag = self.dialog.menu( - message, choices=choices, ok_label=ok_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 is a display exit code - string is the 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 filter_names(self, 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 is a display exit code - names is a list of names selected - :rtype: tuple - - """ - choices = [(n, "", 0) for n in names] - code, names = self.dialog.checklist( - "Which names would you like to activate HTTPS for?", - choices=choices) - return code, [str(s) for s in names] - - def success_installation(self, domains): - """Display a box confirming the installation of HTTPS. - - :param list domains: domain names which were enabled - - """ - self.dialog.msgbox( - "\nCongratulations! You have successfully enabled " - + gen_https_names(domains) + "!", width=self.width) - - -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, unused_height): - """Displays a notification and waits for user acceptance. - - :param str message: Message to display - - """ - side_frame = "-" * 79 - lines = message.splitlines() - fixed_l = [] - for line in lines: - fixed_l.append(textwrap.fill(line, 80)) - self.outfile.write( - "{0}{1}{0}{2}{0}{1}{0}".format( - os.linesep, side_frame, os.linesep.join(fixed_l))) - raw_input("Press Enter to Continue") - - def menu(self, message, choices, input_text="", - unused_ok_label = "", unused_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) - - :returns: tuple of the form (code, tag) where - code is a display exit code - tag is the tag string corresponding to the item chosen - :rtype: tuple - - """ - # 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("\n%s\n" % message) - side_frame = "-" * 79 - self.outfile.write("%s\n" % side_frame) - - for i, choice in enumerate(choices, 1): - self.outfile.write(textwrap.fill( - "%d: %s" % (i, choice), 80) + "\n") - - self.outfile.write("%s\n" % side_frame) - - code, selection = self._get_valid_int_ans( - "%s (c to cancel): " % input_text) - - return code, (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 is a display exit code - input is a str of the user's input - :rtype: tuple - - """ - ans = raw_input("%s (Enter c to cancel)\n" % message) - - if ans == "c" or ans == "C": - return CANCEL, "-1" - else: - return OK, ans - - def yesno(self, message, unused_yes_label="", unused_no_label=""): - """Query the user with a yes/no question. - - :param str message: question for the user - - :returns: True for "Yes", False for "No" - :rtype: bool - - """ - self.outfile.write("\n%s\n" % textwrap.fill(message, 80)) - ans = raw_input("y/n: ") - return ans.startswith("y") or ans.startswith("Y") - - def filter_names(self, 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 is a display exit code - names is a list of names selected - :rtype: tuple - - """ - code, tag = self.menu( - "Choose the names would you like to upgrade to HTTPS?", - names, "Select the number of the name: ") - - # Make sure to return a list... - return code, [names[tag]] - - def success_installation(self, domains): - """Display a box confirming the installation of HTTPS. - - :param list domains: domain names which were enabled - - """ - side_frame = '*' * 79 - msg = textwrap.fill("Congratulations! You have successfully " - "enabled %s!" % gen_https_names(domains)) - self.outfile.write("%s\n%s\n%s\n" % (side_frame, msg, side_frame)) - - - def _get_valid_int_ans(self, input_string): - """Get a numerical selection. - - :param str input_string: Instructions for the user to make a selection. - - :returns: tuple of the form (code, selection) where - code is a display exit code - selection is the user"s int selection - :rtype: tuple - - """ - valid_ans = False - e_msg = "Make a selection by inputting the appropriate number.\n" - while not valid_ans: - - ans = raw_input(input_string) - if ans.startswith("c") or ans.startswith("C"): - code = CANCEL - selection = -1 - valid_ans = True - else: - try: - selection = int(ans) - # TODO add check to make sure it is less than max - if selection < 0: - self.outfile.write(e_msg) - continue - code = OK - valid_ans = True - except ValueError: - self.outfile.write(e_msg) - - return code, selection - - -# 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.""" - - -def gen_https_names(domains): - """Returns a string of the https domains. - - Domains are formatted nicely with https:// prepended to each. - .. todo:: This should not use +=, rewrite this with unittests - - """ - result = "" - if len(domains) > 2: - for i in range(len(domains)-1): - result = result + "https://" + domains[i] + ", " - result = result + "and " - if len(domains) == 2: - return "https://" + domains[0] + " and https://" + domains[1] - if domains: - result = result + "https://" + domains[len(domains)-1] - - return result diff --git a/letsencrypt/client/display/display_util.py b/letsencrypt/client/display/display_util.py index 194350ca3..306729e40 100644 --- a/letsencrypt/client/display/display_util.py +++ b/letsencrypt/client/display/display_util.py @@ -5,6 +5,7 @@ import textwrap import dialog import zope.interface +from letsencrypt.client import errors from letsencrypt.client import interfaces @@ -49,8 +50,8 @@ class NcursesDisplay(object): :param str message: title of menu - :param choices: menu lines - :type choices: list of tuples (tag, item) or + :param choices: menu lines, len must be > 0 + :type choices: list of tuples (`tag`, `item`) tags must be unique or list of items (tags will be enumerated) :param str ok_label: label of the OK button @@ -58,7 +59,7 @@ class NcursesDisplay(object): :returns: tuple of the form (`code`, `tag`) where `code` - `str` display_util exit code - `tag` - `str` or `int` index corresponding to the item chosen + `tag` - `int` index corresponding to the item chosen :rtype: tuple """ @@ -75,7 +76,13 @@ class NcursesDisplay(object): help_button=help_button, help_label=help_label, width=self.width, height=self.height) - return code, str(selection) + # Return the selection index + for i, choice in enumerate(choices): + if choice[0] == selection: + return code, i + + return code, -1 + else: choices = [ (str(i), choice) for i, choice in enumerate(choices, 1) @@ -103,6 +110,8 @@ class NcursesDisplay(object): def yesno(self, message, yes_label="Yes", no_label="No"): """Display a Yes/No dialog box + Yes and No label must begin with different letters. + :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 @@ -120,6 +129,7 @@ class NcursesDisplay(object): :param message: Message to display before choices :param list tags: where each is of type :class:`str` + len(tags) > 0 :returns: tuple of the form (code, list_tags) where `code` - int display exit code @@ -161,7 +171,7 @@ class FileDisplay(object): """Display a menu. :param str message: title of menu - :param choices: Menu lines + :param choices: Menu lines, len must be > 0 :type choices: list of tuples (tag, item) or list of descriptions (tags will be enumerated) @@ -175,7 +185,7 @@ class FileDisplay(object): code, selection = self._get_valid_int_ans(len(choices)) - return code, str(selection - 1) + return code, selection - 1 def input(self, message): # pylint: disable=no-self-use @@ -190,7 +200,7 @@ class FileDisplay(object): """ ans = raw_input( - textwrap.fill("%s (Enter c to cancel): " % message, 80)) + textwrap.fill("%s (Enter 'c' to cancel): " % message, 80)) if ans == "c" or ans == "C": return CANCEL, "-1" @@ -200,6 +210,8 @@ class FileDisplay(object): def yesno(self, message, yes_label="Yes", no_label="No"): """Query the user with a yes/no question. + Yes and No label must begin with different letters + :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 @@ -215,10 +227,9 @@ class FileDisplay(object): 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)) + ans = raw_input("{yes}/{no}: ".format( + yes=_parens_around_char(yes_label), + no=_parens_around_char(no_label))) return (ans.startswith(yes_label[0].lower()) or ans.startswith(yes_label[0].upper())) @@ -227,7 +238,7 @@ class FileDisplay(object): """Display a checklist. :param str message: Message to display to user - :param list tags: `str` tags to select + :param list tags: `str` tags to select, len(tags) > 0 :returns: tuple of (`code`, `tags`) where `code` - str display exit code @@ -255,7 +266,7 @@ class FileDisplay(object): def _scrub_checklist_input(self, indices, tags): """Validate input and transform indices to appropriate tags. - :param list indices: Checklist input + :param list indices: input :param list tags: Original tags of the checklist :returns: tags the user selected @@ -265,7 +276,7 @@ class FileDisplay(object): # They should all be of type int try: indices = [int(index) for index in indices] - except TypeError: + except ValueError: return [] # Remove duplicates @@ -292,14 +303,16 @@ class FileDisplay(object): if choices and isinstance(choices[0], tuple): choices = ["%s - %s" % (c[0], c[1]) for c in choices] + # Write out the message to the user 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): + # Write out the menu choices + for i, desc in enumerate(choices, 1): self.outfile.write( - textwrap.fill("{num}: {tag}".format(num=i, tag=tag), 80)) + textwrap.fill("{num}: {desc}".format(num=i, desc=desc), 80)) # Keep this outside of the textwrap self.outfile.write(os.linesep) @@ -311,7 +324,7 @@ class FileDisplay(object): :param str msg: Original message - :returns: Formatted message + :returns: Formatted message respecting newlines in message :rtype: str """ @@ -325,7 +338,7 @@ class FileDisplay(object): def _get_valid_int_ans(self, max): """Get a numerical selection. - :param int max: The maximum entry (len of choices) + :param int max: The maximum entry (len of choices), must be positive :returns: tuple of the form (`code`, `selection`) where `code` - str display exit code ('ok' or cancel') @@ -337,10 +350,10 @@ class FileDisplay(object): if max > 1: input_msg = ("Select the appropriate number " "[1-{max}] then [enter] (press 'c' to " - "cancel){end}".format(max=max, end=os.linesep)) + "cancel): ".format(max=max)) else: input_msg = ("Press 1 [enter] to confirm the selection " - "(press 'c' to cancel){0}".format(os.linesep)) + "(press 'c' to cancel): ") while selection < 1: ans = raw_input(input_msg) if ans.startswith("c") or ans.startswith("C"): @@ -348,6 +361,7 @@ class FileDisplay(object): try: selection = int(ans) if selection < 1 or selection > max: + selection = -1 raise ValueError except ValueError: diff --git a/letsencrypt/client/enhance_display.py b/letsencrypt/client/enhance_display.py deleted file mode 100644 index 9de58127b..000000000 --- a/letsencrypt/client/enhance_display.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Let's Encrypt Enhancement Display""" -import logging - -import zope.component - -from letsencrypt.client import errors -from letsencrypt.client import interfaces - - -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] != 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/interfaces.py b/letsencrypt/client/interfaces.py index 757c1d705..3ceeee3e8 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -141,15 +141,22 @@ class IDisplay(zope.interface.Interface): """ - def menu(message, choices, input_text="", ok_label="OK", help_label=""): + def menu(message, choices, + ok_label="OK", cancel_label="Cancel", help_label=""): """Displays a generic menu. :param str message: message to display :param choices: choices - :type choices: :class:`list` of :func:`tuple` + :type choices: :class:`list` of :func:`tuple` or :class:`str` - :param str input_text: instructions on how to make a selection + :param str ok_label: label for OK button + :param str cancel_label: label for Cancel button + :param str help_label: label for Help button + + :returns: tuple of (`code`, `index`) where + `code` - str display exit code + `index` - int index of the user's selection """ @@ -168,6 +175,8 @@ class IDisplay(zope.interface.Interface): def yesno(message, yes_label="Yes", no_label="No"): """Query the user with a yes/no question. + Yes and No label must begin with different letters. + :param str message: question for the user :returns: True for "Yes", False for "No" @@ -175,13 +184,13 @@ class IDisplay(zope.interface.Interface): """ - def checkbox(message, choices): + def checklist(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` + :param tags: tags + :type tags: :class:`list` of :class:`str` """ diff --git a/letsencrypt/client/tests/display/display_util_test.py b/letsencrypt/client/tests/display/display_util_test.py index 3c527fd87..3b6ccf83a 100644 --- a/letsencrypt/client/tests/display/display_util_test.py +++ b/letsencrypt/client/tests/display/display_util_test.py @@ -1,4 +1,5 @@ -import sys +import contextlib +import os import unittest import mock @@ -10,6 +11,7 @@ class DisplayT(unittest.TestCase): def setUp(self): self.choices = [("First", "Description1"), ("Second", "Description2")] self.tags = ["tag1", "tag2", "tag3"] + self.tags_choices = [("1", "tag1"), ("2", "tag2"), ("3", "tag3")] def test_visual(displayer, choices): @@ -21,13 +23,21 @@ def test_visual(displayer, choices): ok_label="O", cancel_label="Can", help_label="??") displayer.input("Input Message") displayer.yesno( - "Yes/No Message", yes_label="Yessir", no_label="Nosir") + "YesNo Message", yes_label="Yessir", no_label="Nosir") displayer.checklist( "Checklist Message", [choice[0] for choice in choices]) class NcursesDisplayTest(DisplayT): - """Test ncurses display.""" + """Test ncurses display. + + Since this is mostly a wrapper, it might be more helpful to test the actual + dialog boxes. The test_visual function will actually display the various + boxes but requires the user to do the verification. If something seems amiss + please use the test_visual function to debug it, the automatic tests rely + on too much mocking. + + """ def setUp(self): super(NcursesDisplayTest, self).setUp() self.displayer = display_util.NcursesDisplay() @@ -39,21 +49,233 @@ class NcursesDisplayTest(DisplayT): 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_menu_tag_and_desc(self, mock_menu): + mock_menu.return_value = (display_util.OK, "First") - def test_visual(self): - test_visual(self.displayer, self.choices) + ret = self.displayer.menu("Message", self.choices) + mock_menu.assert_called_with( + "Message", choices=self.choices, ok_label="OK", + cancel_label="Cancel", + help_button=False, help_label="", + width=display_util.WIDTH, height=display_util.HEIGHT) + + self.assertEqual(ret, (display_util.OK, 0)) + + @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.menu") + def test_menu_tag_and_desc_cancel(self, mock_menu): + mock_menu.return_value = (display_util.CANCEL, "") + + ret = self.displayer.menu("Message", self.choices) + + + mock_menu.assert_called_with( + "Message", choices=self.choices, ok_label="OK", + cancel_label="Cancel", + help_button=False, help_label="", + width=display_util.WIDTH, height=display_util.HEIGHT) + + self.assertEqual(ret, (display_util.CANCEL, -1)) + + @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.menu") + def test_menu_desc_only(self, mock_menu): + mock_menu.return_value = (display_util.OK, "1") + + ret = self.displayer.menu("Message", self.tags, help_label="More Info") + + + mock_menu.assert_called_with( + "Message", choices=self.tags_choices, ok_label="OK", + cancel_label="Cancel", + help_button=True, help_label="More Info", + width=display_util.WIDTH, height=display_util.HEIGHT) + + self.assertEqual(ret, (display_util.OK, 0)) + + @mock.patch("letsencrypt.client.display.display_util." + "dialog.Dialog.inputbox") + def test_input(self, mock_input): + self.displayer.input("message") + mock_input.assert_called_with("message") + + @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.yesno") + def test_yesno(self, mock_yesno): + mock_yesno.return_value = display_util.OK + + self.assertTrue(self.displayer.yesno("message")) + + mock_yesno.assert_called_with( + "message", display_util.HEIGHT, display_util.WIDTH, + yes_label="Yes", no_label="No") + + @mock.patch("letsencrypt.client.display.display_util." + "dialog.Dialog.checklist") + def test_checklist(self, mock_checklist): + self.displayer.checklist("message", self.tags) + + choices = [ + (self.tags[0], "", False), + (self.tags[1], "", False), + (self.tags[2], "", False) + ] + mock_checklist.assert_called_with( + "message", width=display_util.WIDTH, height=display_util.HEIGHT, + choices=choices) + + # def test_visual(self): + # test_visual(self.displayer, self.choices) class FileOutputDisplayTest(DisplayT): - """Test stdout display.""" + """Test stdout display. + + Most of this class has to deal with visual output. In order to test how the + functions look to a user, uncomment the test_visual function. + + """ def setUp(self): super(FileOutputDisplayTest, self).setUp() - self.displayer = display_util.FileDisplay(sys.stdout) + self.mock_stdout = mock.MagicMock() + self.displayer = display_util.FileDisplay(self.mock_stdout) - def test_visual(self): - test_visual(self.displayer, self.choices) + def test_notification_no_pause(self): + self.displayer.notification("message", 10, False) + string = self.mock_stdout.write.call_args[0][0] + + self.assertTrue("message" in string) + + def test_notification_pause(self): + # Attempt to mock raw_input + with mock_raw_input(["enter"]): + self.displayer.notification("message") + + self.assertTrue("message" in self.mock_stdout.write.call_args[0][0]) + + @mock.patch("letsencrypt.client.display.display_util." + "FileDisplay._get_valid_int_ans") + def test_menu(self, mock_ans): + mock_ans.return_value = (display_util.OK, 1) + ret = self.displayer.menu("message", self.choices) + self.assertEqual(ret, (display_util.OK, 0)) + + def test_input_cancel(self): + # Attempt to mock raw_input + with mock_raw_input(["c"]): + code, _ = self.displayer.input("message") + + self.assertTrue(code, display_util.CANCEL) + + def test_input_normal(self): + with mock_raw_input(["domain.com"]): + code, input_ = self.displayer.input("message") + + self.assertEqual(code, display_util.OK) + self.assertEqual(input_, "domain.com") + + def test_yesno(self): + with mock_raw_input(["Yes"]): + self.assertTrue(self.displayer.yesno("message")) + with mock_raw_input(["y"]): + self.assertTrue(self.displayer.yesno("message")) + with mock_raw_input(["cancel"]): + self.assertFalse(self.displayer.yesno("message")) + + with mock_raw_input(["a"]): + self.assertTrue(self.displayer.yesno("msg", yes_label="Agree")) + + @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + def test_checklist_valid(self, mock_input): + mock_input.return_value = (display_util.OK, "2 1") + code, tag_list = self.displayer.checklist("msg", self.tags) + self.assertEqual( + (code, set(tag_list)), (display_util.OK, set(["tag1", "tag2"]))) + + @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + def test_checklist_miss_valid(self, mock_input): + mock_input.side_effect = [ + (display_util.OK, "10"), + (display_util.OK, "tag1 please"), + (display_util.OK, "1") + ] + + ret = self.displayer.checklist("msg", self.tags) + self.assertEqual(ret, (display_util.OK, ["tag1"])) + + @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + def test_checklist_miss_quit(self, mock_input): + mock_input.side_effect = [ + (display_util.OK, "10"), + (display_util.CANCEL, "1") + ] + ret = self.displayer.checklist("msg", self.tags) + self.assertEqual(ret, (display_util.CANCEL, [])) + + def test_scrub_checklist_input_valid(self): + indices = [ + ["1"], + ["1", "2", "1"], + ["2", "3"], + ] + exp = [ + set(["tag1"]), + set(["tag1", "tag2"]), + set(["tag2", "tag3"]), + ] + for i, list_ in enumerate(indices): + set_tags = set( + self.displayer._scrub_checklist_input(list_, self.tags)) + self.assertEqual(set_tags, exp[i]) + + def test_scrub_checklist_input_invalid(self): + indices = [ + ["0"], + ["4"], + ["tag1"], + ["1", "tag1"], + ["2", "o"] + ] + for list_ in indices: + self.assertEqual( + self.displayer._scrub_checklist_input(list_, self.tags), []) + + def test_print_menu(self): + # This is purely cosmetic... just make sure there aren't any exceptions + self.displayer._print_menu("msg", self.choices) + self.displayer._print_menu("msg", self.tags) + + def test_wrap_lines(self): + msg = ("This is just a weak test\n" + "This function is only meant to be for easy viewing\n" + "Test a really really really really really really really really " + "really really really really really long line...") + text = self.displayer._wrap_lines(msg) + + self.assertEqual(text.count(os.linesep), 3) + + def test_get_valid_int_ans_valid(self): + with mock_raw_input(["1"]): + self.assertEqual( + self.displayer._get_valid_int_ans(1), (display_util.OK, 1)) + ans = "2" + with mock_raw_input([ans]): + self.assertEqual( + self.displayer._get_valid_int_ans(3), + (display_util.OK, int(ans))) + + def test_get_valid_int_ans_invalid(self): + answers = [ + ["0", "c"], + ["4", "one", "C"], + ["c"], + ] + for ans in answers: + with mock_raw_input(ans): + self.assertEqual( + self.displayer._get_valid_int_ans(3), + (display_util.CANCEL, -1)) + + # def test_visual(self): + # self.displayer = display_util.FileDisplay(sys.stdout) + # test_visual(self.displayer, self.choices) class SeparateListInputTest(unittest.TestCase): @@ -101,5 +323,15 @@ class PlaceParensTest(unittest.TestCase): self.assertEqual("(L)abel", ret) +# https://stackoverflow.com/a/25275926 +@contextlib.contextmanager +def mock_raw_input(values): + func = mock.MagicMock(side_effect=values) + original_raw_input = __builtins__.raw_input + __builtins__.raw_input = func + yield + __builtins__.raw_input = original_raw_input + + if __name__ == "__main__": unittest.main() \ No newline at end of file From 0cf43299363b11d99e85ccec0abb4bc1c2f277ba Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 00:12:43 -0800 Subject: [PATCH 06/46] full display integration --- letsencrypt/client/apache/configurator.py | 2 +- letsencrypt/client/client.py | 10 ++- letsencrypt/client/display/__init__.py | 1 + letsencrypt/client/display/enhancements.py | 74 +++++++---------- letsencrypt/client/display/ops.py | 3 +- letsencrypt/client/reverter.py | 83 +++++++++---------- letsencrypt/client/revoker.py | 3 +- .../client/tests/apache/configurator_test.py | 57 +++++++------ letsencrypt/client/tests/apache/obj_test.py | 4 + .../client/tests/apache/parser_test.py | 2 +- .../client/tests/challenge_util_test.py | 6 +- letsencrypt/client/tests/display/__init__.py | 1 + .../client/tests/display/display_util_test.py | 49 ++++------- letsencrypt/scripts/main.py | 13 +-- 14 files changed, 151 insertions(+), 157 deletions(-) create mode 100644 letsencrypt/client/display/__init__.py create mode 100644 letsencrypt/client/tests/display/__init__.py diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 5333cc3b5..6ca26bd24 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -985,7 +985,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() -def get_version(self): +def get_version(): """Return version of Apache Server. Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 88f7160a1..5b6797005 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -20,6 +20,7 @@ from letsencrypt.client import revoker from letsencrypt.client.apache import configurator from letsencrypt.client.display import ops +from letsencrypt.client.display import enhancements class Client(object): @@ -102,7 +103,9 @@ class Client(object): cert_file, chain_file = self.save_certificate( certificate_dict, cert_path, chain_path) - revoker.Revoker.store_cert_key(cert_file, False) + print cert_file + + revoker.Revoker.store_cert_key(cert_file, self.authkey.file, False) return cert_file, chain_file @@ -217,8 +220,7 @@ class Client(object): raise errors.LetsEncryptClientError("No installer available") if redirect is None: - redirect = zope.component.getUtility( - interfaces.IDisplay).redirect_by_default() + redirect = enhancements.ask("redirect") if redirect: self.redirect_to_ssl(domains) @@ -348,6 +350,8 @@ def determine_authenticator(): logging.info("Unable to determine a way to authenticate the server") if len(auths) > 1: return ops.choose_authenticator(auths) + elif len(auths) == 1: + return auths[0] def determine_installer(): diff --git a/letsencrypt/client/display/__init__.py b/letsencrypt/client/display/__init__.py new file mode 100644 index 000000000..b652c58a9 --- /dev/null +++ b/letsencrypt/client/display/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.display""" diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index 16e8bc520..738be6429 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -8,61 +8,51 @@ from letsencrypt.client import interfaces from letsencrypt.client.display import display_util -class EnhanceDisplay(object): - """Class used to display various enhancements. +def ask(enhancement): + """Display the enhancement to the user. - .. note::This is not a subclass of Display. It merely uses Display as a - component. + :param str enhancement: One of the + :class:`letsencrypt.client.CONFIG.ENHANCEMENTS` enhancements - :ivar displayer: Display singleton - :type displayer: :class:`letsencrypt.client.interfaces.IDisplay + :returns: True if feature is desired, False otherwise + :rtype: bool - :ivar dict dispatch: Dict mapping enhancements to functions + :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If + the enhancement provided is not supported. """ - def __init__(self): - self.displayer = zope.component.getUtility(interfaces.IDisplay) + try: + return _dispatch[enhancement]() + except KeyError: + logging.error("Unsupported enhancement given to ask()") + raise errors.LetsEncryptClientError("Unsupported Enhancement") - self.dispatch = { - "redirect": self.redirect_by_default, - } - def ask(self, enhancement): - """Display the enhancement to the user. +def redirect_by_default(): + """Determines whether the user would like to redirect to HTTPS. - :param str enhancement: One of the - :class:`letsencrypt.client.CONFIG.ENHANCEMENTS` enhancements + :returns: True if redirect is desired, False otherwise + :rtype: bool - :returns: True if feature 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"), + ] - :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If - the enhancement provided is not supported. + result = _util(interfaces.IDisplay).menu( + "Please choose whether HTTPS access is required or optional.", + choices) - """ - try: - return self.dispatch[enhancement] - except KeyError: - logging.error("Unsupported enhancement given to ask()") - raise errors.LetsEncryptClientError("Unsupported Enhancement") + if result[0] != display_util.OK: + return False - def redirect_by_default(self): - """Determines whether the user would like to redirect to HTTPS. + return result[1] == 1 - :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")] +_util = zope.component.getUtility - 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 +_dispatch = { + "redirect": redirect_by_default +} \ No newline at end of file diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index b566bc262..36a1498e2 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -73,10 +73,9 @@ def _filter_names(names): :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) + tags=names) return code, [str(s) for s in names] diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index f0bfd81b9..b0e94c663 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -17,9 +17,9 @@ class Reverter(object): """Reverter Class - save and revert configuration checkpoints""" def __init__(self, direc=None): if not direc: - direc = {'backup': CONFIG.BACKUP_DIR, - 'temp': CONFIG.TEMP_CHECKPOINT_DIR, - 'progress': CONFIG.IN_PROGRESS_DIR} + direc = {"backup": CONFIG.BACKUP_DIR, + "temp": CONFIG.TEMP_CHECKPOINT_DIR, + "progress": CONFIG.IN_PROGRESS_DIR} self.direc = direc def revert_temporary_config(self): @@ -32,13 +32,13 @@ class Reverter(object): Unable to revert config """ - if os.path.isdir(self.direc['temp']): + if os.path.isdir(self.direc["temp"]): try: - self._recover_checkpoint(self.direc['temp']) + self._recover_checkpoint(self.direc["temp"]) except errors.LetsEncryptReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for %s", - self.direc['temp']) + self.direc["temp"]) raise errors.LetsEncryptReverterError( "Unable to revert temporary config") @@ -46,7 +46,7 @@ class Reverter(object): """Revert 'rollback' number of configuration checkpoints. :param int rollback: Number of checkpoints to reverse. A str num will be - cast to an integer. So '2' is also acceptable. + cast to an integer. So "2" is also acceptable. :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: If there is a problem with the input or if the function is unable to @@ -63,7 +63,7 @@ class Reverter(object): logging.error("Rollback argument must be a positive integer") raise errors.LetsEncryptReverterError("Invalid Input") - backups = os.listdir(self.direc['backup']) + backups = os.listdir(self.direc["backup"]) backups.sort() if len(backups) < rollback: @@ -71,7 +71,7 @@ class Reverter(object): rollback, len(backups)) while rollback > 0 and backups: - cp_dir = os.path.join(self.direc['backup'], backups.pop()) + cp_dir = os.path.join(self.direc["backup"], backups.pop()) try: self._recover_checkpoint(cp_dir) except errors.LetsEncryptReverterError: @@ -88,7 +88,7 @@ class Reverter(object): .. todo:: Decide on a policy for error handling, OSError IOError... """ - backups = os.listdir(self.direc['backup']) + backups = os.listdir(self.direc["backup"]) backups.sort(reverse=True) if not backups: @@ -102,12 +102,12 @@ class Reverter(object): float(bkup) except ValueError: raise errors.LetsEncryptReverterError( - "Invalid directories in {0}".format(self.direc['backup'])) + "Invalid directories in {0}".format(self.direc["backup"])) output = [] for bkup in backups: output.append(time.ctime(float(bkup))) - cur_dir = os.path.join(self.direc['backup'], bkup) + cur_dir = os.path.join(self.direc["backup"], bkup) with open(os.path.join(cur_dir, "CHANGES_SINCE")) as changes_fd: output.append(changes_fd.read()) @@ -136,7 +136,7 @@ class Reverter(object): param str save_notes: notes about changes during the save """ - self._add_to_checkpoint_dir(self.direc['temp'], save_files, save_notes) + self._add_to_checkpoint_dir(self.direc["temp"], save_files, save_notes) def add_to_checkpoint(self, save_files, save_notes): """Add files to a permanent checkpoint @@ -148,7 +148,7 @@ class Reverter(object): # Check to make sure we are not overwriting a temp file self._check_tempfile_saves(save_files) self._add_to_checkpoint_dir( - self.direc['progress'], save_files, save_notes) + self.direc["progress"], save_files, save_notes) def _add_to_checkpoint_dir(self, cp_dir, save_files, save_notes): """Add save files to checkpoint directory. @@ -179,7 +179,7 @@ class Reverter(object): try: shutil.copy2(filename, os.path.join( cp_dir, os.path.basename(filename) + "_" + str(idx))) - op_fd.write(filename + '\n') + op_fd.write(filename + os.linesep) # http://stackoverflow.com/questions/4726260/effective-use-of-python-shutil-copy2 except IOError: op_fd.close() @@ -192,7 +192,7 @@ class Reverter(object): idx += 1 op_fd.close() - with open(os.path.join(cp_dir, "CHANGES_SINCE"), 'a') as notes_fd: + with open(os.path.join(cp_dir, "CHANGES_SINCE"), "a") as notes_fd: notes_fd.write(save_notes) def _read_and_append(self, filepath): # pylint: disable=no-self-use @@ -203,11 +203,11 @@ class Reverter(object): """ # Open up filepath differently depending on if it already exists if os.path.isfile(filepath): - op_fd = open(filepath, 'r+') + op_fd = open(filepath, "r+") lines = op_fd.read().splitlines() else: lines = [] - op_fd = open(filepath, 'w') + op_fd = open(filepath, "w") return op_fd, lines @@ -229,7 +229,7 @@ class Reverter(object): for idx, path in enumerate(filepaths): shutil.copy2(os.path.join( cp_dir, - os.path.basename(path) + '_' + str(idx)), path) + os.path.basename(path) + "_" + str(idx)), path) except (IOError, OSError): # This file is required in all checkpoints. logging.error("Unable to recover files from %s", cp_dir) @@ -258,15 +258,15 @@ class Reverter(object): protected_files = [] # Get temp modified files - temp_path = os.path.join(self.direc['temp'], "FILEPATHS") + temp_path = os.path.join(self.direc["temp"], "FILEPATHS") if os.path.isfile(temp_path): - with open(temp_path, 'r') as protected_fd: + with open(temp_path, "r") as protected_fd: protected_files.extend(protected_fd.read().splitlines()) # Get temp new files - new_path = os.path.join(self.direc['temp'], "NEW_FILES") + new_path = os.path.join(self.direc["temp"], "NEW_FILES") if os.path.isfile(new_path): - with open(new_path, 'r') as protected_fd: + with open(new_path, "r") as protected_fd: protected_files.extend(protected_fd.read().splitlines()) # Verify no save_file is in protected_files @@ -299,9 +299,9 @@ class Reverter(object): "Forgot to provide files to registration call") if temporary: - cp_dir = self.direc['temp'] + cp_dir = self.direc["temp"] else: - cp_dir = self.direc['progress'] + cp_dir = self.direc["progress"] le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) @@ -325,7 +325,7 @@ class Reverter(object): def recovery_routine(self): """Revert all previously modified files. - First, any changes found in self.direc['temp'] are removed, + First, any changes found in self.direc["temp"] are removed, then IN_PROGRESS changes are removed The order is important. IN_PROGRESS is unable to add files that are already added by a TEMP change. Thus TEMP must be rolled back first because that will be the @@ -333,17 +333,17 @@ class Reverter(object): """ self.revert_temporary_config() - if os.path.isdir(self.direc['progress']): + if os.path.isdir(self.direc["progress"]): try: - self._recover_checkpoint(self.direc['progress']) + self._recover_checkpoint(self.direc["progress"]) except errors.LetsEncryptReverterError: # We have a partial or incomplete recovery logging.fatal("Incomplete or failed recovery for IN_PROGRESS " "checkpoint - %s", - self.direc['progress']) + self.direc["progress"]) raise errors.LetsEncryptReverterError( "Incomplete or failed recovery for IN_PROGRESS checkpoint " - "- %s" % self.direc['progress']) + "- %s" % self.direc["progress"]) def _remove_contained_files(self, file_list): # pylint: disable=no-self-use """Erase all files contained within file_list. @@ -362,7 +362,7 @@ class Reverter(object): if not os.path.isfile(file_list): return False try: - with open(file_list, 'r') as list_fd: + with open(file_list, "r") as list_fd: filepaths = list_fd.read().splitlines() for path in filepaths: # Files are registered before they are added... so @@ -386,8 +386,8 @@ class Reverter(object): def finalize_checkpoint(self, title): """Move IN_PROGRESS checkpoint to timestamped checkpoint. - Adds title to self.direc['progress'] CHANGES_SINCE - Move self.direc['progress'] to Backups directory and + Adds title to self.direc["progress"] CHANGES_SINCE + Move self.direc["progress"] to Backups directory and rename the directory as a timestamp :param str title: Title describing checkpoint @@ -396,19 +396,18 @@ class Reverter(object): """ # Check to make sure an "in progress" directory exists - if not os.path.isdir(self.direc['progress']): - logging.warning("No IN_PROGRESS checkpoint to finalize") + if not os.path.isdir(self.direc["progress"]): return changes_since_path = os.path.join( - self.direc['progress'], 'CHANGES_SINCE') + self.direc["progress"], "CHANGES_SINCE") changes_since_tmp_path = os.path.join( - self.direc['progress'], 'CHANGES_SINCE.tmp') + self.direc["progress"], "CHANGES_SINCE.tmp") try: - with open(changes_since_tmp_path, 'w') as changes_tmp: + with open(changes_since_tmp_path, "w") as changes_tmp: changes_tmp.write("-- %s --\n" % title) - with open(changes_since_path, 'r') as changes_orig: + with open(changes_since_path, "r") as changes_orig: changes_tmp.write(changes_orig.read()) shutil.move(changes_since_tmp_path, changes_since_path) @@ -424,9 +423,9 @@ class Reverter(object): # collisions in the naming convention. cur_time = time.time() for _ in range(10): - final_dir = os.path.join(self.direc['backup'], str(cur_time)) + final_dir = os.path.join(self.direc["backup"], str(cur_time)) try: - os.rename(self.direc['progress'], final_dir) + os.rename(self.direc["progress"], final_dir) return except OSError: # It is possible if the checkpoints are made extremely quickly @@ -437,6 +436,6 @@ class Reverter(object): # After 10 attempts... something is probably wrong here... logging.error( "Unable to finalize checkpoint, %s -> %s", - self.direc['progress'], final_dir) + self.direc["progress"], final_dir) raise errors.LetsEncryptReverterError( "Unable to finalize checkpoint renaming") diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 6978665b3..e49b8f652 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -61,8 +61,7 @@ class Revoker(object): certs = self._populate_saved_certs(csha1_vhlist) if certs: - self._insert_installed_status(certs) - cert = self.choose_certs(certs) + cert = revocation.choose_certs(certs) self.acme_revocation(cert) else: logging.info( diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index 9ed56f89d..e662e5eab 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -132,31 +132,6 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache.configurator." - "subprocess.Popen") - def test_get_version(self, mock_popen): - mock_popen().communicate.return_value = ( - "Server Version: Apache/2.4.2 (Debian)", "") - self.assertEqual(self.config.get_version(), (2, 4, 2)) - - mock_popen().communicate.return_value = ( - "Server Version: Apache/2 (Linux)", "") - self.assertEqual(self.config.get_version(), (2,)) - - mock_popen().communicate.return_value = ( - "Server Version: Apache (Debian)", "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - - mock_popen().communicate.return_value = ( - "Server Version: Apache/2.3\n Apache/2.4.7", "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - - mock_popen.side_effect = OSError("Can't find program") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - @mock.patch("letsencrypt.client.apache.configurator." "dvsni.ApacheDvsni.perform") @mock.patch("letsencrypt.client.apache.configurator." @@ -189,5 +164,37 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_restart.call_count, 1) + +class GetVersionTest(unittest.TestCase): + @classmethod + def _call(cls): + from letsencrypt.client.apache.configurator import get_version + return get_version() + + @mock.patch("letsencrypt.client.apache.configurator." + "subprocess.Popen") + def test_get_version(self, mock_popen): + mock_popen().communicate.return_value = ( + "Server Version: Apache/2.4.2 (Debian)", "") + self.assertEqual(self._call(), (2, 4, 2)) + + mock_popen().communicate.return_value = ( + "Server Version: Apache/2 (Linux)", "") + self.assertEqual(self._call(), (2,)) + + mock_popen().communicate.return_value = ( + "Server Version: Apache (Debian)", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self._call) + + mock_popen().communicate.return_value = ( + "Server Version: Apache/2.3\n Apache/2.4.7", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self._call) + + mock_popen.side_effect = OSError("Can't find program") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self._call) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/apache/obj_test.py b/letsencrypt/client/tests/apache/obj_test.py index f78e83bb4..0dccd3afb 100644 --- a/letsencrypt/client/tests/apache/obj_test.py +++ b/letsencrypt/client/tests/apache/obj_test.py @@ -64,3 +64,7 @@ class VirtualHostTest(unittest.TestCase): self.assertEqual(vhost1b, self.vhost1) self.assertEqual(str(vhost1b), str(self.vhost1)) self.assertNotEqual(vhost1b, 1234) + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index 00302327f..3022940f3 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -8,9 +8,9 @@ import augeas import mock import zope.component -from letsencrypt.client.display import display_util from letsencrypt.client import errors from letsencrypt.client.apache import parser +from letsencrypt.client.display import display_util from letsencrypt.client.tests.apache import util diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index 8fc327ad5..54f1f13d2 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 = le_util.Client.Key("path", pem) + key = le_util.Key("path", pem) nonce = "12345ABCDE" cert_pem, s_b64 = self._call(domain, r_b64, nonce, key) @@ -49,3 +49,7 @@ class DvsniGenCertTest(unittest.TestCase): def _call(cls, name, r_b64, nonce, key): from letsencrypt.client.challenge_util import dvsni_gen_cert return dvsni_gen_cert(name, r_b64, nonce, key) + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/client/tests/display/__init__.py b/letsencrypt/client/tests/display/__init__.py new file mode 100644 index 000000000..79a386ea2 --- /dev/null +++ b/letsencrypt/client/tests/display/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Display Tests""" diff --git a/letsencrypt/client/tests/display/display_util_test.py b/letsencrypt/client/tests/display/display_util_test.py index 3b6ccf83a..42c551bad 100644 --- a/letsencrypt/client/tests/display/display_util_test.py +++ b/letsencrypt/client/tests/display/display_util_test.py @@ -14,18 +14,16 @@ class DisplayT(unittest.TestCase): self.tags_choices = [("1", "tag1"), ("2", "tag2"), ("3", "tag3")] -def test_visual(displayer, choices): +def 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="??") + 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="??") + ok_label="O", cancel_label="Can", help_label="??") displayer.input("Input Message") - displayer.yesno( - "YesNo Message", yes_label="Yessir", no_label="Nosir") - displayer.checklist( - "Checklist Message", [choice[0] for choice in choices]) + displayer.yesno("YesNo Message", yes_label="Yessir", no_label="Nosir") + displayer.checklist("Checklist Message", [choice[0] for choice in choices]) class NcursesDisplayTest(DisplayT): @@ -122,7 +120,7 @@ class NcursesDisplayTest(DisplayT): choices=choices) # def test_visual(self): - # test_visual(self.displayer, self.choices) + # visual(self.displayer, self.choices) class FileOutputDisplayTest(DisplayT): @@ -144,8 +142,7 @@ class FileOutputDisplayTest(DisplayT): self.assertTrue("message" in string) def test_notification_pause(self): - # Attempt to mock raw_input - with mock_raw_input(["enter"]): + with mock.patch("__builtin__.raw_input", return_value="enter"): self.displayer.notification("message") self.assertTrue("message" in self.mock_stdout.write.call_args[0][0]) @@ -158,28 +155,26 @@ class FileOutputDisplayTest(DisplayT): self.assertEqual(ret, (display_util.OK, 0)) def test_input_cancel(self): - # Attempt to mock raw_input - with mock_raw_input(["c"]): + with mock.patch("__builtin__.raw_input", return_value="c"): code, _ = self.displayer.input("message") self.assertTrue(code, display_util.CANCEL) def test_input_normal(self): - with mock_raw_input(["domain.com"]): + with mock.patch("__builtin__.raw_input", return_value="domain.com"): code, input_ = self.displayer.input("message") self.assertEqual(code, display_util.OK) self.assertEqual(input_, "domain.com") def test_yesno(self): - with mock_raw_input(["Yes"]): + with mock.patch("__builtin__.raw_input", return_value="Yes"): self.assertTrue(self.displayer.yesno("message")) - with mock_raw_input(["y"]): + with mock.patch("__builtin__.raw_input", return_value="y"): self.assertTrue(self.displayer.yesno("message")) - with mock_raw_input(["cancel"]): + with mock.patch("__builtin__.raw_input", return_value="cancel"): self.assertFalse(self.displayer.yesno("message")) - - with mock_raw_input(["a"]): + with mock.patch("__builtin__.raw_input", return_value="a"): self.assertTrue(self.displayer.yesno("msg", yes_label="Agree")) @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") @@ -252,11 +247,11 @@ class FileOutputDisplayTest(DisplayT): self.assertEqual(text.count(os.linesep), 3) def test_get_valid_int_ans_valid(self): - with mock_raw_input(["1"]): + with mock.patch("__builtin__.raw_input", return_value="1"): self.assertEqual( self.displayer._get_valid_int_ans(1), (display_util.OK, 1)) ans = "2" - with mock_raw_input([ans]): + with mock.patch("__builtin__.raw_input", return_value=ans): self.assertEqual( self.displayer._get_valid_int_ans(3), (display_util.OK, int(ans))) @@ -268,14 +263,14 @@ class FileOutputDisplayTest(DisplayT): ["c"], ] for ans in answers: - with mock_raw_input(ans): + with mock.patch("__builtin__.raw_input", side_effect=ans): self.assertEqual( self.displayer._get_valid_int_ans(3), (display_util.CANCEL, -1)) # def test_visual(self): # self.displayer = display_util.FileDisplay(sys.stdout) - # test_visual(self.displayer, self.choices) + # visual(self.displayer, self.choices) class SeparateListInputTest(unittest.TestCase): @@ -323,15 +318,5 @@ class PlaceParensTest(unittest.TestCase): self.assertEqual("(L)abel", ret) -# https://stackoverflow.com/a/25275926 -@contextlib.contextmanager -def mock_raw_input(values): - func = mock.MagicMock(side_effect=values) - original_raw_input = __builtins__.raw_input - __builtins__.raw_input = func - yield - __builtins__.raw_input = original_raw_input - - if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 4645d5e3d..7096c17e1 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -101,18 +101,18 @@ 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_authenticator() + auth = client.determine_authenticator() except errors.LetsEncryptMisconfigurationError as err: - logging.fatal("Please fix your configuration before proceeding.{0}" + logging.fatal("Please fix your configuration before proceeding.%s" "The Authenticator exited with the following message: " - "{1}".format(os.linesep, err)) + "%s", (os.linesep, err)) sys.exit(1) # Use the same object if possible - if interfaces.IAuthenticator.providedBy(installer): # pylint: disable=no-member - auth = installer + if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member + installer = auth else: - auth = client.determine_authenticator() + installer = client.determine_installer() domains = ops.choose_names(installer) if args.domains is None else args.domains @@ -131,6 +131,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches # It should be possible for reconfig only, install-only, no-install # I am not sure the best way to handle all of the unimplemented abilities, # but this code should be safe on all environments. + cert_file = None if auth is not None: cert_file, chain_file = acme.obtain_certificate(domains) if installer is not None and cert_file is not None: From 8b184ca82c50fb40cf6f797432930f6781a1d044 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 01:43:54 -0800 Subject: [PATCH 07/46] add failsafe, further revise --- letsencrypt/client/display/revocation.py | 2 +- letsencrypt/client/errors.py | 3 + letsencrypt/client/le_util.py | 8 ++ letsencrypt/client/recovery_token.py | 2 +- letsencrypt/client/revoker.py | 109 +++++++++++++++++------ 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 812b298b5..2f162626d 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -65,7 +65,7 @@ def display_certs(certs): return code, (int(tag) - 1) -def confirm_revocation(self, cert): +def confirm_revocation(cert): """Confirm revocation screen. :param cert: certificate object diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index d49611ce7..086af84d4 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -39,3 +39,6 @@ class LetsEncryptNoInstallationError(LetsEncryptConfiguratorError): class LetsEncryptMisconfigurationError(LetsEncryptConfiguratorError): """Let's Encrypt Misconfiguration error.""" + +class LetsEncryptRevokerError(LetsEncryptClientError): + """Let's Encrypt Revoker error.""" \ No newline at end of file diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 9087ff7a3..f78d734a1 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -76,6 +76,14 @@ def unique_file(path, mode=0o777): count += 1 +def safely_remove(path): + """Remove a file that may not exist.""" + try: + os.remove(path) + except OSError as err: + if err.errno != errno.ENOENT: + raise + # https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-37#appendix-C # # Jose Base64: diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py index 2c328a46d..84e91e891 100644 --- a/letsencrypt/client/recovery_token.py +++ b/letsencrypt/client/recovery_token.py @@ -50,7 +50,7 @@ class RecoveryToken(object): """ try: - os.remove(os.path.join(self.token_dir, chall.domain)) + le_util.safely_remove(os.path.join(self.token_dir, chall.domain)) except OSError as err: if err.errno != errno.ENOENT: raise diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index e49b8f652..d2c4a3b93 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -9,6 +9,7 @@ import M2Crypto from letsencrypt.client import acme from letsencrypt.client import CONFIG +from letsencrypt.client import errors from letsencrypt.client import le_util from letsencrypt.client import network @@ -17,37 +18,95 @@ from letsencrypt.client.display import revocation class Revoker(object): - """A revocation class for LE.""" + """A revocation class for LE. + + ..todo:: Add a method to specify your own certificate for revocation - CLI + + :ivar network: Network object + :type network: :class:`letsencrypt.client.network` + + :ivar installer: Installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ list_path = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + marked_path = os.path.join(CONFIG.CERT_KEY_BACKUP, "MARKED") def __init__(self, server, installer): self.network = network.Network(server) self.installer = installer + # This will go through and make sure that nothing almost got revoked... + # but didn't quite make it... also, guarantees no orphan cert/key files + self.recovery_routine() - def acme_revocation(self, cert): + def revoke_from_interface(self, cert): """Handle ACME "revocation" phase. :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_path, "rU") as backup_key_file: - key = backup_key_file.read() + self._mark_for_revocation(cert) - revoc = self.network.send_and_receive_expected( - acme.revocation_request(cert_der, key), "revocation") - - revocation.success_revocation(cert) + revoc = self.revoke(cert.backup_path, cert.backup_key_path) self.remove_cert_key(cert) + self._remove_mark() + + if revoc is not None: + revocation.success_revocation(cert) + else: + # TODO: Display a nice explanation + pass + self.display_menu() - return revoc + def revoke(self, cert_path, key_path): + """Revoke the certificate with the ACME server. + + :param str cert_path: path to certificate file + :param str key_path: path to associated private key or authorized key + + """ + try: + cert_der = M2Crypto.X509.load_cert(cert_path).as_der() + with open(key_path, "rU") as backup_key_file: + key = backup_key_file.read() + + # If either of the files don't exist... or are corrupted + except (OSError, IOError, M2Crypto.X509.X509Error): + return None + + # TODO: Catch error associated with already revoked and proceed. + return self.network.send_and_receive_expected( + acme.revocation_request(cert_der, key), "revocation") + + def recovery_routine(self): + """Intended to make sure files aren't orphaned.""" + if not os.path.isfile(Revoker.marked_path): + return + with open(Revoker.marked_path, "r") as marked_file: + csvreader = csv.reader(marked_file) + for row in csvreader: + self.revoke(row[0], row[1]) + le_util.safely_remove(row[0]) + le_util.safely_remove(row[1]) + + self._remove_mark() + + def _mark_for_revocation(self, cert): + """Marks a cert for revocation.""" + if os.path.isfile(Revoker.marked_path): + raise errors.LetsEncryptRevokerError( + "MARKED file was never cleaned.") + with open(Revoker.marked_path, "w") as marked_file: + csvwriter = csv.writer(marked_file) + csvwriter.writerow([cert.backup_path, cert.backup_key_path]) + + def _remove_mark(self): + """Remove the marked file.""" + os.remove(Revoker.marked_path) def display_menu(self): """List trusted Let's Encrypt certificates.""" @@ -62,7 +121,7 @@ class Revoker(object): if certs: cert = revocation.choose_certs(certs) - self.acme_revocation(cert) + self.revoke_from_interface(cert) else: logging.info( "There are not any trusted Let's Encrypt " @@ -70,7 +129,6 @@ class Revoker(object): 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) @@ -86,9 +144,7 @@ class Revoker(object): # Set the meta data cert.add_meta(int(row[0]), row[1], row[2], b_c, b_k) # If we were able to find the cert installed... update status - if self.installer is not None: - cert.installed = csha1_vhlist.get( - cert.get_fingerprint, []) + cert.installed = csha1_vhlist.get(cert.get_fingerprint(), []) certs.append(cert) @@ -129,8 +185,8 @@ class Revoker(object): self._remove_cert_from_list(cert) # Remove files - os.remove(cert["backup_cert_file"]) - os.remove(cert["backup_key_file"]) + os.remove(cert.backup_path) + os.remove(cert.backup_key_path) def _remove_cert_from_list(self, cert): """Remove a certificate from the LIST file.""" @@ -259,29 +315,29 @@ class Cert(object): :param str backup_key: backup key filepath """ - DELETED_MSG = "This file has been moved or deleted" - CHANGED_MSG = "This file has changed" + deleted_msg = "This file has been moved or deleted" + changed_msg = "This file has changed" status = "" key_status = "" # Verify original cert path if not os.path.isfile(orig): - status = DELETED_MSG + status = deleted_msg else: o_cert = M2Crypto.X509.load_cert(orig) if self.get_fingerprint() != o_cert.get_fingerprint(md="sha1"): - status = CHANGED_MSG + status = changed_msg # Verify original key path if not os.path.isfile(orig_key): - key_status = DELETED_MSG + key_status = deleted_msg else: with open(orig_key, "r") as fd: key_pem = fd.read() with open(backup_key, "r") as fd: backup_key_pem = fd.read() if key_pem != backup_key_pem: - key_status = CHANGED_MSG + key_status = changed_msg self.idx = idx self.orig = Cert.PathStatus(orig, status) @@ -342,4 +398,3 @@ class Cert(object): text += str(self) text += "-" * (display_util.WIDTH - 4) return text - From f23b61d164943f892b7d281327c3dcb99f9778f8 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 01:44:46 -0800 Subject: [PATCH 08/46] Add documentation for new display --- docs/api/client/display.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/api/client/display.rst b/docs/api/client/display.rst index 5dde8b66d..30df5f814 100644 --- a/docs/api/client/display.rst +++ b/docs/api/client/display.rst @@ -3,3 +3,21 @@ .. automodule:: letsencrypt.client.display :members: + +:mod:`letsencrypt.client.display.display_util` +============================================== + +.. automodule:: letsencrypt.client.display.display_util + :members: + +:mod:`letsencrypt.client.display.ops` +===================================== + +.. automodule:: letsencrypt.client.display.ops + :members: + +:mod:`letsencrypt.client.display.enhancements` +============================================== + +.. automodule:: letsencrypt.client.display.enhancements + :members: From a311df9c2d591ca3db33b42ce50de37968f4f5b8 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 02:46:29 -0800 Subject: [PATCH 09/46] Add tests for enhancments UI --- .../client/tests/display/enhancements_test.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 letsencrypt/client/tests/display/enhancements_test.py diff --git a/letsencrypt/client/tests/display/enhancements_test.py b/letsencrypt/client/tests/display/enhancements_test.py new file mode 100644 index 000000000..dfdac52e2 --- /dev/null +++ b/letsencrypt/client/tests/display/enhancements_test.py @@ -0,0 +1,58 @@ +"""Module for enhancement UI.""" +import logging +import unittest + +import mock + +from letsencrypt.client import errors +from letsencrypt.client.display import display_util + + +class AskTest(unittest.TestCase): + """Test the ask method.""" + def setUp(self): + logging.disable(logging.CRITICAL) + + def tearDown(self): + logging.disable(logging.NOTSET) + + @classmethod + def _call(cls, enhancement): + from letsencrypt.client.display.enhancements import ask + return ask(enhancement) + + @mock.patch("letsencrypt.client.display.enhancements.util") + def test_redirect(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 1) + self.assertTrue(self._call("redirect")) + + def test_key_error(self): + self.assertRaises( + errors.LetsEncryptClientError, self._call, "unknown_enhancement") + + +class RedirectTest(unittest.TestCase): + """Test the redirect_by_default method.""" + @classmethod + def _call(cls): + from letsencrypt.client.display.enhancements import redirect_by_default + return redirect_by_default() + + @mock.patch("letsencrypt.client.display.enhancements.util") + def test_secure(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 1) + self.assertTrue(self._call()) + + @mock.patch("letsencrypt.client.display.enhancements.util") + def test_cancel(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, 1) + self.assertFalse(self._call()) + + @mock.patch("letsencrypt.client.display.enhancements.util") + def test_easy(self, mock_util): + mock_util().menu.return_value = (display_util.OK, 0) + self.assertFalse(self._call()) + + +if __name__ == "__main__": + unittest.main() From 5fb9cc4c39ece2d7ae2f287fddf1d58aa633598a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 02:47:45 -0800 Subject: [PATCH 10/46] pylint fixes --- letsencrypt/client/client.py | 2 +- letsencrypt/client/display/display_util.py | 25 +++++++++++-------- letsencrypt/client/display/enhancements.py | 14 +++++------ letsencrypt/client/display/ops.py | 8 +++--- letsencrypt/client/display/revocation.py | 16 ++++++------ letsencrypt/client/errors.py | 2 +- letsencrypt/client/revoker.py | 17 ++++++++++--- .../client/tests/apache/configurator_test.py | 1 + .../client/tests/display/display_util_test.py | 18 +++++++++---- letsencrypt/client/tests/display/ops_test.py | 9 ++++--- letsencrypt/scripts/main.py | 10 ++++---- 11 files changed, 75 insertions(+), 47 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 5b6797005..aca683265 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -272,7 +272,7 @@ def validate_key_csr(privkey, csr=None): if csr: if csr.form == "der": csr_obj = M2Crypto.X509.load_request_der_string(csr.data) - csr = Client.CSR(csr.file, csr_obj.as_pem(), "der") + csr = le_util.CSR(csr.file, csr_obj.as_pem(), "der") # If CSR is provided, it must be readable and valid. if csr.data and not crypto_util.valid_csr(csr.data): diff --git a/letsencrypt/client/display/display_util.py b/letsencrypt/client/display/display_util.py index 306729e40..d0df0fa12 100644 --- a/letsencrypt/client/display/display_util.py +++ b/letsencrypt/client/display/display_util.py @@ -5,7 +5,6 @@ import textwrap import dialog import zope.interface -from letsencrypt.client import errors from letsencrypt.client import interfaces @@ -35,6 +34,7 @@ class NcursesDisplay(object): self.height = height def notification(self, message, height=10, pause=False): + # pylint: disable=unused-argument """Display a notification to the user and wait for user acceptance. :param str message: Message to display @@ -150,6 +150,7 @@ class FileDisplay(object): self.outfile = outfile def notification(self, message, height=10, pause=True): + # pylint: disable=unused-argument """Displays a notification and waits for user acceptance. :param str message: Message to display @@ -166,8 +167,9 @@ class FileDisplay(object): if pause: raw_input("Press Enter to Continue") - def menu( - self, message, choices, ok_label="", cancel_label="", help_label=""): + def menu(self, message, choices, + ok_label="", cancel_label="", help_label=""): + # pylint: disable=unused-argument """Display a menu. :param str message: title of menu @@ -264,6 +266,7 @@ class FileDisplay(object): return code, [] def _scrub_checklist_input(self, indices, tags): + # pylint: disable=no-self-use """Validate input and transform indices to appropriate tags. :param list indices: input @@ -335,7 +338,7 @@ class FileDisplay(object): return os.linesep.join(fixed_l) - def _get_valid_int_ans(self, max): + def _get_valid_int_ans(self, max_): """Get a numerical selection. :param int max: The maximum entry (len of choices), must be positive @@ -347,10 +350,10 @@ class FileDisplay(object): """ selection = -1 - if max > 1: + if max_ > 1: input_msg = ("Select the appropriate number " - "[1-{max}] then [enter] (press 'c' to " - "cancel): ".format(max=max)) + "[1-{max_}] then [enter] (press 'c' to " + "cancel): ".format(max_=max_)) else: input_msg = ("Press 1 [enter] to confirm the selection " "(press 'c' to cancel): ") @@ -360,7 +363,7 @@ class FileDisplay(object): return CANCEL, -1 try: selection = int(ans) - if selection < 1 or selection > max: + if selection < 1 or selection > max_: selection = -1 raise ValueError @@ -371,16 +374,16 @@ class FileDisplay(object): return OK, selection -def separate_list_input(input): +def separate_list_input(input_): """Separate a comma or space separated list. - :param str input: input from the user + :param str input_: input from the user :returns: strings :rtype: list """ - no_commas = input.replace(",", " ") + no_commas = input_.replace(",", " ") return [string for string in no_commas.split()] def _parens_around_char(label): diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index 738be6429..ed73f24db 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -22,7 +22,7 @@ def ask(enhancement): """ try: - return _dispatch[enhancement]() + return dispatch[enhancement]() except KeyError: logging.error("Unsupported enhancement given to ask()") raise errors.LetsEncryptClientError("Unsupported Enhancement") @@ -40,19 +40,19 @@ def redirect_by_default(): ("Secure", "Make all requests redirect to secure HTTPS access"), ] - result = _util(interfaces.IDisplay).menu( + code, selection = util(interfaces.IDisplay).menu( "Please choose whether HTTPS access is required or optional.", choices) - if result[0] != display_util.OK: + if code != display_util.OK: return False - return result[1] == 1 + return selection == 1 -_util = zope.component.getUtility +util = zope.component.getUtility # pylint: disable=invalid-name -_dispatch = { +dispatch = { # pylint: disable=invalid-name "redirect": redirect_by_default -} \ No newline at end of file +} diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 36a1498e2..22048df7b 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -1,4 +1,4 @@ -import logging +"""Contains UI methods for LE user operations.""" import os import sys @@ -8,7 +8,7 @@ from letsencrypt.client import interfaces from letsencrypt.client.display import display_util # Define a helper function to avoid verbose code -util = zope.component.getUtility +util = zope.component.getUtility # pylint: disable=invalid-name def choose_authenticator(auths): @@ -82,11 +82,11 @@ def _filter_names(names): def _choose_names_manually(): """Manualy input names for those without an installer.""" - code, input = util(interfaces.IDisplay).input( + 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) + return display_util.separate_list_input(input_) sys.exit(0) diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 2f162626d..04394a11e 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -1,3 +1,4 @@ +"""Revocation UI class.""" import os import zope.component @@ -5,7 +6,7 @@ import zope.component from letsencrypt.client import interfaces from letsencrypt.client.display import display_util -util = zope.component.getUtility +util = zope.component.getUtility # pylint: disable=invalid-name def choose_certs(certs): @@ -45,12 +46,13 @@ def display_certs(certs): """ 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)) + ("%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( diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index 086af84d4..c1d6c785f 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -41,4 +41,4 @@ class LetsEncryptMisconfigurationError(LetsEncryptConfiguratorError): class LetsEncryptRevokerError(LetsEncryptClientError): - """Let's Encrypt Revoker error.""" \ No newline at end of file + """Let's Encrypt Revoker error.""" diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index d2c4a3b93..48d4deaff 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -95,7 +95,7 @@ class Revoker(object): self._remove_mark() - def _mark_for_revocation(self, cert): + def _mark_for_revocation(self, cert): # pylint: disable=no-self-use """Marks a cert for revocation.""" if os.path.isfile(Revoker.marked_path): raise errors.LetsEncryptRevokerError( @@ -104,7 +104,7 @@ class Revoker(object): csvwriter = csv.writer(marked_file) csvwriter.writerow([cert.backup_path, cert.backup_key_path]) - def _remove_mark(self): + def _remove_mark(self): # pylint: disable=no-self-use """Remove the marked file.""" os.remove(Revoker.marked_path) @@ -128,6 +128,7 @@ class Revoker(object): "certificates for this server.") def _populate_saved_certs(self, csha1_vhlist): + # pylint: disable=no-self-use """Populate a list of all the saved certs.""" certs = [] with open(Revoker.list_path, "rb") as csvfile: @@ -188,7 +189,7 @@ class Revoker(object): os.remove(cert.backup_path) os.remove(cert.backup_key_path) - def _remove_cert_from_list(self, cert): + def _remove_cert_from_list(self, cert): # pylint: disable=no-self-use """Remove a certificate from the LIST file.""" list_path2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") @@ -346,34 +347,44 @@ class Cert(object): self.backup_key_path = backup_key def get_installed_msg(self): + """Access installed message.""" return ", ".join(self.installed) def get_subject(self): + """Get subject.""" return self.cert.get_subject().as_text() def get_cn(self): + """Get common name.""" return self.cert.get_subject().CN def get_issuer(self): + """Get issuer.""" return self.cert.get_issuer().as_text() def get_fingerprint(self): + """Get sha1 fingerprint.""" return self.cert.get_fingerprint(md="sha1") def get_not_before(self): + """Get not_valid_before field.""" return self.cert.get_not_before().get_datetime() def get_not_after(self): + """Get not_valid_after field.""" return self.cert.get_not_after().get_datetime() def get_serial(self): + """Get serial number.""" self.cert.get_serial_number() def get_pub_key(self): + """Get public key size.""" # .. todo:: M2Crypto doesn't support ECC, this will have to be updated return "RSA " + str(self.cert.get_pubkey().size() * 8) def get_san(self): + """Get subject alternative name if available.""" try: return self.cert.get_ext("subjectAltName").get_value() except LookupError: diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index e662e5eab..c1ad7278d 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -166,6 +166,7 @@ class TwoVhost80Test(util.ApacheTest): class GetVersionTest(unittest.TestCase): + # pylint: disable=too-few-public-methods @classmethod def _call(cls): from letsencrypt.client.apache.configurator import get_version diff --git a/letsencrypt/client/tests/display/display_util_test.py b/letsencrypt/client/tests/display/display_util_test.py index 42c551bad..3c81a463f 100644 --- a/letsencrypt/client/tests/display/display_util_test.py +++ b/letsencrypt/client/tests/display/display_util_test.py @@ -1,4 +1,4 @@ -import contextlib +"""Test the display utility.""" import os import unittest @@ -8,6 +8,8 @@ from letsencrypt.client.display import display_util class DisplayT(unittest.TestCase): + """Base class for both utility classes.""" + # pylint: disable=too-few-public-methods def setUp(self): self.choices = [("First", "Description1"), ("Second", "Description2")] self.tags = ["tag1", "tag2", "tag3"] @@ -205,6 +207,7 @@ class FileOutputDisplayTest(DisplayT): self.assertEqual(ret, (display_util.CANCEL, [])) def test_scrub_checklist_input_valid(self): + # pylint: disable=protected-access indices = [ ["1"], ["1", "2", "1"], @@ -221,6 +224,7 @@ class FileOutputDisplayTest(DisplayT): self.assertEqual(set_tags, exp[i]) def test_scrub_checklist_input_invalid(self): + # pylint: disable=protected-access indices = [ ["0"], ["4"], @@ -233,11 +237,13 @@ class FileOutputDisplayTest(DisplayT): self.displayer._scrub_checklist_input(list_, self.tags), []) def test_print_menu(self): + # pylint: disable=protected-access # This is purely cosmetic... just make sure there aren't any exceptions self.displayer._print_menu("msg", self.choices) self.displayer._print_menu("msg", self.tags) def test_wrap_lines(self): + # pylint: disable=protected-access msg = ("This is just a weak test\n" "This function is only meant to be for easy viewing\n" "Test a really really really really really really really really " @@ -247,6 +253,7 @@ class FileOutputDisplayTest(DisplayT): self.assertEqual(text.count(os.linesep), 3) def test_get_valid_int_ans_valid(self): + # pylint: disable=protected-access with mock.patch("__builtin__.raw_input", return_value="1"): self.assertEqual( self.displayer._get_valid_int_ans(1), (display_util.OK, 1)) @@ -257,6 +264,7 @@ class FileOutputDisplayTest(DisplayT): (display_util.OK, int(ans))) def test_get_valid_int_ans_invalid(self): + # pylint: disable=protected-access answers = [ ["0", "c"], ["4", "one", "C"], @@ -279,9 +287,9 @@ class SeparateListInputTest(unittest.TestCase): self.exp = ["a", "b", "c", "test"] @classmethod - def _call(cls, input): + def _call(cls, input_): from letsencrypt.client.display.display_util import separate_list_input - return separate_list_input(input) + return separate_list_input(input_) def test_commas(self): actual = self._call("a,b,c,test") @@ -305,7 +313,7 @@ class SeparateListInputTest(unittest.TestCase): class PlaceParensTest(unittest.TestCase): @classmethod - def _call(cls, label): + def _call(cls, label): # pylint: disable=protected-access from letsencrypt.client.display.display_util import _parens_around_char return _parens_around_char(label) @@ -319,4 +327,4 @@ class PlaceParensTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index bb105ce65..10d8d9642 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -1,3 +1,4 @@ +"""Test display.ops.""" import sys import unittest @@ -129,13 +130,15 @@ class ChooseNamesTest(unittest.TestCase): @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"]) + mock_util().checklist.return_value = ( + display_util.CANCEL, ["example.com"]) self.assertRaises(SystemExit, self._call, self.mock_install) class SuccessInstallationTest(unittest.TestCase): - + # pylint: disable=too-few-public-methods + """Test the success installation message.""" @classmethod def _call(cls, names): from letsencrypt.client.display.ops import success_installation @@ -156,4 +159,4 @@ class SuccessInstallationTest(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 7096c17e1..24892f6a8 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -105,7 +105,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches except errors.LetsEncryptMisconfigurationError as err: logging.fatal("Please fix your configuration before proceeding.%s" "The Authenticator exited with the following message: " - "%s", (os.linesep, err)) + "%s", os.linesep, err) sys.exit(1) # Use the same object if possible @@ -114,7 +114,7 @@ def main(): # pylint: disable=too-many-statements,too-many-branches else: installer = client.determine_installer() - domains = ops.choose_names(installer) if args.domains is None else args.domains + doms = ops.choose_names(installer) if args.domains is None else args.domains # Prepare for init of Client if args.privkey is None: @@ -133,11 +133,11 @@ def main(): # pylint: disable=too-many-statements,too-many-branches # but this code should be safe on all environments. cert_file = None if auth is not None: - cert_file, chain_file = acme.obtain_certificate(domains) + cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(domains, privkey, cert_file, chain_file) + acme.deploy_certificate(doms, privkey, cert_file, chain_file) if installer is not None: - acme.enhance_config(domains, args.redirect) + acme.enhance_config(doms, args.redirect) def display_eula(): From 9e46fcb21994591ab29540c4a1fc2ef53900522d Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 03:08:00 -0800 Subject: [PATCH 11/46] modify test accordingly, green travis --- letsencrypt/client/tests/reverter_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 39ef3d135..8484a593d 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -296,8 +296,8 @@ class TestFullCheckpointsReverter(unittest.TestCase): @mock.patch("letsencrypt.client.reverter.logging.warning") def test_finalize_checkpoint_no_in_progress(self, mock_warn): - self.reverter.finalize_checkpoint("No checkpoint... should warn") - self.assertEqual(mock_warn.call_count, 1) + # No need to warn for this... just make sure there are no errors. + self.reverter.finalize_checkpoint("No checkpoint...") @mock.patch("letsencrypt.client.reverter.shutil.move") def test_finalize_checkpoint_cannot_title(self, mock_move): From b5f0cf9adc2de1976fdc4218faba9676f620a91c Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 9 Feb 2015 14:31:25 -0800 Subject: [PATCH 12/46] Remove unnecessary mock --- letsencrypt/client/tests/reverter_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 8484a593d..028d037bd 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -294,8 +294,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.reverter.rollback_checkpoints(1) self.assertEqual(read_in(self.config1), "directive-dir1") - @mock.patch("letsencrypt.client.reverter.logging.warning") - def test_finalize_checkpoint_no_in_progress(self, mock_warn): + def test_finalize_checkpoint_no_in_progress(self): # No need to warn for this... just make sure there are no errors. self.reverter.finalize_checkpoint("No checkpoint...") From a31500eb966e6c018f7237c29cb31d197340c237 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 10 Feb 2015 00:55:40 -0800 Subject: [PATCH 13/46] finish merge from hell --- letsencrypt/client/apache/configurator.py | 70 +++++++++---------- letsencrypt/client/client.py | 4 +- letsencrypt/client/display/ops.py | 2 +- letsencrypt/client/interfaces.py | 52 +++++++++++++- letsencrypt/client/revoker.py | 6 +- .../client/tests/apache/configurator_test.py | 19 ++--- letsencrypt/client/tests/crypto_util_test.py | 4 +- letsencrypt/scripts/main.py | 4 +- 8 files changed, 101 insertions(+), 60 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 198d83e81..9569b1d99 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -94,7 +94,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.check_parsing_errors("httpd.aug") # Set Version - self.version = get_version() if version is None else version + self.version = self.get_version() if version is None else version # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() @@ -911,9 +911,39 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) - @classmethod - def __str__(cls): - return "Apache version %s" % ".".join(get_version()) + 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( + [self.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" % self.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 __str__(self): + return "Apache version %s" % ".".join(self.get_version()) ########################################################################### @@ -973,38 +1003,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() -def get_version(apache_ctl): - """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( - [apache_ctl, '-v'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - text = proc.communicate()[0] - except (OSError, ValueError): - raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -v" % 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, apache_init, apache_enmod): """Enables module in Apache. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 2ec530f82..063f55df6 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -420,7 +420,7 @@ def _misconfigured_rollback(checkpoints, config): :type config: :class:`letsencrypt.client.interfaces.IConfig` """ - yes = zope.component.getUtility(interfaces.IDisplay).generic_yesno( + yes = zope.component.getUtility(interfaces.IDisplay).yesno( "Oh, no! The web server is currently misconfigured.{0}{0}" "Would you still like to rollback the " "configuration?".format(os.linesep)) @@ -466,7 +466,7 @@ def revoke(config): "installed may not be available.") installer = None - revoc = revoker.Revoker(server, installer) + revoc = revoker.Revoker(installer, config) revoc.display_menu() diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 22048df7b..c64d6a5e7 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -23,7 +23,7 @@ def choose_authenticator(auths): """ 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]) + [str(auth) for auth in auths]) if code == display_util.OK: return auths[index] diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 2530fa752..2cdb98071 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -43,7 +43,57 @@ class IAuthenticator(zope.interface.Interface): """ def cleanup(chall_list): - """Revert changes and shutdown after challenges complete.""" + """Revert changes and shutdown after challenges complete. + + :param list chall_list: namedtuple types defined in + :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.). + + """ + + +class IConfig(zope.interface.Interface): + """Let's Encrypt user-supplied configuration. + + .. warning:: The values stored in the configuration have not been + filtered, stripped or sanitized. + + """ + server = zope.interface.Attribute( + "CA hostname (and optionally :port). The server certificate must " + "be trusted in order to avoid further modifications to the client.") + rsa_key_size = zope.interface.Attribute("Size of the RSA key.") + + config_dir = zope.interface.Attribute("Configuration directory.") + work_dir = zope.interface.Attribute("Working directory.") + backup_dir = zope.interface.Attribute("Configuration backups directory.") + temp_checkpoint_dir = zope.interface.Attribute( + "Temporary checkpoint directory.") + in_progress_dir = zope.interface.Attribute( + "Directory used before a permanent checkpoint is finalized.") + cert_key_backup = zope.interface.Attribute( + "Directory where all certificates and keys are stored. " + "Used for easy revocation.") + rec_token_dir = zope.interface.Attribute( + "Directory where all recovery tokens are saved.") + key_dir = zope.interface.Attribute("Keys storage.") + cert_dir = zope.interface.Attribute("Certificates storage.") + + le_vhost_ext = zope.interface.Attribute( + "SSL vhost configuration extension.") + cert_path = zope.interface.Attribute("Let's Encrypt certificate file.") + chain_path = zope.interface.Attribute("Let's Encrypt chain file.") + + apache_server_root = zope.interface.Attribute( + "Apache server root directory.") + apache_ctl = zope.interface.Attribute( + "Path to the 'apache2ctl' binary, used for 'configtest' and " + "retrieving Apache2 version number.") + apache_enmod = zope.interface.Attribute( + "Path to the Apache 'a2enmod' binary.") + apache_init_script = zope.interface.Attribute( + "Path to the Apache init script (used for server reload/restart).") + apache_mod_ssl_conf = zope.interface.Attribute( + "Contains standard Apache SSL directives.") class IInstaller(zope.interface.Interface): diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index dd4cbe3bd..d248952c0 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -104,7 +104,7 @@ class Revoker(object): def _mark_for_revocation(self, cert): # pylint: disable=no-self-use """Marks a cert for revocation.""" - if os.path.isfile(Revoker.marked_path): + if os.path.isfile(self.marked_path): raise errors.LetsEncryptRevokerError( "MARKED file was never cleaned.") with open(self.marked_path, "w") as marked_file: @@ -200,7 +200,7 @@ class Revoker(object): """Remove a certificate from the LIST file.""" list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") - with open(Revoker.list_path, "rb") as orgfile: + with open(self.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) with open(list_path2, "wb") as newfile: @@ -212,7 +212,7 @@ class Revoker(object): row[2] == cert.orig_key.path): csvwriter.writerow(row) - shutil.copy2(list_path2, Revoker.list_path) + shutil.copy2(list_path2, self.list_path) os.remove(list_path2) @classmethod diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index c1ad7278d..a67c0088a 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -164,38 +164,31 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(mock_restart.call_count, 1) - -class GetVersionTest(unittest.TestCase): - # pylint: disable=too-few-public-methods - @classmethod - def _call(cls): - from letsencrypt.client.apache.configurator import get_version - return get_version() - @mock.patch("letsencrypt.client.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( "Server Version: Apache/2.4.2 (Debian)", "") - self.assertEqual(self._call(), (2, 4, 2)) + self.assertEqual(self.config.get_version(), (2, 4, 2)) mock_popen().communicate.return_value = ( "Server Version: Apache/2 (Linux)", "") - self.assertEqual(self._call(), (2,)) + self.assertEqual(self.config.get_version(), (2,)) mock_popen().communicate.return_value = ( "Server Version: Apache (Debian)", "") self.assertRaises( - errors.LetsEncryptConfiguratorError, self._call) + errors.LetsEncryptConfiguratorError, self.config.get_version) mock_popen().communicate.return_value = ( "Server Version: Apache/2.3\n Apache/2.4.7", "") self.assertRaises( - errors.LetsEncryptConfiguratorError, self._call) + errors.LetsEncryptConfiguratorError, self.config.get_version) mock_popen.side_effect = OSError("Can't find program") self.assertRaises( - errors.LetsEncryptConfiguratorError, self._call) + errors.LetsEncryptConfiguratorError, self.config.get_version) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 5bf804773..a70d57144 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -99,9 +99,9 @@ class MakeKeyTest(unittest.TestCase): # pylint: disable=too-few-public-methods def test_it(self): # pylint: disable=no-self-use from letsencrypt.client.crypto_util import make_key + # This individual test was taking over 6 seconds... + # I have shortened it... to aid debugging the rest of the project M2Crypto.RSA.load_key_string(make_key(1024)) - M2Crypto.RSA.load_key_string(make_key(2048)) - M2Crypto.RSA.load_key_string(make_key(4096)) class ValidPrivkeyTest(unittest.TestCase): diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 78ea122cd..8c48f557d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -127,7 +127,7 @@ def main(): # pylint: disable=too-many-branches # Make sure we actually get an installer that is functioning properly # before we begin to try to use it. try: - auth = client.determine_authenticator() + auth = client.determine_authenticator(config) except errors.LetsEncryptMisconfigurationError as err: logging.fatal("Please fix your configuration before proceeding.%s" "The Authenticator exited with the following message: " @@ -138,7 +138,7 @@ def main(): # pylint: disable=too-many-branches if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member installer = auth else: - installer = client.determine_installer() + installer = client.determine_installer(config) doms = ops.choose_names(installer) if args.domains is None else args.domains From 71dc5435c94a6c7873dd9824633e1c08793fa9b9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 11 Feb 2015 15:38:59 -0800 Subject: [PATCH 14/46] merge standalone, plus further development --- docs/api/client/display.rst | 6 + letsencrypt/client/client.py | 2 +- letsencrypt/client/configuration.py | 4 +- letsencrypt/client/constants.py | 3 + letsencrypt/client/le_util.py | 2 +- letsencrypt/client/revoker.py | 105 ++-- .../client/standalone_authenticator.py | 336 +++++++++++++ letsencrypt/client/tests/revoker_test.py | 6 + .../tests/standalone_authenticator_test.py | 461 ++++++++++++++++++ letsencrypt/scripts/main.py | 2 +- requirements.txt | 1 + setup.py | 1 + 12 files changed, 875 insertions(+), 54 deletions(-) create mode 100755 letsencrypt/client/standalone_authenticator.py create mode 100644 letsencrypt/client/tests/revoker_test.py create mode 100644 letsencrypt/client/tests/standalone_authenticator_test.py diff --git a/docs/api/client/display.rst b/docs/api/client/display.rst index 30df5f814..f6cc19b4d 100644 --- a/docs/api/client/display.rst +++ b/docs/api/client/display.rst @@ -21,3 +21,9 @@ .. automodule:: letsencrypt.client.display.enhancements :members: + +:mod:`letsencrypt.client.display.revocation` +============================================ + +.. automodule:: letsencrypt.client.display.revocation + :members: diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 063f55df6..1385e3a94 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -250,7 +250,7 @@ def validate_key_csr(privkey, csr=None): :type privkey: :class:`letsencrypt.client.le_util.Key` :param csr: CSR - :type csr: :class:`letsencrypt.client.client.Client.CSR` + :type csr: :class:`letsencrypt.client.le_util.CSR` :raises LetsEncryptClientError: if validation fails diff --git a/letsencrypt/client/configuration.py b/letsencrypt/client/configuration.py index 1bdbe2059..87502ed63 100644 --- a/letsencrypt/client/configuration.py +++ b/letsencrypt/client/configuration.py @@ -45,8 +45,10 @@ class NamespaceConfig(object): @property def cert_key_backup(self): # pylint: disable=missing-docstring return os.path.join( - self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR) + self.namespace.work_dir, constants.CERT_KEY_BACKUP_DIR, + self.namespace.server.partition(":")[0]) + # TODO: This should probably include the server name @property def rec_token_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR) diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 3652face7..e30a4b725 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -45,6 +45,9 @@ APACHE_REWRITE_HTTPS_ARGS = [ """Apache rewrite rule arguments used for redirections to https vhost""" +DVSNI_CHALLENGE_PORT = 443 +"""Port to perform DVSNI challenge.""" + DVSNI_DOMAIN_SUFFIX = ".acme.invalid" """Suffix appended to domains in DVSNI validation.""" diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index f78d734a1..1226020f9 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -1,4 +1,4 @@ -"""Utilities for all Let"s Encrypt.""" +"""Utilities for all Let's Encrypt.""" import base64 import collections import errno diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index d248952c0..1dd2c0975 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -20,7 +20,7 @@ from letsencrypt.client.display import revocation class Revoker(object): """A revocation class for LE. - ..todo:: Add a method to specify your own certificate for revocation - CLI + .. todo:: Add a method to specify your own certificate for revocation - CLI :ivar network: Network object :type network: :class:`letsencrypt.client.network` @@ -32,20 +32,15 @@ class Revoker(object): :type config: :class:`~letsencrypt.client.interfaces.IConfig` """ - def __init__(self, installer, config): self.network = network.Network(config.server) self.installer = installer self.config = config - # This will go through and make sure that nothing almost got revoked... - # but didn't quite make it... also, guarantees no orphan cert/key files - self.recovery_routine() + le_util.make_or_verify_dir(config.cert_key_backup, 0o700) - # TODO: WTF do I do with these... + # TODO: Find a better solution for this... self.list_path = os.path.join(config.cert_key_backup, "LIST") - self.marked_path = os.path.join(config.cert_key_backup, "MARKED") - def revoke_from_interface(self, cert): """Handle ACME "revocation" phase. @@ -54,12 +49,9 @@ class Revoker(object): :type cert: :class:`letsencrypt.client.revoker.Cert` """ - self._mark_for_revocation(cert) - revoc = self.revoke(cert.backup_path, cert.backup_key_path) - self.remove_cert_key(cert) - self._remove_mark() + self.remove_cert_key([cert.idx, cert.backup_path, cert.backup_key_path]) if revoc is not None: revocation.success_revocation(cert) @@ -69,6 +61,22 @@ class Revoker(object): self.display_menu() + def revoke_from_key(self, auth_key): + marked = [] + with open(self.list_path, "r") as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + # idx, cert, key + # Add all keys that match to marked list + # TODO: This doesn't account for padding in file that might + # differ. This should only consider the key material. + # Note: The key can be different than the pub key found in the + # certificate. + if auth_key.pem == open(row[2]).read(): + marked.append(row) + + self.remove_certs_keys(marked) + def revoke(self, cert_path, key_path): """Revoke the certificate with the ACME server. @@ -89,32 +97,6 @@ class Revoker(object): return self.network.send_and_receive_expected( acme.revocation_request(cert_der, key), "revocation") - def recovery_routine(self): - """Intended to make sure files aren't orphaned.""" - if not os.path.isfile(self.marked_path): - return - with open(self.marked_path, "r") as marked_file: - csvreader = csv.reader(marked_file) - for row in csvreader: - self.revoke(row[0], row[1]) - le_util.safely_remove(row[0]) - le_util.safely_remove(row[1]) - - self._remove_mark() - - def _mark_for_revocation(self, cert): # pylint: disable=no-self-use - """Marks a cert for revocation.""" - if os.path.isfile(self.marked_path): - raise errors.LetsEncryptRevokerError( - "MARKED file was never cleaned.") - with open(self.marked_path, "w") as marked_file: - csvwriter = csv.writer(marked_file) - csvwriter.writerow([cert.backup_path, cert.backup_key_path]) - - def _remove_mark(self): # pylint: disable=no-self-use - """Remove the marked file.""" - os.remove(self.marked_path) - def display_menu(self): """List trusted Let's Encrypt certificates.""" @@ -136,7 +118,15 @@ class Revoker(object): def _populate_saved_certs(self, csha1_vhlist): # pylint: disable=no-self-use - """Populate a list of all the saved certs.""" + """Populate a list of all the saved certs. + + It is important to read from the file rather than the directory. + We assume that the LIST file is the master record and depending on + program crashes, this may differ from what is actually in the directory. + Namely, additional certs/keys may exist. There should never be any + certs/keys in the LIST that don't exist in the directory however. + + """ certs = [] with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) @@ -183,23 +173,32 @@ class Revoker(object): return csha1_vhlist - def remove_cert_key(self, cert): # pylint: disable=no-self-use + def remove_certs_keys(self, del_list): # pylint: disable=no-self-use """Remove certificate and key. - :param cert: cert object - :type cert: :class:`letsencrypt.client.revoker.Cert` + :param list del_list: each is a `list` in the form + [idx, cert_path, key_path] all entries must be in the original + LIST order """ - self._remove_cert_from_list(cert) + # This must occur first, LIST is the official key + self._remove_certs_from_list(del_list) # Remove files - os.remove(cert.backup_path) - os.remove(cert.backup_key_path) + for row in del_list: + os.remove(row[1]) + os.remove(row[2]) - def _remove_cert_from_list(self, cert): # pylint: disable=no-self-use - """Remove a certificate from the LIST file.""" + def _remove_certs_from_list(self, del_list): # pylint: disable=no-self-use + """Remove a certificate from the LIST file. + + :param list del_list: each is a csv row, all items must be in the + proper file order. + + """ list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") + idx = 0 with open(self.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) @@ -207,10 +206,16 @@ class Revoker(object): csvwriter = csv.writer(newfile) for row in csvreader: - if not (row[0] == str(cert.idx) and - row[1] == cert.orig.path and - row[2] == cert.orig_key.path): + if not (row[0] == str(del_list[idx][0]) and + row[1] == del_list[idx][1] and + row[2] == del_list[idx][2]): csvwriter.writerow(row) + else: + # Found one of the marked rows... on to the next + idx += 1 + + if idx != len(del_list): + errors.LetsEncryptRevokerError("Did not find all items in del_list") shutil.copy2(list_path2, self.list_path) os.remove(list_path2) diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py new file mode 100755 index 000000000..a1b1daa58 --- /dev/null +++ b/letsencrypt/client/standalone_authenticator.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""An authenticator that doesn't rely on any existing server program. + +This authenticator creates its own ephemeral TCP listener on the specified +port in order to respond to incoming DVSNI challenges from the certificate +authority.""" + +import os +import signal +import socket +import sys +import time + +import Crypto.Random +import OpenSSL.crypto +import OpenSSL.SSL +import zope.component +import zope.interface + +from letsencrypt.client import challenge_util +from letsencrypt.client import constants +from letsencrypt.client import interfaces + + +class StandaloneAuthenticator(object): + # pylint: disable=too-many-instance-attributes + """The StandaloneAuthenticator class itself. + + This authenticator can be invoked by the Let's Encrypt client + according to the IAuthenticator API interface. It creates a local + TCP listener on a specified port and satisfies DVSNI challenges.""" + zope.interface.implements(interfaces.IAuthenticator) + + def __init__(self): + self.child_pid = None + self.parent_pid = os.getpid() + self.subproc_state = None + self.tasks = {} + self.sock = None + self.connection = None + self.private_key = None + self.ssl_conn = None + + def client_signal_handler(self, sig, unused_frame): + """Signal handler for the parent process. + + This handler receives inter-process communication from the + child process in the form of Unix signals. + + :param int sig: Which signal the process received.""" + # subprocess → client READY : SIGIO + # subprocess → client INUSE : SIGUSR1 + # subprocess → client CANTBIND: SIGUSR2 + if sig == signal.SIGIO: + self.subproc_state = "ready" + elif sig == signal.SIGUSR1: + self.subproc_state = "inuse" + elif sig == signal.SIGUSR2: + self.subproc_state = "cantbind" + else: + # NOTREACHED + raise ValueError("Unexpected signal in signal handler") + + def subproc_signal_handler(self, sig, unused_frame): + """Signal handler for the child process. + + This handler receives inter-process communication from the parent + process in the form of Unix signals. + + :param int sig: Which signal the process received.""" + # client → subprocess CLEANUP : SIGINT + if sig == signal.SIGINT: + try: + self.ssl_conn.shutdown() + self.ssl_conn.close() + except BaseException: + # There might not even be any currently active SSL connection. + pass + try: + self.connection.close() + except BaseException: + # There might not even be any currently active connection. + pass + try: + self.sock.close() + except BaseException: + # Various things can go wrong in the course of closing these + # connections, but none of them can clearly be usefully + # reported here and none of them should impede us from + # exiting as gracefully as possible. + pass + os.kill(self.parent_pid, signal.SIGUSR1) + sys.exit(0) + + def sni_callback(self, connection): + """Used internally to respond to incoming SNI names. + + This method will set a new OpenSSL context object for this + connection when an incoming connection provides an SNI name + (in order to serve the appropriate certificate, if any). + + :param OpenSSL.Connection connection: The TLS connection object + on which the SNI extension was received.""" + + sni_name = connection.get_servername() + if sni_name in self.tasks: + pem_cert = self.tasks[sni_name] + else: + # TODO: Should we really present a certificate if we get an + # unexpected SNI name? Or should we just disconnect? + pem_cert = self.tasks.values()[0] + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem_cert) + new_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + new_ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False) + new_ctx.use_certificate(cert) + new_ctx.use_privatekey(self.private_key) + connection.set_context(new_ctx) + + def do_parent_process(self, port, delay_amount=5): + """Perform the parent process side of the TCP listener task. + + This should only be called by start_listener(). We will wait + up to delay_amount seconds to hear from the child process via + a signal. + + :param int port: Which TCP port to bind. + :param float delay_amount: How long in seconds to wait for the + subprocess to notify us whether it succeeded. + + :returns: True or False according to whether we were notified + that the child process succeeded or failed in binding the port.""" + + signal.signal(signal.SIGIO, self.client_signal_handler) + signal.signal(signal.SIGUSR1, self.client_signal_handler) + signal.signal(signal.SIGUSR2, self.client_signal_handler) + display = zope.component.getUtility(interfaces.IDisplay) + start_time = time.time() + while time.time() < start_time + delay_amount: + if self.subproc_state == "ready": + return True + if self.subproc_state == "inuse": + display.generic_notification( + "Could not bind TCP port {0} because it is already in " + "use it is already in use by another process on this " + "system (such as a web server).".format(port)) + return False + if self.subproc_state == "cantbind": + display.generic_notification( + "Could not bind TCP port {0} because you don't have " + "the appropriate permissions (for example, you " + "aren't running this program as " + "root).".format(port)) + return False + time.sleep(0.1) + display.generic_notification( + "Subprocess unexpectedly timed out while trying to bind TCP " + "port {0}.".format(port)) + return False + + def do_child_process(self, port, key): + """Perform the child process side of the TCP listener task. + + This should only be called by start_listener(). + + Normally does not return; instead, the child process exits from + within this function or from within the child process signal + handler. + + :param int port: Which TCP port to bind. + :param le_util.Key key: The private key to use to respond to + DVSNI challenge requests.""" + signal.signal(signal.SIGINT, self.subproc_signal_handler) + self.sock = socket.socket() + try: + self.sock.bind(("0.0.0.0", port)) + except socket.error, error: + if error.errno == socket.errno.EACCES: + # Signal permissions denied to bind TCP port + os.kill(self.parent_pid, signal.SIGUSR2) + elif error.errno == socket.errno.EADDRINUSE: + # Signal TCP port is already in use + os.kill(self.parent_pid, signal.SIGUSR1) + else: + # XXX: How to handle unknown errors in binding? + raise error + sys.exit(1) + # XXX: We could use poll mechanism to handle simultaneous + # XXX: rather than sequential inbound TCP connections here + self.sock.listen(1) + # Signal that we've successfully bound TCP port + os.kill(self.parent_pid, signal.SIGIO) + self.private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + + while True: + self.connection, _ = self.sock.accept() + + # The code below uses the PyOpenSSL bindings to respond to + # the client. This may expose us to bugs and vulnerabilities + # in OpenSSL (and creates additional dependencies). + ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False) + pem_cert = self.tasks.values()[0] + first_cert = OpenSSL.crypto.load_certificate( + OpenSSL.crypto.FILETYPE_PEM, pem_cert) + ctx.use_certificate(first_cert) + ctx.use_privatekey(self.private_key) + ctx.set_cipher_list("HIGH") + ctx.set_tlsext_servername_callback(self.sni_callback) + self.ssl_conn = OpenSSL.SSL.Connection(ctx, self.connection) + self.ssl_conn.set_accept_state() + self.ssl_conn.do_handshake() + self.ssl_conn.shutdown() + self.ssl_conn.close() + + def start_listener(self, port, key): + """Create a child process which will start a TCP listener on the + specified port to perform the specified DVSNI challenges. + + :param int port: The TCP port to bind. + :param le_util.Key key: The private key to use to respond to + DVSNI challenge requests. + :returns: True or False to indicate success or failure creating + the subprocess. + """ + fork_result = os.fork() + Crypto.Random.atfork() + if fork_result: + # PARENT process (still the Let's Encrypt client process) + self.child_pid = fork_result + # do_parent_process() can return True or False to indicate + # reported success or failure creating the listener. + return self.do_parent_process(port) + else: + # CHILD process (the TCP listener subprocess) + self.child_pid = os.getpid() + # do_child_process() is normally not expected to return but + # should terminate via sys.exit(). + return self.do_child_process(port, key) + + # IAuthenticator method implementations follow + + def get_chall_pref(self, unused_domain): + # pylint: disable=no-self-use + """IAuthenticator interface method get_chall_pref. + + Return a list of challenge types that this authenticator + can perform for this domain. In the case of the + StandaloneAuthenticator, the only challenge type that can ever + be performed is dvsni. + + :returns: A list containing only 'dvsni'.""" + return ["dvsni"] + + def perform(self, chall_list): + """IAuthenticator interface method perform. + + Attempt to perform the + specified challenges, returning the status of each. For the + StandaloneAuthenticator, because there is no convenient way to add + additional requests, this should only be invoked once; subsequent + invocations are an error. To perform validations for multiple + independent sets of domains, a separate StandaloneAuthenticator + should be instantiated. + + :param list chall_list: A list of the the challenge objects to + be attempted by this authenticator. + :returns: A list in the same order containing, in each position, + the successfully configured challenge, False, or None.""" + if self.child_pid or self.tasks: + # We should not be willing to continue with perform + # if there were existing pending challenges. + raise ValueError(".perform() was called with pending tasks!") + results_if_success = [] + results_if_failure = [] + if not chall_list or not isinstance(chall_list, list): + raise ValueError(".perform() was called without challenge list") + for chall in chall_list: + if isinstance(chall, challenge_util.DvsniChall): + # We will attempt to do it + name, r_b64 = chall.domain, chall.r_b64 + nonce, key = chall.nonce, chall.key + cert, s_b64 = challenge_util.dvsni_gen_cert( + name, r_b64, nonce, key) + self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] = cert + results_if_success.append({"type": "dvsni", "s": s_b64}) + results_if_failure.append(None) + else: + # We will not attempt to do this challenge because it + # is not a type we can handle + results_if_success.append(False) + results_if_failure.append(False) + if not self.tasks: + raise ValueError("nothing for .perform() to do") + # Try to do the authentication; note that this creates + # the listener subprocess via os.fork() + if self.start_listener(constants.DVSNI_CHALLENGE_PORT, key): + return results_if_success + else: + # TODO: This should probably raise a DVAuthError exception + # rather than returning a list of None objects. + return results_if_failure + + def cleanup(self, chall_list): + """IAuthenticator interface method cleanup. + + Remove each of the specified challenges from the list of + challenges that still need to be performed. (In the case of + the StandaloneAuthenticator, if some challenges are removed + from the list, the authenticator socket will still respond to + those challenges.) Once all challenges have been removed from + the list, the listener is deactivated and stops listening. + + :param list chall_list: A list of the the challenge objects to + be deactivated.""" + # Remove this from pending tasks list + for chall in chall_list: + assert isinstance(chall, challenge_util.DvsniChall) + nonce = chall.nonce + if nonce + constants.DVSNI_DOMAIN_SUFFIX in self.tasks: + del self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] + else: + # Could not find the challenge to remove! + raise ValueError("could not find the challenge to remove") + if self.child_pid and not self.tasks: + # There are no remaining challenges, so + # try to shutdown self.child_pid cleanly. + # TODO: ignore any signals from child during this process + os.kill(self.child_pid, signal.SIGINT) + time.sleep(1) + # TODO: restore original signal handlers in parent process + # by resetting their actions to SIG_DFL + # print "TCP listener subprocess has been told to shut down" diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py new file mode 100644 index 000000000..b62b9e1b7 --- /dev/null +++ b/letsencrypt/client/tests/revoker_test.py @@ -0,0 +1,6 @@ +import unittest +import package + + +class CertTest(unittest.TestCase): + def setUp(self): diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py new file mode 100644 index 000000000..0beb0b1d9 --- /dev/null +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python + +"""Tests for standalone_authenticator.py.""" +import mock +import unittest + +import os +import pkg_resources +import signal +import socket + +import OpenSSL.crypto +import OpenSSL.SSL + +from letsencrypt.client import challenge_util +from letsencrypt.client import le_util + + +# Classes based on to allow interrupting infinite loop under test +# after one iteration, based on. +# http://igorsobreira.com/2013/03/17/testing-infinite-loops.html + +class SocketAcceptOnlyNTimes(object): + # pylint: disable=too-few-public-methods + """ + Callable that will raise `CallableExhausted` + exception after `limit` calls, modified to also return + a tuple simulating the return values of a socket.accept() + call + """ + def __init__(self, limit): + self.limit = limit + self.calls = 0 + + def __call__(self): + self.calls += 1 + if self.calls > self.limit: + raise CallableExhausted + # Modified here for a single use as socket.accept() + return (mock.MagicMock(), "ignored") + +class CallableExhausted(Exception): + # pylint: disable=too-few-public-methods + """Exception raised when a method is called more than the + specified number of times.""" + + +class ChallPrefTest(unittest.TestCase): + """Tests for chall_pref() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_chall_pref(self): + self.assertEqual( + self.authenticator.get_chall_pref("example.com"), ["dvsni"]) + + +class SNICallbackTest(unittest.TestCase): + """Tests for sni_callback() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + name, r_b64 = "example.com", le_util.jose_b64encode("x" * 32) + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + nonce, key = "abcdef", le_util.Key("foo", test_key) + self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + self.authenticator.private_key = private_key + self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} + self.authenticator.child_pid = 12345 + + def test_real_servername(self): + connection = mock.MagicMock() + connection.get_servername.return_value = "abcdef.acme.invalid" + self.authenticator.sni_callback(connection) + self.assertEqual(connection.set_context.call_count, 1) + called_ctx = connection.set_context.call_args[0][0] + self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context)) + + def test_fake_servername(self): + """Test behavior of SNI callback when an unexpected name is received. + + (Currently the expected behavior in this case is to return the + "first" certificate with which the listener was configured, + although they are stored in an unordered data structure so + this might not be the one that was first in the challenge list + passed to the perform method. In the future, this might result + in dropping the connection instead.)""" + connection = mock.MagicMock() + connection.get_servername.return_value = "example.com" + self.authenticator.sni_callback(connection) + self.assertEqual(connection.set_context.call_count, 1) + called_ctx = connection.set_context.call_args[0][0] + self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context)) + +class ClientSignalHandlerTest(unittest.TestCase): + """Tests for client_signal_handler() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + + def test_client_signal_handler(self): + self.assertTrue(self.authenticator.subproc_state is None) + self.authenticator.client_signal_handler(signal.SIGIO, None) + self.assertEqual(self.authenticator.subproc_state, "ready") + + self.authenticator.client_signal_handler(signal.SIGUSR1, None) + self.assertEqual(self.authenticator.subproc_state, "inuse") + + self.authenticator.client_signal_handler(signal.SIGUSR2, None) + self.assertEqual(self.authenticator.subproc_state, "cantbind") + + # Testing the unreached path for a signal other than these + # specified (which can't occur in normal use because this + # function is only set as a signal handler for the above three + # signals). + self.assertRaises( + ValueError, self.authenticator.client_signal_handler, + signal.SIGPIPE, None) + + +class SubprocSignalHandlerTest(unittest.TestCase): + """Tests for subproc_signal_handler() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + self.authenticator.parent_pid = 23456 + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_subproc_signal_handler(self, mock_exit, mock_kill): + self.authenticator.ssl_conn = mock.MagicMock() + self.authenticator.connection = mock.MagicMock() + self.authenticator.sock = mock.MagicMock() + self.authenticator.subproc_signal_handler(signal.SIGINT, None) + self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1) + self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1) + self.assertEquals(self.authenticator.connection.close.call_count, 1) + self.assertEquals(self.authenticator.sock.close.call_count, 1) + mock_kill.assert_called_once_with( + self.authenticator.parent_pid, signal.SIGUSR1) + mock_exit.assert_called_once_with(0) + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_subproc_signal_handler_trouble(self, mock_exit, mock_kill): + """Test attempting to shut down a non-existent connection. + + (This could occur because none was established or active at the + time the signal handler tried to perform the cleanup).""" + self.authenticator.ssl_conn = mock.MagicMock() + self.authenticator.connection = mock.MagicMock() + self.authenticator.sock = mock.MagicMock() + # AttributeError simulates the case where one of these properties + # is None because no connection exists. We raise it for + # ssl_conn.close() instead of ssl_conn.shutdown() for better code + # coverage. + self.authenticator.ssl_conn.close.side_effect = AttributeError("!") + self.authenticator.connection.close.side_effect = AttributeError("!") + self.authenticator.sock.close.side_effect = AttributeError("!") + self.authenticator.subproc_signal_handler(signal.SIGINT, None) + self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1) + self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1) + self.assertEquals(self.authenticator.connection.close.call_count, 1) + self.assertEquals(self.authenticator.sock.close.call_count, 1) + mock_kill.assert_called_once_with( + self.authenticator.parent_pid, signal.SIGUSR1) + mock_exit.assert_called_once_with(0) + + +class PerformTest(unittest.TestCase): + """Tests for perform() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_can_perform(self): + """What happens if start_listener() returns True.""" + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + key = le_util.Key("something", test_key) + chall1 = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", key) + chall2 = challenge_util.DvsniChall( + "bar.example.com", "whee", "barnonce", key) + bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") + self.authenticator.start_listener = mock.Mock() + self.authenticator.start_listener.return_value = True + result = self.authenticator.perform([chall1, chall2, bad_chall]) + self.assertEqual(len(self.authenticator.tasks), 2) + self.assertTrue( + self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.assertTrue( + self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.assertTrue(isinstance(result, list)) + self.assertEqual(len(result), 3) + self.assertTrue(isinstance(result[0], dict)) + self.assertTrue(isinstance(result[1], dict)) + self.assertFalse(result[2]) + self.assertTrue(result[0].has_key("s")) + self.assertTrue(result[1].has_key("s")) + self.authenticator.start_listener.assert_called_once_with(443, key) + + def test_cannot_perform(self): + """What happens if start_listener() returns False.""" + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + key = le_util.Key("something", test_key) + chall1 = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", key) + chall2 = challenge_util.DvsniChall( + "bar.example.com", "whee", "barnonce", key) + bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") + self.authenticator.start_listener = mock.Mock() + self.authenticator.start_listener.return_value = False + result = self.authenticator.perform([chall1, chall2, bad_chall]) + self.assertEqual(len(self.authenticator.tasks), 2) + self.assertTrue( + self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.assertTrue( + self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.assertTrue(isinstance(result, list)) + self.assertEqual(len(result), 3) + self.assertEqual(result, [None, None, False]) + self.authenticator.start_listener.assert_called_once_with(443, key) + + def test_perform_with_pending_tasks(self): + self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} + extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + self.assertRaises( + ValueError, self.authenticator.perform, [extra_challenge]) + + def test_perform_without_challenge_list(self): + extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + # This is wrong because a challenge must be specified. + self.assertRaises(ValueError, self.authenticator.perform, []) + # This is wrong because it must be a list, not a bare challenge. + self.assertRaises( + ValueError, self.authenticator.perform, extra_challenge) + # This is wrong because the list must contain at least one challenge. + self.assertRaises( + ValueError, self.authenticator.perform, range(20)) + + +class StartListenerTest(unittest.TestCase): + """Tests for start_listener() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + @mock.patch("letsencrypt.client.standalone_authenticator." + "Crypto.Random.atfork") + @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + def test_start_listener_fork_parent(self, mock_fork, mock_atfork): + self.authenticator.do_parent_process = mock.Mock() + self.authenticator.do_parent_process.return_value = True + mock_fork.return_value = 22222 + result = self.authenticator.start_listener(1717, "key") + # start_listener is expected to return the True or False return + # value from do_parent_process. + self.assertTrue(result) + self.assertEqual(self.authenticator.child_pid, 22222) + self.authenticator.do_parent_process.assert_called_once_with(1717) + mock_atfork.assert_called_once_with() + + @mock.patch("letsencrypt.client.standalone_authenticator." + "Crypto.Random.atfork") + @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + def test_start_listener_fork_child(self, mock_fork, mock_atfork): + self.authenticator.do_parent_process = mock.Mock() + self.authenticator.do_child_process = mock.Mock() + mock_fork.return_value = 0 + self.authenticator.start_listener(1717, "key") + self.assertEqual(self.authenticator.child_pid, os.getpid()) + self.authenticator.do_child_process.assert_called_once_with( + 1717, "key") + mock_atfork.assert_called_once_with() + +class DoParentProcessTest(unittest.TestCase): + """Tests for do_parent_process() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_ok(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "ready" + result = self.authenticator.do_parent_process(1717) + self.assertTrue(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_inuse(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "inuse" + result = self.authenticator.do_parent_process(1717) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_cantbind(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "cantbind" + result = self.authenticator.do_parent_process(1717) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_timeout(self, mock_get_utility, mock_signal): + # Normally times out in 5 seconds and returns False. We can + # now set delay_amount to a lower value so that it times out + # faster than it would under normal use. + result = self.authenticator.do_parent_process(1717, delay_amount=1) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + +class DoChildProcessTest(unittest.TestCase): + """Tests for do_child_process() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + name, r_b64 = "example.com", le_util.jose_b64encode("x" * 32) + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + nonce, key = "abcdef", le_util.Key("foo", test_key) + self.key = key + self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + self.authenticator.private_key = private_key + self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} + self.authenticator.parent_pid = 12345 + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_do_child_process_cantbind1( + self, mock_exit, mock_kill, mock_socket): + mock_exit.side_effect = IndentationError("subprocess would exit here") + eaccess = socket.error(socket.errno.EACCES, "Permission denied") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eaccess + mock_socket.return_value = sample_socket + # Using the IndentationError as an error that cannot easily be + # generated at runtime, to indicate the behavior of sys.exit has + # taken effect without actually causing the test process to exit. + # (Just replacing it with a no-op causes logic errors because the + # do_child_process code assumes that calling sys.exit() will + # cause subsequent code not to be executed.) + self.assertRaises( + IndentationError, self.authenticator.do_child_process, 1717, + self.key) + mock_exit.assert_called_once_with(1) + mock_kill.assert_called_once_with(12345, signal.SIGUSR2) + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_do_child_process_cantbind2(self, mock_exit, mock_kill, + mock_socket): + mock_exit.side_effect = IndentationError("subprocess would exit here") + eaccess = socket.error(socket.errno.EADDRINUSE, "Port already in use") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eaccess + mock_socket.return_value = sample_socket + self.assertRaises( + IndentationError, self.authenticator.do_child_process, 1717, + self.key) + mock_exit.assert_called_once_with(1) + mock_kill.assert_called_once_with(12345, signal.SIGUSR1) + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + def test_do_child_process_cantbind3(self, mock_socket): + """Test case where attempt to bind socket results in an unhandled + socket error. (The expected behavior is arguably wrong because it + will crash the program; the reason for the expected behavior is + that we don't have a way to report arbitrary socket errors.)""" + eio = socket.error(socket.errno.EIO, "Imaginary unhandled error") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eio + mock_socket.return_value = sample_socket + self.assertRaises( + socket.error, self.authenticator.do_child_process, 1717, self.key) + + @mock.patch("letsencrypt.client.standalone_authenticator." + "OpenSSL.SSL.Connection") + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + def test_do_child_process_success(self, mock_kill, mock_socket, + mock_connection): + sample_socket = mock.MagicMock() + sample_socket.accept.side_effect = SocketAcceptOnlyNTimes(2) + mock_socket.return_value = sample_socket + mock_connection.return_value = mock.MagicMock() + self.assertRaises( + CallableExhausted, self.authenticator.do_child_process, 1717, + self.key) + mock_socket.assert_called_once_with() + sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717)) + sample_socket.listen.assert_called_once_with(1) + self.assertEqual(sample_socket.accept.call_count, 3) + mock_kill.assert_called_once_with(12345, signal.SIGIO) + # TODO: We could have some tests about the fact that the listener + # asks OpenSSL to negotiate a TLS connection (and correctly + # sets the SNI callback function). + + +class CleanupTest(unittest.TestCase): + """Tests for cleanup() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.time.sleep") + def test_cleanup(self, mock_sleep, mock_kill): + mock_sleep.return_value = None + mock_kill.return_value = None + chall = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", "key") + self.authenticator.cleanup([chall]) + mock_kill.assert_called_once_with(12345, signal.SIGINT) + mock_sleep.assert_called_once_with(1) + + def test_bad_cleanup(self): + chall = challenge_util.DvsniChall( + "bad.example.com", "whee", "badnonce", "key") + self.assertRaises(ValueError, self.authenticator.cleanup, [chall]) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8c48f557d..44394b03d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python """Parse command line and call the appropriate functions. -..todo:: Sanity check all input. Be sure to avoid shell code etc... +.. todo:: Sanity check all input. Be sure to avoid shell code etc... """ import argparse diff --git a/requirements.txt b/requirements.txt index a95a0807f..a364c4e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-augeas==0.5.0 requests==2.4.3 argparse==1.2.2 mock==1.0.1 +PyOpenSSL==0.13 diff --git a/setup.py b/setup.py index 51c3a1c27..a07e5cf1d 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ install_requires = [ 'jsonschema', 'mock', 'pycrypto', + 'PyOpenSSL', 'python-augeas', 'python2-pythondialog', 'requests', From 0bc5c8a162d72edc3080d7122a6c050c7b28f70f Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 11 Feb 2015 17:04:50 -0800 Subject: [PATCH 15/46] Correct reverter display methods --- letsencrypt/client/reverter.py | 2 +- letsencrypt/client/tests/reverter_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 1e65d54ba..cc0b668d1 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -126,7 +126,7 @@ class Reverter(object): output.append(os.linesep) - zope.component.getUtility(interfaces.IDisplay).generic_notification( + zope.component.getUtility(interfaces.IDisplay).notification( os.linesep.join(output), display_util.HEIGHT) def add_to_temp_checkpoint(self, save_files, save_notes): diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 0e3346924..248e38a59 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -339,7 +339,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.reverter.view_config_changes() # Make sure notification is output - self.assertEqual(mock_output().generic_notification.call_count, 1) + self.assertEqual(mock_output().notification.call_count, 1) @mock.patch("letsencrypt.client.reverter.logging") def test_view_config_changes_no_backups(self, mock_logging): From 9a990ccfaf97426b8a4759f1da58f525647275ea Mon Sep 17 00:00:00 2001 From: James Kasten Date: Sun, 15 Feb 2015 23:17:53 -0800 Subject: [PATCH 16/46] revoker progress --- letsencrypt/client/auth_handler.py | 7 +- letsencrypt/client/client.py | 14 +- letsencrypt/client/constants.py | 2 +- letsencrypt/client/display/revocation.py | 14 +- letsencrypt/client/revoker.py | 195 ++++++++++++------ .../client/tests/display/revocation_test.py | 39 ++++ letsencrypt/scripts/main.py | 19 +- 7 files changed, 201 insertions(+), 89 deletions(-) create mode 100644 letsencrypt/client/tests/display/revocation_test.py diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 6f0ece535..ed785a5f1 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -149,10 +149,11 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes for dom in self.domains: flat_client.extend(ichall.chall for ichall in self.client_c[dom]) flat_auth.extend(ichall.chall for ichall in self.dv_c[dom]) - try: - client_resp = self.client_auth.perform(flat_client) - dv_resp = self.dv_auth.perform(flat_auth) + if flat_client: + client_resp = self.client_auth.perform(flat_client) + if flat_auth: + dv_resp = self.dv_auth.perform(flat_auth) # This will catch both specific types of errors. except errors.LetsEncryptAuthHandlerError as err: logging.critical("Failure in setting up challenges:") diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 1385e3a94..09a8aeaee 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -231,7 +231,7 @@ class Client(object): try: self.installer.enhance(dom, "redirect") except errors.LetsEncryptConfiguratorError: - logging.warn('Unable to perform redirect for %s', dom) + logging.warn("Unable to perform redirect for %s", dom) self.installer.save("Add Redirects") self.installer.restart() @@ -448,7 +448,7 @@ def _misconfigured_rollback(checkpoints, config): "Rollback was unable to solve the misconfiguration issues") -def revoke(config): +def revoke(config, no_confirm, cert, authkey): """Revoke certificates. :param config: Configuration. @@ -466,8 +466,14 @@ def revoke(config): "installed may not be available.") installer = None - revoc = revoker.Revoker(installer, config) - revoc.display_menu() + revoc = revoker.Revoker(installer, config, no_confirm) + # Cert is most selective, so it is chosen first. + if cert is not None: + revoc.revoke_from_cert(cert[0]) + elif authkey is not None: + revoc.revoke_from_key(le_util.Key(authkey[0], authkey[1])) + else: + revoc.display_menu() def view_config_changes(config): diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index e30a4b725..291506940 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -36,7 +36,7 @@ List of expected options parameters: APACHE_MOD_SSL_CONF = pkg_resources.resource_filename( - 'letsencrypt.client.apache', 'options-ssl.conf') + "letsencrypt.client.apache", "options-ssl.conf") """Path to the Apache mod_ssl config file found in the Let's Encrypt distribution.""" diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 04394a11e..0646adb10 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -46,20 +46,17 @@ def display_certs(certs): """ list_choices = [ - ("%s | %s | %s" % ( + "%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) - ) + else "") for cert in certs ] + print list_choices 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", + list_choices, help_label="More Info", ok_label="Revoke", cancel_label="Exit") if not tag: tag = -1 @@ -81,8 +78,7 @@ def confirm_revocation(cert): "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) + return display_util.OK == util(interfaces.IDisplay).yesno(text) def more_info_cert(cert): diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 1dd2c0975..53da5d028 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -1,4 +1,11 @@ -"""Revoker module to enable LE revocations.""" +"""Revoker module to enable LE revocations. + +The backend of this module would fit a database quite nicely, but in order to +minimize dependencies and maintain transparency, the class currently implements +its own storage system. The number of certs that will likely be stored on any +given client might not warrant requiring a database. + +""" import collections import csv import logging @@ -32,57 +39,89 @@ class Revoker(object): :type config: :class:`~letsencrypt.client.interfaces.IConfig` """ - def __init__(self, installer, config): + def __init__(self, installer, config, no_confirm=False): self.network = network.Network(config.server) self.installer = installer self.config = config + self.no_confirm = no_confirm le_util.make_or_verify_dir(config.cert_key_backup, 0o700) # TODO: Find a better solution for this... self.list_path = os.path.join(config.cert_key_backup, "LIST") - def revoke_from_interface(self, cert): - """Handle ACME "revocation" phase. + def safe_revoke(self, certs): + """Confirm and revoke certificates. - :param cert: cert intended to be revoked - :type cert: :class:`letsencrypt.client.revoker.Cert` + :param certs: certs intended to be revoked + :type certs: :class:`letsencrypt.client.revoker.Cert` """ - revoc = self.revoke(cert.backup_path, cert.backup_key_path) + success_list = [] + try: + for cert in certs: + if self.no_confirm or revocation.confirm_revocation(cert): + revoc = self._acme_revoke( + cert.backup_path, cert.backup_key_path) - self.remove_cert_key([cert.idx, cert.backup_path, cert.backup_key_path]) + if revoc is not None: + success_list.append(cert) + revocation.success_revocation(cert) + else: + # TODO: Display a nice explanation + pass + finally: + self._remove_certs_keys(success_list) - if revoc is not None: - revocation.success_revocation(cert) - else: - # TODO: Display a nice explanation - pass + def revoke_from_key(self, authkey): + """Revoke all certificates under an authorized key. - self.display_menu() + :param authkey: Authorized key used in previous transactions + :type authkey: :class:`letsencrypt.client.le_util.Key` - def revoke_from_key(self, auth_key): - marked = [] + """ + certs = [] with open(self.list_path, "r") as csvfile: csvreader = csv.reader(csvfile) for row in csvreader: # idx, cert, key # Add all keys that match to marked list - # TODO: This doesn't account for padding in file that might + # TODO: This doesn't account for padding in the file that might # differ. This should only consider the key material. # Note: The key can be different than the pub key found in the # certificate. - if auth_key.pem == open(row[2]).read(): - marked.append(row) + _, b_k = self._row_to_backup(row) + if authkey.pem == open(b_k).read(): + certs.append(Cert.fromrow(row)) - self.remove_certs_keys(marked) + self.safe_revoke(certs) - def revoke(self, cert_path, key_path): + def revoke_from_cert(self, cert_path): + """Revoke a certificate by specifying a file path. + + :param str cert_path: path to ACME certificate in pem form + + """ + # Locate the correct certificate (do not rely on filename) + cert_to_revoke = Cert(cert_path) + + with open(self.list_path, "r") as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + cert = Cert.fromrow(row) + + # This uses md5 but it doesn't matter and it is easier to read + if cert.get_fingerprint() == cert_to_revoke.get_fingerprint(): + self.safe_revoke([cert]) + + def _acme_revoke(self, cert_path, key_path): """Revoke the certificate with the ACME server. :param str cert_path: path to certificate file :param str key_path: path to associated private key or authorized key + :returns: TODO + """ try: cert_der = M2Crypto.X509.load_cert(cert_path).as_der() @@ -111,6 +150,8 @@ class Revoker(object): if certs: cert = revocation.choose_certs(certs) self.revoke_from_interface(cert) + # Recursive... + self.display_menu() else: logging.info( "There are not any trusted Let's Encrypt " @@ -132,15 +173,8 @@ class Revoker(object): csvreader = csv.reader(csvfile) # idx, orig_cert, orig_key for row in csvreader: - # Generate backup key/cert names - b_k = os.path.join(self.config.cert_key_backup, - os.path.basename(row[2]) + "_" + row[0]) - b_c = os.path.join(self.config.cert_key_backup, - os.path.basename(row[1]) + "_" + row[0]) + cert = Cert.fromrow(row) - cert = Cert(b_c) - # Set the meta data - cert.add_meta(int(row[0]), row[1], row[2], b_c, b_k) # If we were able to find the cert installed... update status cert.installed = csha1_vhlist.get(cert.get_fingerprint(), []) @@ -173,27 +207,26 @@ class Revoker(object): return csha1_vhlist - def remove_certs_keys(self, del_list): # pylint: disable=no-self-use + def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use """Remove certificate and key. - :param list del_list: each is a `list` in the form - [idx, cert_path, key_path] all entries must be in the original - LIST order + :param list cert_list: each is of type + :class:`letsencrypt.client.revoker.Cert` """ # This must occur first, LIST is the official key - self._remove_certs_from_list(del_list) + self._remove_certs_from_list(cert_list) # Remove files - for row in del_list: - os.remove(row[1]) - os.remove(row[2]) + for cert in cert_list: + os.remove(cert.backup_path) + os.remove(cert.backup_key_path) - def _remove_certs_from_list(self, del_list): # pylint: disable=no-self-use + def _remove_certs_from_list(self, cert_list): # pylint: disable=no-self-use """Remove a certificate from the LIST file. - :param list del_list: each is a csv row, all items must be in the - proper file order. + :param list cert_list: each is of type + :class:`letsencrypt.client.revoker.Cert` """ list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") @@ -201,25 +234,33 @@ class Revoker(object): idx = 0 with open(self.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) - with open(list_path2, "wb") as newfile: csvwriter = csv.writer(newfile) for row in csvreader: - if not (row[0] == str(del_list[idx][0]) and - row[1] == del_list[idx][1] and - row[2] == del_list[idx][2]): + if row != cert_list[idx].get_row(): csvwriter.writerow(row) else: - # Found one of the marked rows... on to the next idx += 1 - if idx != len(del_list): - errors.LetsEncryptRevokerError("Did not find all items in del_list") + if idx != len(cert_list): + errors.LetsEncryptRevokerError("Did not find all cert_list items") shutil.copy2(list_path2, self.list_path) os.remove(list_path2) + def _row_to_backup(self, row): + """Convenience function + + :param list row: csv file row 'idx', 'cert_path', 'key_path' + + :returns: tuple of the form ('backup_cert_path', 'backup_key_path') + :rtype: tuple + + """ + return (self._get_backup(self.config.cert_key_backup, row[0], row[1]), + self._get_backup(self.config.cert_key_backup, row[0], row[2])) + @classmethod def store_cert_key(cls, cert_path, key_path, config, encrypt=False): """Store certificate key. (Used to allow quick revocation) @@ -238,7 +279,6 @@ class Revoker(object): """ list_path = (config.cert_key_backup, "LIST") le_util.make_or_verify_dir(config.cert_key_backup, 0o700) - idx = 0 if encrypt: logging.error( @@ -247,39 +287,46 @@ class Revoker(object): "next update!") return False - cls._append_index_file(cert_path, key_path, list_path) - - 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))) + cls._catalog_files( + config.cert_key_backup, cert_path, key_path, list_path) return True @classmethod - def _append_index_file(cls, cert_path, key_path, list_path): + def _catalog_files(cls, backup_dir, cert_path, key_path, list_path): if os.path.isfile(list_path): - with open(list_path, 'r+b') as csvfile: + with open(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) + # You must move the files before appending the row + cls._copy_files(backup_dir, idx, cert_path, key_path) csvwriter.writerow([str(idx), cert_path, key_path]) else: - with open(list_path, 'wb') as csvfile: + with open(list_path, "wb") as csvfile: csvwriter = csv.writer(csvfile) + # You must move the files before appending the row + cls._copy_files(backup_dir, "0", cert_path, key_path) csvwriter.writerow(["0", cert_path, key_path]) + @classmethod + def _copy_files(cls, backup_dir, idx, cert_path, key_path): + shutil.copy2(cert_path, cls._get_backup(backup_dir, idx, cert_path)) + shutil.copy2(key_path, cls._get_backup(backup_dir, idx, key_path)) + + @classmethod + def _get_backup(cls, backup_dir, idx, orig_path): + return os.path.join( + backup_dir, "{name}_{idx}".format( + name=os.path.basename(orig_path), idx=str(idx))) + class Cert(object): - """Cert object used for convenience. + """Cert object used for Revocation convenience. :ivar cert: M2Crypto X509 cert :type cert: :class:`M2Crypto.X509` @@ -309,7 +356,8 @@ class Cert(object): try: self.cert = M2Crypto.X509.load_cert(cert_path) except (IOError, M2Crypto.X509.X509Error): - self.cert = None + raise errors.LetsEncryptRevokerError( + "Error loading certificate: %s" % cert_path) self.idx = -1 @@ -320,6 +368,19 @@ class Cert(object): self.installed = ["Unknown"] + @classmethod + def fromrow(cls, row, backup_dir): + """Initialize Cert from a csv row.""" + idx = int(row[0]) + backup = Revoker._get_backup(backup_dir, idx, row[1]) + backup_key = Revoker._get_backup(backup_dir, idx, row[2]) + + obj = cls(backup) + obj.add_meta(idx, row[1], row[2], backup, backup_key) + + def get_row(self): + """Returns a list in CSV format.""" + return [str(self.idx), self.orig, self.orig_key] def add_meta(self, idx, orig, orig_key, backup, backup_key): """Add meta data to cert @@ -420,7 +481,7 @@ class Cert(object): def pretty_print(self): """Nicely frames a cert str""" - text = "-" * (display_util.WIDTH - 4) + os.linesep - text += str(self) - text += "-" * (display_util.WIDTH - 4) - return text + frame = "-" * (display_util.WIDTH - 4) + os.linesep + return "{frame}{cert}{frame}".format(frame=frame, cert=str(self)) + + diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py new file mode 100644 index 000000000..c78a726e1 --- /dev/null +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -0,0 +1,39 @@ +import os +import pkg_resources +import sys +import unittest + +import mock +import zope.component + +from letsencrypt.client.display import display_util + + +class ChooseCertsTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.revoker import Cert + base_package = "letsencrypt.client.tests" + self.cert1 = Cert(pkg_resources.resource_filename( + base_package, os.path.join("testdata", "cert.pem"))) + self.cert2 = Cert(pkg_resources.resource_filename( + base_package, os.path.join("testdata", "cert-san.pem"))) + + self.certs = [self.cert1, self.cert2] + + zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + + @classmethod + def _call(cls, certs): + from letsencrypt.client.display.revocation import choose_certs + return choose_certs(certs) + + #@mock.patch("letsencrypt.client.display.revocation.util") + def test_confirm_revocation(self): + pass + #mock_util().yesno.return_value = True + self._call(self.certs) + + + +if __name__ == "__main__": + unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 44394b03d..2e3922b32 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -36,12 +36,18 @@ def create_parser(): add("-s", "--server", default="letsencrypt-demo.org:443", help=config_help("server")) - add("-p", "--privkey", type=read_file, - help="Path to the private key file for certificate generation.") + add("-k", "--authkey", type=read_file, + help="Path to the authorized key file") add("-B", "--rsa-key-size", type=int, default=2048, metavar="N", help=config_help("rsa_key_size")) - add("-k", "--revoke", action="store_true", help="Revoke a certificate.") + add("-R", "--revoke", action="store_true", + help="Revoke a certificate from a menu.") + add("--revoke-certificate", dest="rev_cert", type=read_file, + help="Revoke a specific certificate.") + add("--revoke-key", dest="rev_key", type=read_file, + help="Revoke all certs generated by the provided authorized key.") + add("-b", "--rollback", type=int, default=0, metavar="N", help="Revert configuration N number of checkpoints.") add("-v", "--view-config-changes", action="store_true", @@ -52,6 +58,9 @@ def create_parser(): help="Automatically redirect all HTTP traffic to HTTPS for the newly " "authenticated vhost.") + add("--no-confirm", dest="no_confirm", action="store_true", + help="Turn off confirmation screens, currently used for --revoke") + add("-e", "--agree-tos", dest="eula", action="store_true", help="Skip the end user license agreement screen.") add("-t", "--text", dest="use_curses", action="store_false", @@ -114,7 +123,7 @@ def main(): # pylint: disable=too-many-branches sys.exit() if args.revoke: - client.revoke(config) + client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() if args.rollback > 0: @@ -146,7 +155,7 @@ def main(): # pylint: disable=too-many-branches if args.privkey is None: privkey = client.init_key(args.rsa_key_size, config.key_dir) else: - privkey = le_util.Key(args.privkey[0], args.privkey[1]) + privkey = le_util.Key(args.authkey[0], args.authkey[1]) acme = client.Client(config, privkey, auth, installer) From f77307c28bb94df81464f8642f17f220b401501e Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 18 Feb 2015 04:01:49 -0800 Subject: [PATCH 17/46] Finish revoker implementation and unittests --- letsencrypt/client/display/enhancements.py | 2 +- letsencrypt/client/display/ops.py | 2 +- letsencrypt/client/display/revocation.py | 37 +- letsencrypt/client/display/util.py | 5 +- letsencrypt/client/log.py | 2 +- letsencrypt/client/reverter.py | 2 +- letsencrypt/client/revoker.py | 222 ++++++------ .../client/tests/apache/parser_test.py | 2 +- .../client/tests/configuration_test.py | 6 +- .../client/tests/display/enhancements_test.py | 2 +- letsencrypt/client/tests/display/ops_test.py | 2 +- .../client/tests/display/revocation_test.py | 61 +++- letsencrypt/client/tests/display/util_test.py | 36 +- letsencrypt/client/tests/revoker_test.py | 322 +++++++++++++++++- letsencrypt/scripts/main.py | 2 +- tox.ini | 2 +- 16 files changed, 539 insertions(+), 168 deletions(-) diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index ed73f24db..c9a81a4b1 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -5,7 +5,7 @@ import zope.component from letsencrypt.client import errors from letsencrypt.client import interfaces -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util def ask(enhancement): diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index c64d6a5e7..205f3fb08 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -5,7 +5,7 @@ import sys import zope.component from letsencrypt.client import interfaces -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util # Define a helper function to avoid verbose code util = zope.component.getUtility # pylint: disable=invalid-name diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 0646adb10..76666ddf6 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -4,37 +4,33 @@ import os import zope.component from letsencrypt.client import interfaces -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util util = zope.component.getUtility # pylint: disable=invalid-name def choose_certs(certs): - """Display choose certificates menu. + """Choose a certificate from a menu. :param list certs: List of cert dicts. - :returns: cert to revoke - :rtype: :class:`letsencrypt.client.revoker.Cert` + :returns: selection (zero-based index) + :rtype: int """ - code, tag = display_certs(certs) + while True: + code, selection = _display_certs(certs) - if code == display_util.OK: - cert = certs[tag] - if confirm_revocation(cert): - return cert + if code == display_util.OK: + if confirm_revocation(certs[selection]): + return selection + elif code == display_util.HELP: + more_info_cert(certs[selection]) else: - choose_certs(certs) - elif code == display_util.HELP: - cert = certs[tag] - more_info_cert(cert) - choose_certs(certs) - else: - exit(0) + exit(0) -def display_certs(certs): +def _display_certs(certs): """Display the certificates in a menu for revocation. :param list certs: each is a :class:`letsencrypt.client.revoker.Cert` @@ -53,15 +49,12 @@ def display_certs(certs): else "") for cert in certs ] - print list_choices code, tag = util(interfaces.IDisplay).menu( "Which certificates would you like to revoke?", list_choices, help_label="More Info", ok_label="Revoke", cancel_label="Exit") - if not tag: - tag = -1 - return code, (int(tag) - 1) + return code, tag def confirm_revocation(cert): @@ -78,7 +71,7 @@ def confirm_revocation(cert): "certificate:{0}".format(os.linesep)) text += cert.pretty_print() text += "This action cannot be reversed!" - return display_util.OK == util(interfaces.IDisplay).yesno(text) + return util(interfaces.IDisplay).yesno(text) def more_info_cert(cert): diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index d0df0fa12..0b5e7c7d6 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -93,7 +93,10 @@ class NcursesDisplay(object): help_button=help_button, help_label=help_label, width=self.width, height=self.height) - return code, int(tag) - 1 + if code == OK: + return code, int(tag) - 1 + + return code, -1 def input(self, message): """Display an input box to the user. diff --git a/letsencrypt/client/log.py b/letsencrypt/client/log.py index 90d923f76..a267fa77e 100644 --- a/letsencrypt/client/log.py +++ b/letsencrypt/client/log.py @@ -3,7 +3,7 @@ import logging import dialog -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class DialogHandler(logging.Handler): # pylint: disable=too-few-public-methods diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index a6ae4323d..75ff8b9f6 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -9,7 +9,7 @@ import zope.component from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class Reverter(object): diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index b9c72cc07..76898e533 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -22,7 +22,7 @@ from letsencrypt.client import errors from letsencrypt.client import le_util from letsencrypt.client import network -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util from letsencrypt.client.display import revocation @@ -47,33 +47,12 @@ class Revoker(object): self.config = config self.no_confirm = no_confirm - le_util.make_or_verify_dir(config.cert_key_backup, 0o700) + le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid()) # TODO: Find a better solution for this... self.list_path = os.path.join(config.cert_key_backup, "LIST") - - def safe_revoke(self, certs): - """Confirm and revoke certificates. - - :param certs: certs intended to be revoked - :type certs: :class:`letsencrypt.client.revoker.Cert` - - """ - success_list = [] - try: - for cert in certs: - if self.no_confirm or revocation.confirm_revocation(cert): - revoc = self._acme_revoke( - cert.backup_path, cert.backup_key_path) - - if revoc is not None: - success_list.append(cert) - revocation.success_revocation(cert) - else: - # TODO: Display a nice explanation - pass - finally: - self._remove_certs_keys(success_list) + # Make sure that the file is available for use for rest of class + open(self.list_path, "a").close() def revoke_from_key(self, authkey): """Revoke all certificates under an authorized key. @@ -83,7 +62,7 @@ class Revoker(object): """ certs = [] - with open(self.list_path, "r") as csvfile: + with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) for row in csvreader: # idx, cert, key @@ -94,9 +73,10 @@ class Revoker(object): # certificate. _, b_k = self._row_to_backup(row) if authkey.pem == open(b_k).read(): - certs.append(Cert.fromrow(row)) + certs.append(Cert.fromrow(row, self.config.cert_key_backup)) - self.safe_revoke(certs) + if certs: + self._safe_revoke(certs) def revoke_from_cert(self, cert_path): """Revoke a certificate by specifying a file path. @@ -107,59 +87,36 @@ class Revoker(object): # Locate the correct certificate (do not rely on filename) cert_to_revoke = Cert(cert_path) - with open(self.list_path, "r") as csvfile: + with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) for row in csvreader: - cert = Cert.fromrow(row) + cert = Cert.fromrow(row, self.config.cert_key_backup) - # This uses md5 but it doesn't matter and it is easier to read - if cert.get_fingerprint() == cert_to_revoke.get_fingerprint(): - self.safe_revoke([cert]) + if cert == cert_to_revoke: + self._safe_revoke([cert]) - def _acme_revoke(self, cert_path, key_path): - """Revoke the certificate with the ACME server. - - :param str cert_path: path to certificate file - :param str key_path: path to associated private key or authorized key - - :returns: TODO - - """ - try: - cert_der = M2Crypto.X509.load_cert(cert_path).as_der() - with open(key_path, "rU") as backup_key_file: - key = backup_key_file.read() - - # If either of the files don't exist... or are corrupted - except (OSError, IOError, M2Crypto.X509.X509Error): - return None - - # TODO: Catch error associated with already revoked and proceed. - return self.network.send_and_receive_expected( - messages.RevocationRequest.create( - certificate=certificate, key=key), - messages.Revocation) - - def display_menu(self): + def revoke_from_menu(self): """List trusted Let's Encrypt certificates.""" - if not os.path.isfile(self.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) - if certs: - cert = revocation.choose_certs(certs) - self.revoke_from_interface(cert) - # Recursive... - self.display_menu() - else: - logging.info( - "There are not any trusted Let's Encrypt " - "certificates for this server.") + while True: + if certs: + selection = revocation.choose_certs(certs) + + self._safe_revoke([certs[selection]]) + # This is safer than using remove as Revoker.Certs only check + # the DER value of the cert. There could potentially be multiple + # backup certs with the same value. + del certs[selection] + else: + logging.info( + "There are not any trusted Let's Encrypt " + "certificates for this server.") + return + + def _populate_saved_certs(self, csha1_vhlist): # pylint: disable=no-self-use @@ -177,7 +134,7 @@ class Revoker(object): csvreader = csv.reader(csvfile) # idx, orig_cert, orig_key for row in csvreader: - cert = Cert.fromrow(row) + cert = Cert.fromrow(row, self.config.cert_key_backup) # If we were able to find the cert installed... update status cert.installed = csha1_vhlist.get(cert.get_fingerprint(), []) @@ -189,8 +146,8 @@ class Revoker(object): def _get_installed_locations(self): """Get installed locations of certificates - :returns: cert sha1 fingerprint -> :class:`list` of vhosts where - the certificate is installed. + :returns: map from cert sha1 fingerprint to :class:`list` of vhosts + where the certificate is installed. """ csha1_vhlist = {} @@ -211,10 +168,58 @@ class Revoker(object): return csha1_vhlist + def _safe_revoke(self, certs): + """Confirm and revoke certificates. + + :param certs: certs intended to be revoked + :type certs: :class:`list` of :class:`letsencrypt.client.revoker.Cert` + + """ + success_list = [] + try: + for cert in certs: + if self.no_confirm or revocation.confirm_revocation(cert): + try: + self._acme_revoke(cert) + + success_list.append(cert) + revocation.success_revocation(cert) + except errors.LetsEncryptClientError: + # TODO: Improve error handling when networking is set... + logging.error( + "Unable to revoke cert:%s%s", os.linesep, str(cert)) + finally: + if success_list: + self._remove_certs_keys(success_list) + + def _acme_revoke(self, cert): + """Revoke the certificate with the ACME server. + + :param cert: certificate to revoke + :type cert: :class:`letsencrypt.client.revoker.Cert` + + :returns: TODO + + """ + try: + certificate = acme_util.ComparableX509(cert.cert) + with open(cert.backup_key_path, "rU") as backup_key_file: + key = Crypto.PublicKey.RSA.importKey(backup_key_file.read()) + + # If the key file doesn't exist... or is corrupted + except (OSError, IOError): + raise errors.LetsEncryptRevokerError("Unable to read key file") + + # TODO: Catch error associated with already revoked and proceed. + return self.network.send_and_receive_expected( + messages.RevocationRequest.create( + certificate=certificate, key=key), + messages.Revocation) + def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use """Remove certificate and key. - :param list cert_list: each is of type + :param list cert_list: Must contain certs, each is of type :class:`letsencrypt.client.revoker.Cert` """ @@ -229,26 +234,29 @@ class Revoker(object): def _remove_certs_from_list(self, cert_list): # pylint: disable=no-self-use """Remove a certificate from the LIST file. - :param list cert_list: each is of type + :param list cert_list: Must contain valid certs, each is of type :class:`letsencrypt.client.revoker.Cert` """ list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") idx = 0 + with open(self.list_path, "rb") as orgfile: csvreader = csv.reader(orgfile) with open(list_path2, "wb") as newfile: csvwriter = csv.writer(newfile) for row in csvreader: - if row != cert_list[idx].get_row(): + if idx >= len(cert_list) or row != cert_list[idx].get_row(): csvwriter.writerow(row) else: idx += 1 + # This should never happen... if idx != len(cert_list): - errors.LetsEncryptRevokerError("Did not find all cert_list items") + raise errors.LetsEncryptRevokerError( + "Did not find all cert_list items to remove from LIST") shutil.copy2(list_path2, self.list_path) os.remove(list_path2) @@ -266,35 +274,22 @@ class Revoker(object): self._get_backup(self.config.cert_key_backup, row[0], row[2])) @classmethod - def store_cert_key(cls, cert_path, key_path, config, encrypt=False): + def store_cert_key(cls, cert_path, key_path, config): """Store certificate key. (Used to allow quick revocation) :param str cert_path: Path to a certificate file. - :param key_path: Authorized key for certificate - :type key_path: :class:`letsencrypt.client.le_util.Key` + :param str key_path: Path to authorized key for certificate + :ivar config: Configuration. :type config: :class:`~letsencrypt.client.interfaces.IConfig` - :param bool encrypt: Should the certificate key be encrypted? - - :returns: True if key file was stored successfully, False otherwise. - :rtype: bool - """ - list_path = (config.cert_key_backup, "LIST") - le_util.make_or_verify_dir(config.cert_key_backup, 0o700) - - if encrypt: - logging.error( - "Unfortunately securely storing the certificates/" - "keys is not yet available. Stay tuned for the " - "next update!") - return False + list_path = os.path.join(config.cert_key_backup, "LIST") + le_util.make_or_verify_dir(config.cert_key_backup, 0o700, os.geteuid()) cls._catalog_files( config.cert_key_backup, cert_path, key_path, list_path) - return True @classmethod def _catalog_files(cls, backup_dir, cert_path, key_path, list_path): @@ -319,11 +314,13 @@ class Revoker(object): @classmethod def _copy_files(cls, backup_dir, idx, cert_path, key_path): + """Copies the files into the backup dir appropriately.""" shutil.copy2(cert_path, cls._get_backup(backup_dir, idx, cert_path)) shutil.copy2(key_path, cls._get_backup(backup_dir, idx, key_path)) @classmethod def _get_backup(cls, backup_dir, idx, orig_path): + """Returns the path to the backup.""" return os.path.join( backup_dir, "{name}_{idx}".format( name=os.path.basename(orig_path), idx=str(idx))) @@ -336,9 +333,9 @@ class Cert(object): :type cert: :class:`M2Crypto.X509` :ivar int idx: convenience index used for listing - :ivar orig: (`str` original certificate filepath, `str` status) - :type orig: PathStatus - :ivar orig_key: named tuple with(`str` original auth key path, `str` status) + :ivar orig: (`str` path - original certificate, `str` status) + :type orig: :class:`PathStatus` + :ivar orig_key: (`str` path - original auth key, `str` status) :type orig_key: :class:`PathStatus` :ivar str backup_path: backup filepath of the certificate :ivar str backup_key_path: backup filepath of the authorized key @@ -350,6 +347,9 @@ class Cert(object): PathStatus = collections.namedtuple("PathStatus", "path status") """Convenience container to hold path and status info""" + DELETED_MSG = "This file has been moved or deleted" + CHANGED_MSG = "This file has changed" + def __init__(self, cert_path): """Cert initialization @@ -373,7 +373,7 @@ class Cert(object): self.installed = ["Unknown"] @classmethod - def fromrow(cls, row, backup_dir): + def fromrow(cls, row, backup_dir): # pylint: disable=protected-access """Initialize Cert from a csv row.""" idx = int(row[0]) backup = Revoker._get_backup(backup_dir, idx, row[1]) @@ -381,10 +381,13 @@ class Cert(object): obj = cls(backup) obj.add_meta(idx, row[1], row[2], backup, backup_key) + return obj def get_row(self): - """Returns a list in CSV format.""" - return [str(self.idx), self.orig, self.orig_key] + """Returns a list in CSV format. If meta data is available.""" + if self.orig is not None and self.orig_key is not None: + return [str(self.idx), self.orig.path, self.orig_key.path] + return None def add_meta(self, idx, orig, orig_key, backup, backup_key): """Add meta data to cert @@ -396,29 +399,27 @@ class Cert(object): :param str backup_key: backup key filepath """ - deleted_msg = "This file has been moved or deleted" - changed_msg = "This file has changed" status = "" key_status = "" # Verify original cert path if not os.path.isfile(orig): - status = deleted_msg + status = Cert.DELETED_MSG else: o_cert = M2Crypto.X509.load_cert(orig) if self.get_fingerprint() != o_cert.get_fingerprint(md="sha1"): - status = changed_msg + status = Cert.CHANGED_MSG # Verify original key path if not os.path.isfile(orig_key): - key_status = deleted_msg + key_status = Cert.DELETED_MSG else: with open(orig_key, "r") as fd: key_pem = fd.read() with open(backup_key, "r") as fd: backup_key_pem = fd.read() if key_pem != backup_key_pem: - key_status = changed_msg + key_status = Cert.CHANGED_MSG self.idx = idx self.orig = Cert.PathStatus(orig, status) @@ -488,4 +489,5 @@ class Cert(object): frame = "-" * (display_util.WIDTH - 4) + os.linesep return "{frame}{cert}{frame}".format(frame=frame, cert=str(self)) - + def __eq__(self, other): + return self.cert.as_der() == other.cert.as_der() \ No newline at end of file diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py index 3f5cc36ae..f30927886 100644 --- a/letsencrypt/client/tests/apache/parser_test.py +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -9,7 +9,7 @@ import mock import zope.component from letsencrypt.client import errors -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util from letsencrypt.client.tests.apache import util diff --git a/letsencrypt/client/tests/configuration_test.py b/letsencrypt/client/tests/configuration_test.py index a07953396..dde1f44cb 100644 --- a/letsencrypt/client/tests/configuration_test.py +++ b/letsencrypt/client/tests/configuration_test.py @@ -9,7 +9,8 @@ class NamespaceConfigTest(unittest.TestCase): def setUp(self): from letsencrypt.client.configuration import NamespaceConfig - namespace = mock.MagicMock(work_dir='/tmp/foo', foo='bar') + namespace = mock.MagicMock( + work_dir='/tmp/foo', foo='bar', server='acme-server.org:443') self.config = NamespaceConfig(namespace) def test_proxy_getattr(self): @@ -24,7 +25,8 @@ class NamespaceConfigTest(unittest.TestCase): constants.REC_TOKEN_DIR = '/r' self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') - self.assertEqual(self.config.cert_key_backup, '/tmp/foo/c/') + self.assertEqual( + self.config.cert_key_backup, '/tmp/foo/c/acme-server.org') self.assertEqual(self.config.rec_token_dir, '/r') diff --git a/letsencrypt/client/tests/display/enhancements_test.py b/letsencrypt/client/tests/display/enhancements_test.py index dfdac52e2..a7fb7f246 100644 --- a/letsencrypt/client/tests/display/enhancements_test.py +++ b/letsencrypt/client/tests/display/enhancements_test.py @@ -5,7 +5,7 @@ import unittest import mock from letsencrypt.client import errors -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class AskTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 10d8d9642..7b4a6d8b5 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -5,7 +5,7 @@ import unittest import mock import zope.component -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class ChooseAuthenticatorTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index c78a726e1..359e80c5e 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -6,19 +6,19 @@ import unittest import mock import zope.component -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class ChooseCertsTest(unittest.TestCase): def setUp(self): from letsencrypt.client.revoker import Cert base_package = "letsencrypt.client.tests" - self.cert1 = Cert(pkg_resources.resource_filename( + self.cert0 = Cert(pkg_resources.resource_filename( base_package, os.path.join("testdata", "cert.pem"))) - self.cert2 = Cert(pkg_resources.resource_filename( + self.cert1 = Cert(pkg_resources.resource_filename( base_package, os.path.join("testdata", "cert-san.pem"))) - self.certs = [self.cert1, self.cert2] + self.certs = [self.cert0, self.cert1] zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) @@ -27,13 +27,56 @@ class ChooseCertsTest(unittest.TestCase): from letsencrypt.client.display.revocation import choose_certs return choose_certs(certs) - #@mock.patch("letsencrypt.client.display.revocation.util") - def test_confirm_revocation(self): - pass - #mock_util().yesno.return_value = True - self._call(self.certs) + @mock.patch("letsencrypt.client.display.revocation.util") + def test_confirm_revocation(self, mock_util): + mock_util().yesno.return_value = True + mock_util().menu.return_value = (display_util.OK, 0) + choice = self._call(self.certs) + self.assertTrue(self.certs[choice] == self.cert0) + + @mock.patch("letsencrypt.client.display.revocation.util") + def test_confirm_cancel(self, mock_util): + mock_util().yesno.return_value = False + mock_util().menu.side_effect = [ + (display_util.OK, 0), + (display_util.CANCEL, -1) + ] + + self.assertRaises(SystemExit, self._call, self.certs) + + @mock.patch("letsencrypt.client.display.revocation.util") + def test_more_info(self, mock_util): + mock_util().menu.side_effect = [ + (display_util.HELP, 1), + (display_util.OK, 1), + ] + mock_util().yesno.return_value = True + + choice = self._call(self.certs) + + self.assertTrue(self.certs[choice] == self.cert1) + self.assertEqual(mock_util().notification.call_count, 1) + +class SuccessRevocationTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.revoker import Cert + base_package = "letsencrypt.client.tests" + self.cert = Cert(pkg_resources.resource_filename( + base_package, os.path.join("testdata", "cert.pem"))) + + @classmethod + def _call(cls, cert): + from letsencrypt.client.display.revocation import success_revocation + success_revocation(cert) + + # Pretty trivial test... something is displayed... + @mock.patch("letsencrypt.client.display.revocation.util") + def test_success_revocation(self, mock_util): + self._call(self.cert) + + self.assertEqual(mock_util().notification.call_count, 1) if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 3c81a463f..3a0c78e94 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -4,7 +4,7 @@ import unittest import mock -from letsencrypt.client.display import display_util +from letsencrypt.client.display import util as display_util class DisplayT(unittest.TestCase): @@ -42,13 +42,13 @@ class NcursesDisplayTest(DisplayT): super(NcursesDisplayTest, self).setUp() self.displayer = display_util.NcursesDisplay() - @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.msgbox") + @mock.patch("letsencrypt.client.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") + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") def test_menu_tag_and_desc(self, mock_menu): mock_menu.return_value = (display_util.OK, "First") @@ -61,7 +61,7 @@ class NcursesDisplayTest(DisplayT): self.assertEqual(ret, (display_util.OK, 0)) - @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.menu") + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") def test_menu_tag_and_desc_cancel(self, mock_menu): mock_menu.return_value = (display_util.CANCEL, "") @@ -76,7 +76,7 @@ class NcursesDisplayTest(DisplayT): self.assertEqual(ret, (display_util.CANCEL, -1)) - @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.menu") + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") def test_menu_desc_only(self, mock_menu): mock_menu.return_value = (display_util.OK, "1") @@ -91,13 +91,21 @@ class NcursesDisplayTest(DisplayT): self.assertEqual(ret, (display_util.OK, 0)) - @mock.patch("letsencrypt.client.display.display_util." + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") + def test_menu_desc_only_cancel(self, mock_menu): + mock_menu.return_value = (display_util.CANCEL, "") + + ret = self.displayer.menu("Message", self.tags, help_label="More Info") + + self.assertEqual(ret, (display_util.CANCEL, -1)) + + @mock.patch("letsencrypt.client.display.util." "dialog.Dialog.inputbox") def test_input(self, mock_input): self.displayer.input("message") mock_input.assert_called_with("message") - @mock.patch("letsencrypt.client.display.display_util.dialog.Dialog.yesno") + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.yesno") def test_yesno(self, mock_yesno): mock_yesno.return_value = display_util.OK @@ -107,7 +115,7 @@ class NcursesDisplayTest(DisplayT): "message", display_util.HEIGHT, display_util.WIDTH, yes_label="Yes", no_label="No") - @mock.patch("letsencrypt.client.display.display_util." + @mock.patch("letsencrypt.client.display.util." "dialog.Dialog.checklist") def test_checklist(self, mock_checklist): self.displayer.checklist("message", self.tags) @@ -149,7 +157,7 @@ class FileOutputDisplayTest(DisplayT): self.assertTrue("message" in self.mock_stdout.write.call_args[0][0]) - @mock.patch("letsencrypt.client.display.display_util." + @mock.patch("letsencrypt.client.display.util." "FileDisplay._get_valid_int_ans") def test_menu(self, mock_ans): mock_ans.return_value = (display_util.OK, 1) @@ -179,14 +187,14 @@ class FileOutputDisplayTest(DisplayT): with mock.patch("__builtin__.raw_input", return_value="a"): self.assertTrue(self.displayer.yesno("msg", yes_label="Agree")) - @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + @mock.patch("letsencrypt.client.display.util.FileDisplay.input") def test_checklist_valid(self, mock_input): mock_input.return_value = (display_util.OK, "2 1") code, tag_list = self.displayer.checklist("msg", self.tags) self.assertEqual( (code, set(tag_list)), (display_util.OK, set(["tag1", "tag2"]))) - @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + @mock.patch("letsencrypt.client.display.util.FileDisplay.input") def test_checklist_miss_valid(self, mock_input): mock_input.side_effect = [ (display_util.OK, "10"), @@ -197,7 +205,7 @@ class FileOutputDisplayTest(DisplayT): ret = self.displayer.checklist("msg", self.tags) self.assertEqual(ret, (display_util.OK, ["tag1"])) - @mock.patch("letsencrypt.client.display.display_util.FileDisplay.input") + @mock.patch("letsencrypt.client.display.util.FileDisplay.input") def test_checklist_miss_quit(self, mock_input): mock_input.side_effect = [ (display_util.OK, "10"), @@ -288,7 +296,7 @@ class SeparateListInputTest(unittest.TestCase): @classmethod def _call(cls, input_): - from letsencrypt.client.display.display_util import separate_list_input + from letsencrypt.client.display.util import separate_list_input return separate_list_input(input_) def test_commas(self): @@ -314,7 +322,7 @@ class SeparateListInputTest(unittest.TestCase): class PlaceParensTest(unittest.TestCase): @classmethod def _call(cls, label): # pylint: disable=protected-access - from letsencrypt.client.display.display_util import _parens_around_char + from letsencrypt.client.display.util import _parens_around_char return _parens_around_char(label) def test_single_letter(self): diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index b62b9e1b7..47b057220 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -1,6 +1,326 @@ +import csv +import os +import pkg_resources +import shutil +import tempfile import unittest -import package + +import mock + +from letsencrypt.client import errors +from letsencrypt.client import le_util + + +class RevokerBase(unittest.TestCase): + def setUp(self): + self.paths, self.certs, self.key_path = create_revoker_certs() + + self.backup_dir = tempfile.mkdtemp("cert_backup") + self.mock_config = mock.MagicMock(cert_key_backup=self.backup_dir) + + self.list_path = os.path.join(self.backup_dir, "LIST") + + def _store_certs(self): + from letsencrypt.client.revoker import Revoker + Revoker.store_cert_key(self.paths[0], self.key_path, self.mock_config) + Revoker.store_cert_key(self.paths[1], self.key_path, self.mock_config) + + # Set metadata + for i in xrange(2): + self.certs[i].add_meta( + i, self.paths[i], self.key_path, + Revoker._get_backup(self.backup_dir, i, self.paths[i]), + Revoker._get_backup(self.backup_dir, i, self.key_path)) + + def _get_rows(self): + with open(self.list_path, "rb") as csvfile: + return [row for row in csv.reader(csvfile)] + + def _write_rows(self, rows): + with open(self.list_path, "wb") as csvfile: + csvwriter = csv.writer(csvfile) + for row in rows: + csvwriter.writerow(row) + + +class RevokerTest(RevokerBase): + def setUp(self): + from letsencrypt.client.revoker import Revoker + super(RevokerTest, self).setUp() + + with open(self.key_path) as key_file: + self.key = le_util.Key(self.key_path, key_file.read()) + + self._store_certs() + + self.mock_installer = mock.MagicMock() + self.revoker = Revoker(self.mock_installer, self.mock_config) + + def tearDown(self): + shutil.rmtree(self.backup_dir) + + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_key_all(self, mock_display, mock_net): + mock_display().confirm_revocation.return_value = True + + self.revoker.revoke_from_key(self.key) + self.assertEqual(self._get_rows(), []) + + # Check to make sure backups were eliminated + for i in xrange(2): + self.assertFalse(self._backups_exist(self.certs[i].get_row())) + + self.assertEqual(mock_net.call_count, 2) + + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_cert(self, mock_display, mock_net): + mock_display().confirm_revocation.return_value = True + + self.revoker.revoke_from_cert(self.paths[1]) + + row0 = self.certs[0].get_row() + row1 = self.certs[1].get_row() + + self.assertEqual(self._get_rows(), [row0]) + + self.assertTrue(self._backups_exist(row0)) + self.assertFalse(self._backups_exist(row1)) + + self.assertEqual(mock_net.call_count, 1) + + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_menu(self, mock_display, mock_net): + mock_display().confirm_revocation.return_value = True + mock_display.choose_certs.side_effect = [0, SystemExit] + + self.assertRaises(SystemExit, self.revoker.revoke_from_menu) + + row0 = self.certs[0].get_row() + row1 = self.certs[1].get_row() + + self.assertEqual(self._get_rows(), [row1]) + + self.assertFalse(self._backups_exist(row0)) + self.assertTrue(self._backups_exist(row1)) + + self.assertEqual(mock_net.call_count, 1) + + @mock.patch("letsencrypt.client.revoker.logging") + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log): + mock_display().confirm_revocation.return_value = True + mock_display.choose_certs.return_value = 0 + + self.revoker.revoke_from_menu() + + self.assertEqual(self._get_rows(), []) + + # Everything should be deleted... + for i in xrange(2): + self.assertFalse(self._backups_exist(self.certs[i].get_row())) + + self.assertEqual(mock_net.call_count, 2) + # Info is called when there aren't any certs left... + self.assertTrue(mock_log.info.called) + + @mock.patch("letsencrypt.client.revoker.revocation") + @mock.patch("letsencrypt.client.revoker.Revoker._acme_revoke") + @mock.patch("letsencrypt.client.revoker.logging") + def test_safe_revoke_acme_fail(self, mock_log, mock_revoke, mock_display): + mock_revoke.side_effect = errors.LetsEncryptClientError + mock_display().confirm_revocation.return_value = True + + self.revoker._safe_revoke(self.certs) + self.assertTrue(mock_log.error.called) + + @mock.patch("letsencrypt.client.revoker.Crypto.PublicKey.RSA.importKey") + def test_acme_revoke_failure(self, mock_crypto): + mock_crypto.side_effect = IOError + self.assertRaises(errors.LetsEncryptClientError, + self.revoker._acme_revoke, + self.certs[0]) + + def test_remove_certs_from_list_bad_certs(self): + from letsencrypt.client.revoker import Cert + + new_cert = Cert(self.paths[0]) + + # This isn't stored in the db + new_cert.idx = 10 + new_cert.backup_path = self.paths[0] + new_cert.backup_key_path = self.key_path + new_cert.orig = Cert.PathStatus("false path", "not here") + new_cert.orig_key = Cert.PathStatus("false path", "not here") + + self.assertRaises(errors.LetsEncryptRevokerError, + self.revoker._remove_certs_from_list, + [new_cert]) + + def _backups_exist(self, row): + cert_path, key_path = self.revoker._row_to_backup(row) + return os.path.isfile(cert_path) and os.path.isfile(key_path) + + +class RevokerInstallerTest(RevokerBase): + def setUp(self): + super(RevokerInstallerTest, self).setUp() + + self.installs = [ + ["installation/path0a", "installation/path0b"], + ["installation/path1"], + ] + + self.certs_keys = [ + (self.paths[0], self.key_path, self.installs[0][0]), + (self.paths[0], self.key_path, self.installs[0][1]), + (self.paths[1], self.key_path, self.installs[1][0]), + ] + + self._store_certs() + + def _get_revoker(self, installer): + from letsencrypt.client.revoker import Revoker + return Revoker(installer, self.mock_config) + + def test_no_installer_get_installed_locations(self): + revoker = self._get_revoker(None) + self.assertEqual(revoker._get_installed_locations(), {}) + + def test_get_installed_locations(self): + mock_installer = mock.MagicMock() + mock_installer.get_all_certs_keys.return_value = self.certs_keys + + revoker = self._get_revoker(mock_installer) + sha_vh = revoker._get_installed_locations() + + self.assertEqual(len(sha_vh), 2) + for i, cert in enumerate(self.certs): + self.assertTrue(cert.get_fingerprint() in sha_vh) + self.assertEqual( + sha_vh[cert.get_fingerprint()], self.installs[i]) + + @mock.patch("letsencrypt.client.revoker.M2Crypto.X509.load_cert") + def test_get_installed_load_failure(self, mock_m2): + mock_installer = mock.MagicMock() + mock_installer.get_all_certs_keys.return_value = self.certs_keys + + mock_m2.side_effect = IOError + + revoker = self._get_revoker(mock_installer) + + self.assertEqual(revoker._get_installed_locations(), {}) + +class RevokerClassMethodsTest(RevokerBase): + def setUp(self): + super(RevokerClassMethodsTest, self).setUp() + self.mock_config = mock.MagicMock(cert_key_backup=self.backup_dir) + + def tearDown(self): + shutil.rmtree(self.backup_dir) + + def _call(self, cert_path, key_path): + from letsencrypt.client.revoker import Revoker + Revoker.store_cert_key(cert_path, key_path, self.mock_config) + + def test_store_two(self): + from letsencrypt.client.revoker import Revoker + self._call(self.paths[0], self.key_path) + self._call(self.paths[1], self.key_path) + + self.assertTrue(os.path.isfile(self.list_path)) + rows = self._get_rows() + i = 0 + for i, row in enumerate(rows): + self.assertTrue(os.path.isfile( + Revoker._get_backup(self.backup_dir, i, self.paths[i]))) + self.assertTrue(os.path.isfile( + Revoker._get_backup(self.backup_dir, i, self.key_path))) + self.assertEqual([str(i), self.paths[i], self.key_path], row) + + self.assertEqual(i, 1) + + def test_store_one_mixed(self): + from letsencrypt.client.revoker import Revoker + self._write_rows( + [["5", "blank", "blank"], ["18", "dc", "dc"], ["21", "b", "b"]]) + self._call(self.paths[0], self.key_path) + + self.assertEqual( + self._get_rows()[3], ["22", self.paths[0], self.key_path]) + + self.assertTrue(os.path.isfile( + Revoker._get_backup(self.backup_dir, 22, self.paths[0]))) + self.assertTrue(os.path.isfile( + Revoker._get_backup(self.backup_dir, 22, self.key_path))) class CertTest(unittest.TestCase): def setUp(self): + self.paths, self.certs, self.key_path = create_revoker_certs() + + def test_failed_load(self): + from letsencrypt.client.revoker import Cert + self.assertRaises(errors.LetsEncryptRevokerError, Cert, self.key_path) + + def test_no_row(self): + self.assertEqual(self.certs[0].get_row(), None) + + def test_meta_moved_files(self): + from letsencrypt.client.revoker import Cert + fake_path = "/not/a/real/path/r72d3t6" + self.certs[0].add_meta( + 0, fake_path, fake_path, self.paths[0], self.key_path) + + self.assertEqual(self.certs[0].orig.status, Cert.DELETED_MSG) + self.assertEqual(self.certs[0].orig_key.status, Cert.DELETED_MSG) + + def test_meta_changed_files(self): + from letsencrypt.client.revoker import Cert + self.certs[0].add_meta( + 0, self.paths[1], self.paths[1], self.paths[0], self.key_path) + + self.assertEqual(self.certs[0].orig.status, Cert.CHANGED_MSG) + self.assertEqual(self.certs[0].orig_key.status, Cert.CHANGED_MSG) + + def test_meta_no_status(self): + self.certs[0].add_meta( + 0, self.paths[0], self.key_path, self.paths[0], self.key_path) + + self.assertEqual(self.certs[0].orig.status, "") + self.assertEqual(self.certs[0].orig_key.status, "") + + def test_print(self): + """Just make sure there aren't any errors.""" + self.assertTrue(self.certs[0].pretty_print()) + self.assertTrue(self.certs[1].pretty_print()) + +def create_revoker_certs(): + from letsencrypt.client.revoker import Cert + + base_package = "letsencrypt.client.tests" + + cert0_path = pkg_resources.resource_filename( + base_package, os.path.join("testdata", "cert.pem")) + + cert1_path = pkg_resources.resource_filename( + base_package, os.path.join("testdata", "cert-san.pem")) + + cert0 = Cert(cert0_path) + cert1 = Cert(cert1_path) + + key_path = pkg_resources.resource_filename( + base_package, os.path.join("testdata", "rsa512_key.pem")) + + return [cert0_path, cert1_path], [cert0, cert1], key_path + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 2e3922b32..8e2589f15 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -20,7 +20,7 @@ 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 util as display_util from letsencrypt.client.display import ops diff --git a/tox.ini b/tox.ini index 7a5f3810d..d4af50fa5 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=73 + python setup.py nosetests --with-coverage --cover-min-percentage=83 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From b4c327be38e015a6081ab264ce6124f166e65904 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 18 Feb 2015 04:03:32 -0800 Subject: [PATCH 18/46] pylint fixes --- letsencrypt/client/client.py | 2 +- letsencrypt/client/crypto_util.py | 5 ----- letsencrypt/client/revoker.py | 5 +++-- .../client/tests/display/revocation_test.py | 1 + letsencrypt/client/tests/display/util_test.py | 2 +- letsencrypt/client/tests/revoker_test.py | 21 +++++++++++++++---- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a7e775bcf..01969b748 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -473,7 +473,7 @@ def revoke(config, no_confirm, cert, authkey): elif authkey is not None: revoc.revoke_from_key(le_util.Key(authkey[0], authkey[1])) else: - revoc.display_menu() + revoc.revoke_from_menu() def view_config_changes(config): diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index e49173f56..c9bae885e 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -162,8 +162,3 @@ def make_ss_cert(key_str, domains, not_before=None, # print check_purpose(,0 return m2_cert.as_pem() - -def b64_cert_to_pem(b64_der_cert): - """Convert JOSE Base-64 encoded DER cert to PEM.""" - return M2Crypto.X509.load_cert_der_string( - le_util.jose_b64decode(b64_der_cert)).as_pem() diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 76898e533..77f262e6f 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -373,7 +373,8 @@ class Cert(object): self.installed = ["Unknown"] @classmethod - def fromrow(cls, row, backup_dir): # pylint: disable=protected-access + def fromrow(cls, row, backup_dir): + # pylint: disable=protected-access """Initialize Cert from a csv row.""" idx = int(row[0]) backup = Revoker._get_backup(backup_dir, idx, row[1]) @@ -490,4 +491,4 @@ class Cert(object): return "{frame}{cert}{frame}".format(frame=frame, cert=str(self)) def __eq__(self, other): - return self.cert.as_der() == other.cert.as_der() \ No newline at end of file + return self.cert.as_der() == other.cert.as_der() diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index 359e80c5e..4e8591272 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -1,3 +1,4 @@ +"""Test :mod:`letsencrypt.client.display.revocation`.""" import os import pkg_resources import sys diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 3a0c78e94..b355f0fc0 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -1,4 +1,4 @@ -"""Test the display utility.""" +"""Test :mod:`letsencrypt.client.display.util`.""" import os import unittest diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index 47b057220..a347970c9 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -1,3 +1,4 @@ +"""Test :mod:`letsencrypt.client.revoker`.""" import csv import os import pkg_resources @@ -11,7 +12,8 @@ from letsencrypt.client import errors from letsencrypt.client import le_util -class RevokerBase(unittest.TestCase): +class RevokerBase(unittest.TestCase): # pylint: disable=too-few-public-methods + """Base Class for Revoker Tests.""" def setUp(self): self.paths, self.certs, self.key_path = create_revoker_certs() @@ -21,6 +23,7 @@ class RevokerBase(unittest.TestCase): self.list_path = os.path.join(self.backup_dir, "LIST") def _store_certs(self): + # pylint: disable=protected-access from letsencrypt.client.revoker import Revoker Revoker.store_cert_key(self.paths[0], self.key_path, self.mock_config) Revoker.store_cert_key(self.paths[1], self.key_path, self.mock_config) @@ -135,6 +138,7 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.client.revoker.Revoker._acme_revoke") @mock.patch("letsencrypt.client.revoker.logging") def test_safe_revoke_acme_fail(self, mock_log, mock_revoke, mock_display): + # pylint: disable=protected-access mock_revoke.side_effect = errors.LetsEncryptClientError mock_display().confirm_revocation.return_value = True @@ -143,12 +147,14 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.client.revoker.Crypto.PublicKey.RSA.importKey") def test_acme_revoke_failure(self, mock_crypto): + # pylint: disable=protected-access mock_crypto.side_effect = IOError self.assertRaises(errors.LetsEncryptClientError, self.revoker._acme_revoke, self.certs[0]) def test_remove_certs_from_list_bad_certs(self): + # pylint: disable=protected-access from letsencrypt.client.revoker import Cert new_cert = Cert(self.paths[0]) @@ -165,6 +171,7 @@ class RevokerTest(RevokerBase): [new_cert]) def _backups_exist(self, row): + # pylint: disable=protected-access cert_path, key_path = self.revoker._row_to_backup(row) return os.path.isfile(cert_path) and os.path.isfile(key_path) @@ -191,10 +198,12 @@ class RevokerInstallerTest(RevokerBase): return Revoker(installer, self.mock_config) def test_no_installer_get_installed_locations(self): + # pylint: disable=protected-access revoker = self._get_revoker(None) self.assertEqual(revoker._get_installed_locations(), {}) def test_get_installed_locations(self): + # pylint: disable=protected-access mock_installer = mock.MagicMock() mock_installer.get_all_certs_keys.return_value = self.certs_keys @@ -216,6 +225,7 @@ class RevokerInstallerTest(RevokerBase): revoker = self._get_revoker(mock_installer) + # pylint: disable=protected-access self.assertEqual(revoker._get_installed_locations(), {}) class RevokerClassMethodsTest(RevokerBase): @@ -239,6 +249,7 @@ class RevokerClassMethodsTest(RevokerBase): rows = self._get_rows() i = 0 for i, row in enumerate(rows): + # pylint: disable=protected-access self.assertTrue(os.path.isfile( Revoker._get_backup(self.backup_dir, i, self.paths[i]))) self.assertTrue(os.path.isfile( @@ -256,10 +267,11 @@ class RevokerClassMethodsTest(RevokerBase): self.assertEqual( self._get_rows()[3], ["22", self.paths[0], self.key_path]) + # pylint: disable=protected-access self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, 22, self.paths[0]))) + Revoker._get_backup(self.backup_dir, 22, self.paths[0]))) self.assertTrue(os.path.isfile( - Revoker._get_backup(self.backup_dir, 22, self.key_path))) + Revoker._get_backup(self.backup_dir, 22, self.key_path))) class CertTest(unittest.TestCase): @@ -303,6 +315,7 @@ class CertTest(unittest.TestCase): self.assertTrue(self.certs[1].pretty_print()) def create_revoker_certs(): + """Create a few revoker.Cert objects.""" from letsencrypt.client.revoker import Cert base_package = "letsencrypt.client.tests" @@ -323,4 +336,4 @@ def create_revoker_certs(): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 08fc0852d79cd13e1ea444d9c4d11b4449e8de85 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Wed, 18 Feb 2015 04:13:49 -0800 Subject: [PATCH 19/46] Correct display docs --- docs/api/client/display.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api/client/display.rst b/docs/api/client/display.rst index f6cc19b4d..59f97d18e 100644 --- a/docs/api/client/display.rst +++ b/docs/api/client/display.rst @@ -4,10 +4,10 @@ .. automodule:: letsencrypt.client.display :members: -:mod:`letsencrypt.client.display.display_util` -============================================== +:mod:`letsencrypt.client.display.util` +====================================== -.. automodule:: letsencrypt.client.display.display_util +.. automodule:: letsencrypt.client.display.util :members: :mod:`letsencrypt.client.display.ops` From 6c8eb8be17741ed13d8a24afbeac56851f290aa5 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 19 Feb 2015 04:13:39 -0800 Subject: [PATCH 20/46] Revisions through running/testing --- letsencrypt/client/apache/configurator.py | 12 +++- letsencrypt/client/client.py | 59 +++++++++++++++---- letsencrypt/client/display/ops.py | 36 +++++++---- letsencrypt/client/display/revocation.py | 4 +- letsencrypt/client/display/util.py | 30 ++++++---- letsencrypt/client/interfaces.py | 7 +++ letsencrypt/client/revoker.py | 40 +++++++++---- .../client/standalone_authenticator.py | 17 ++++-- letsencrypt/client/tests/display/ops_test.py | 34 +++++++++-- .../client/tests/display/revocation_test.py | 32 +++++++--- letsencrypt/client/tests/display/util_test.py | 17 +++++- letsencrypt/client/tests/revoker_test.py | 14 ++++- .../tests/standalone_authenticator_test.py | 12 ++++ letsencrypt/scripts/main.py | 29 +++++---- 14 files changed, 257 insertions(+), 86 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 54eeb3fc1..1bfa83613 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -942,9 +942,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): return tuple([int(i) for i in matches[0].split('.')]) - def __str__(self): - return "Apache version %s" % ".".join(self.get_version()) - + def more_info(self): + """Human-readable string to help understand the module""" + return ( + "Configures Apache to authenticate and install HTTPS.{0}" + "Server root: {root}{0}" + "Version: {version}".format( + os.linesep, root=self.parser.loc["root"], + version=".".join(str(i) for i in self.version)) + ) ########################################################################### # Challenges Section diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 01969b748..76665c327 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -19,6 +19,7 @@ from letsencrypt.client import le_util from letsencrypt.client import network from letsencrypt.client import reverter from letsencrypt.client import revoker +from letsencrypt.client import standalone_authenticator from letsencrypt.client.apache import configurator from letsencrypt.client.display import ops @@ -102,7 +103,8 @@ class Client(object): cert_file, chain_file = self.save_certificate( certificate_msg, self.config.cert_path, self.config.chain_path) - revoker.Revoker.store_cert_key(cert_file, self.authkey.file, False) + revoker.Revoker.store_cert_key( + cert_file, self.authkey.file, self.config) return cert_file, chain_file @@ -350,16 +352,53 @@ def determine_authenticator(config): :param config: Configuration. :type config: :class:`letsencrypt.client.interfaces.IConfig` + :returns: Valid Authenticator object or None + """ - auths = [] - try: - auths.append(configurator.ApacheConfigurator(config)) - except errors.LetsEncryptNoInstallationError: - logging.info("Unable to determine a way to authenticate the server") - if len(auths) > 1: - return ops.choose_authenticator(auths) - elif len(auths) == 1: - return auths[0] + # list of (Description, Known Authenticator classes, init arguments) + all_auths = [ + ("Apache Web Server", configurator.ApacheConfigurator, config), + ("Standalone Authenticator", + standalone_authenticator.StandaloneAuthenticator), + ] + + # Available Authenticator objects + avail_auths = [] + # Error messages for misconfigured authenticators + errs = {} + + for pot_auth in all_auths: + try: + # I do not think this a great solution but haven't come up with + # anything better yet... + if len(pot_auth) == 2: + # pylint: disable=no-value-for-parameter + avail_auths.append((pot_auth[0], pot_auth[1]())) + elif len(pot_auth) == 3: + avail_auths.append((pot_auth[0], pot_auth[1](pot_auth[2]))) + else: + errors.LetsEncryptClientError( + "IAuthenticator: Number of parameters not supported") + except errors.LetsEncryptMisconfigurationError as err: + errs[pot_auth[1]] = err + avail_auths.append((pot_auth[0], pot_auth[1])) + except errors.LetsEncryptNoInstallationError: + pass + + if len(avail_auths) > 1: + auth = ops.choose_authenticator(avail_auths, errs) + elif len(avail_auths) == 1: + auth = avail_auths[0] + else: + auth = None + + if auth in errs: + logging.error("Please fix the configuration for the Authenticator. " + "The following error message was received: " + "%s", errs[auth]) + sys.exit(1) + + return auth def determine_installer(config): diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 205f3fb08..34a036c71 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -11,24 +11,38 @@ from letsencrypt.client.display import util as display_util util = zope.component.getUtility # pylint: disable=invalid-name -def choose_authenticator(auths): +def choose_authenticator(auths, errs): """Allow the user to choose their authenticator. - :param list auths: Where each is a - :class:`letsencrypt.client.interfaces.IAuthenticator` object + :param list auths: Where each is a tuple of the form + ('description', 'IAuthenticator') where IAuthenticator is a + :class:`letsencrypt.client.interfaces.IAuthenticator` object or class + :param dict errs: Mapping IAuthenticator objects to error messages :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) for auth in auths]) + descs = [auth[0] for auth in auths] + iauths = [auth[1] for auth in auths] + + while True: + code, index = util(interfaces.IDisplay).menu( + "How would you like to authenticate with the Let's Encrypt CA?", + descs, help_label="More Info") + + if code == display_util.OK: + return iauths[index] + elif code == display_util.HELP: + if iauths[index] in errs: + msg = "Reported Error: %s" % errs[iauths[index]] + else: + msg = iauths[index].more_info() + util(interfaces.IDisplay).notification( + msg, height=display_util.HEIGHT) + else: + sys.exit(0) - if code == display_util.OK: - return auths[index] - else: - sys.exit(0) def choose_names(installer): """Display screen to select domains to validate. @@ -116,7 +130,7 @@ def _gen_https_names(domains): 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]), + ", ".join("https://%s" % dom for dom in domains[:-1]), ", and https://", domains[-1]) diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 76666ddf6..8db334731 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -20,10 +20,8 @@ def choose_certs(certs): """ while True: code, selection = _display_certs(certs) - if code == display_util.OK: - if confirm_revocation(certs[selection]): - return selection + return selection elif code == display_util.HELP: more_info_cert(certs[selection]) else: diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index 0b5e7c7d6..04db3ebb2 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -93,10 +93,10 @@ class NcursesDisplay(object): help_button=help_button, help_label=help_label, width=self.width, height=self.height) - if code == OK: - return code, int(tag) - 1 + if code == CANCEL: + return code, -1 - return code, -1 + return code, int(tag) - 1 def input(self, message): """Display an input box to the user. @@ -108,7 +108,7 @@ class NcursesDisplay(object): `string` - input entered by the user """ - return self.dialog.inputbox(message) + return self.dialog.inputbox(message, width=self.width) def yesno(self, message, yes_label="Yes", no_label="No"): """Display a Yes/No dialog box @@ -232,12 +232,19 @@ class FileDisplay(object): self.outfile.write("{0}{frame}{msg}{0}{frame}".format( os.linesep, frame=side_frame, msg=message)) - ans = raw_input("{yes}/{no}: ".format( - yes=_parens_around_char(yes_label), - no=_parens_around_char(no_label))) + while True: + ans = raw_input("{yes}/{no}: ".format( + yes=_parens_around_char(yes_label), + no=_parens_around_char(no_label))) - return (ans.startswith(yes_label[0].lower()) or - ans.startswith(yes_label[0].upper())) + # Couldn't get pylint indentation right with elif + # elif doesn't matter in this situation + if (ans.startswith(yes_label[0].lower()) or + ans.startswith(yes_label[0].upper())): + return True + if (ans.startswith(no_label[0].lower()) or + ans.startswith(no_label[0].upper())): + return False def checklist(self, message, tags): """Display a checklist. @@ -275,7 +282,7 @@ class FileDisplay(object): :param list indices: input :param list tags: Original tags of the checklist - :returns: tags the user selected + :returns: valid tags the user selected :rtype: :class:`list` of :class:`str` """ @@ -387,7 +394,8 @@ def separate_list_input(input_): """ no_commas = input_.replace(",", " ") - return [string for string in no_commas.split()] + # Each string is naturally unicode, this causes problems with M2Crypto SANs + return [str(string) for string in no_commas.split()] def _parens_around_char(label): """Place parens around first character of label. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 771ef4676..e6ed243c4 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -57,6 +57,13 @@ class IAuthenticator(zope.interface.Interface): """ + def more_info(self): + """Human-readable string to help the user. + + Should describe the steps taken and any relevant info to help the user + decide which Authenticator to use. + + """ class IConfig(zope.interface.Interface): """Let's Encrypt user-supplied configuration. diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 77f262e6f..73ac779c9 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -105,19 +105,19 @@ class Revoker(object): if certs: selection = revocation.choose_certs(certs) - self._safe_revoke([certs[selection]]) - # This is safer than using remove as Revoker.Certs only check - # the DER value of the cert. There could potentially be multiple - # backup certs with the same value. - del certs[selection] + revoked_certs = self._safe_revoke([certs[selection]]) + # Since we are currently only revoking one cert at a time... + if revoked_certs: + # This is safer than using remove as Revoker.Certs only + # check the DER value of the cert. There could potentially + # be multiple backup certs with the same value. + del certs[selection] else: logging.info( "There are not any trusted Let's Encrypt " "certificates for this server.") return - - def _populate_saved_certs(self, csha1_vhlist): # pylint: disable=no-self-use """Populate a list of all the saved certs. @@ -174,6 +174,9 @@ class Revoker(object): :param certs: certs intended to be revoked :type certs: :class:`list` of :class:`letsencrypt.client.revoker.Cert` + :returns: certs successfully revoked + :rtype: :class:`list` of :class:`letsencrypt.client.revoker.Cert` + """ success_list = [] try: @@ -192,6 +195,8 @@ class Revoker(object): if success_list: self._remove_certs_keys(success_list) + return success_list + def _acme_revoke(self, cert): """Revoke the certificate with the ACME server. @@ -290,9 +295,9 @@ class Revoker(object): cls._catalog_files( config.cert_key_backup, cert_path, key_path, list_path) - @classmethod def _catalog_files(cls, backup_dir, cert_path, key_path, list_path): + idx = 0 if os.path.isfile(list_path): with open(list_path, "r+b") as csvfile: csvreader = csv.reader(csvfile) @@ -309,8 +314,8 @@ class Revoker(object): with open(list_path, "wb") as csvfile: csvwriter = csv.writer(csvfile) # You must move the files before appending the row - cls._copy_files(backup_dir, "0", cert_path, key_path) - csvwriter.writerow(["0", cert_path, key_path]) + cls._copy_files(backup_dir, idx, cert_path, key_path) + csvwriter.writerow([str(idx), cert_path, key_path]) @classmethod def _copy_files(cls, backup_dir, idx, cert_path, key_path): @@ -481,8 +486,21 @@ class Cert(object): text.append("Not Before: %s" % str(self.get_not_before())) text.append("Not After: %s" % str(self.get_not_after())) text.append("Serial Number: %s" % self.get_serial()) - text.append("SHA1: %s" % self.get_fingerprint()) + text.append("SHA1: %s%s" % (self.get_fingerprint(), os.linesep)) text.append("Installed: %s" % self.get_installed_msg()) + + if self.orig is not None: + if self.orig.status == "": + text.append("Path: %s" % self.orig.path) + else: + text.append("Orig Path: %s (%s)" % self.orig) + if self.orig_key is not None: + if self.orig_key.status == "": + text.append("Auth Key Path: %s" % self.orig_key.path) + else: + text.append("Orig Auth Key Path: %s (%s)" % self.orig_key) + + text.append("") return os.linesep.join(text) def pretty_print(self): diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index e2b1d7872..2c870068f 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -149,14 +149,14 @@ class StandaloneAuthenticator(object): if self.subproc_state == "ready": return True elif self.subproc_state == "inuse": - display.generic_notification( + display.notification( "Could not bind TCP port {0} because it is already in " "use by another process on this system (such as a web " "server). Please stop the program in question and then " "try again.".format(port)) return False elif self.subproc_state == "cantbind": - display.generic_notification( + display.notification( "Could not bind TCP port {0} because you don't have " "the appropriate permissions (for example, you " "aren't running this program as " @@ -164,7 +164,7 @@ class StandaloneAuthenticator(object): return False time.sleep(0.1) - display.generic_notification( + display.notification( "Subprocess unexpectedly timed out while trying to bind TCP " "port {0}.".format(port)) @@ -291,7 +291,7 @@ class StandaloneAuthenticator(object): if listeners: pid, name = listeners[0].split("/") display = zope.component.getUtility(interfaces.IDisplay) - display.generic_notification( + display.notification( "The program {0} (process ID {1}) is already listening " "on TCP port {2}. This will prevent us from binding to " "that port. Please stop the {0} program temporarily " @@ -406,3 +406,12 @@ class StandaloneAuthenticator(object): # TODO: restore original signal handlers in parent process # by resetting their actions to SIG_DFL # print "TCP listener subprocess has been told to shut down" + + def more_info(self): # pylint: disable=no-self-use + """Human-readable string that describes the Authenticator.""" + return ("The Standalone Authenticator uses PyOpenSSL to listen " + "on port 443 and perform DVSNI challenges. Once a certificate" + "is attained, it will be saved in the " + "(TODO) current working directory.{0}{0}" + "Port 443 must be open in order to use the " + "Standalone Authenticator.") diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 7b4a6d8b5..247a8c3e8 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -12,25 +12,49 @@ class ChooseAuthenticatorTest(unittest.TestCase): """Test choose_authenticator function.""" def setUp(self): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) + self.mock_apache = mock.Mock() + self.mock_stand = mock.Mock() + self.mock_apache().more_info.return_value = "Apache Info" + self.mock_stand().more_info.return_value = "Standalone Info" + + self.auths = [ + ("Apache Tag", self.mock_apache), + ("Standalone Tag", self.mock_stand) + ] + + self.errs = {self.mock_apache: "This is an error message."} @classmethod - def _call(cls, auths): + def _call(cls, auths, errs): from letsencrypt.client.display.ops import choose_authenticator - return choose_authenticator(auths) + return choose_authenticator(auths, errs) @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"]) + ret = self._call(self.auths, {}) - self.assertEqual(ret, "authenticator1") + self.assertEqual(ret, self.mock_apache) + + @mock.patch("letsencrypt.client.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), + ] + + ret = self._call(self.auths, self.errs) + + self.assertEqual(mock_util().notification.call_count, 2) + self.assertEqual(ret, self.mock_stand) @mock.patch("letsencrypt.client.display.ops.util") def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertRaises(SystemExit, self._call, ["authenticator1"]) + self.assertRaises(SystemExit, self._call, self.auths, {}) class GenHttpsNamesTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index 4e8591272..ecf247757 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -29,8 +29,7 @@ class ChooseCertsTest(unittest.TestCase): return choose_certs(certs) @mock.patch("letsencrypt.client.display.revocation.util") - def test_confirm_revocation(self, mock_util): - mock_util().yesno.return_value = True + def test_revocation(self, mock_util): mock_util().menu.return_value = (display_util.OK, 0) choice = self._call(self.certs) @@ -38,12 +37,8 @@ class ChooseCertsTest(unittest.TestCase): self.assertTrue(self.certs[choice] == self.cert0) @mock.patch("letsencrypt.client.display.revocation.util") - def test_confirm_cancel(self, mock_util): - mock_util().yesno.return_value = False - mock_util().menu.side_effect = [ - (display_util.OK, 0), - (display_util.CANCEL, -1) - ] + def test_cancel(self, mock_util): + mock_util().menu.return_value = (display_util.CANCEL, -1) self.assertRaises(SystemExit, self._call, self.certs) @@ -53,7 +48,6 @@ class ChooseCertsTest(unittest.TestCase): (display_util.HELP, 1), (display_util.OK, 1), ] - mock_util().yesno.return_value = True choice = self._call(self.certs) @@ -79,5 +73,25 @@ class SuccessRevocationTest(unittest.TestCase): self.assertEqual(mock_util().notification.call_count, 1) + +class ConfirmRevocationTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.revoker import Cert + self.cert = Cert(pkg_resources.resource_filename( + "letsencrypt.client.tests", os.path.join("testdata", "cert.pem"))) + + @classmethod + def _call(cls, cert): + from letsencrypt.client.display.revocation import confirm_revocation + return confirm_revocation(cert) + + @mock.patch("letsencrypt.client.display.revocation.util") + def test_confirm_revocation(self, mock_util): + mock_util().yesno.return_value = True + self.assertTrue(self._call(self.cert)) + + mock_util().yesno.return_value = False + self.assertFalse(self._call(self.cert)) + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index b355f0fc0..89dc3cfe3 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -91,6 +91,14 @@ class NcursesDisplayTest(DisplayT): self.assertEqual(ret, (display_util.OK, 0)) + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") + def test_menu_desc_only_help(self, mock_menu): + mock_menu.return_value = (display_util.HELP, "2") + + ret = self.displayer.menu("Message", self.tags, help_label="More Info") + + self.assertEqual(ret, (display_util.HELP, 1)) + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.menu") def test_menu_desc_only_cancel(self, mock_menu): mock_menu.return_value = (display_util.CANCEL, "") @@ -103,7 +111,7 @@ class NcursesDisplayTest(DisplayT): "dialog.Dialog.inputbox") def test_input(self, mock_input): self.displayer.input("message") - mock_input.assert_called_with("message") + self.assertEqual(mock_input.call_count, 1) @mock.patch("letsencrypt.client.display.util.dialog.Dialog.yesno") def test_yesno(self, mock_yesno): @@ -182,8 +190,13 @@ class FileOutputDisplayTest(DisplayT): self.assertTrue(self.displayer.yesno("message")) with mock.patch("__builtin__.raw_input", return_value="y"): self.assertTrue(self.displayer.yesno("message")) - with mock.patch("__builtin__.raw_input", return_value="cancel"): + with mock.patch("__builtin__.raw_input", side_effect=["maybe", "y"]): + self.assertTrue(self.displayer.yesno("message")) + with mock.patch("__builtin__.raw_input", return_value="No"): self.assertFalse(self.displayer.yesno("message")) + with mock.patch("__builtin__.raw_input", side_effect=["cancel", "n"]): + self.assertFalse(self.displayer.yesno("message")) + with mock.patch("__builtin__.raw_input", return_value="a"): self.assertTrue(self.displayer.yesno("msg", yes_label="Agree")) diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index a347970c9..f5c9feace 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -309,11 +309,21 @@ class CertTest(unittest.TestCase): self.assertEqual(self.certs[0].orig.status, "") self.assertEqual(self.certs[0].orig_key.status, "") - def test_print(self): - """Just make sure there aren't any errors.""" + def test_print_meta(self): + """Just make sure there aren't any major errors.""" + self.certs[0].add_meta( + 0, self.paths[0], self.key_path, self.paths[0], self.key_path) + # Changed path and deleted file + self.certs[1].add_meta( + 1, self.paths[0], "/not/a/path", self.paths[1], self.key_path) self.assertTrue(self.certs[0].pretty_print()) self.assertTrue(self.certs[1].pretty_print()) + def test_print_no_meta(self): + self.assertTrue(self.certs[0].pretty_print()) + self.assertTrue(self.certs[1].pretty_print()) + + def create_revoker_certs(): """Create a few revoker.Cert objects.""" from letsencrypt.client.revoker import Cert diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 60a1ba600..955afc0d6 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -542,5 +542,17 @@ class CleanupTest(unittest.TestCase): self.assertRaises(ValueError, self.authenticator.cleanup, [chall]) +class MoreInfoTest(unittest.TestCase): + """Tests for more_info() method. (trivially)""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_chall_pref(self): + """Make sure exceptions aren't raised.""" + self.authenticator.more_info() + + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8e2589f15..9ec42bd44 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -16,7 +16,6 @@ import letsencrypt from letsencrypt.client import configuration from letsencrypt.client import client -from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log @@ -116,12 +115,14 @@ def main(): # pylint: disable=too-many-branches displayer = display_util.NcursesDisplay() else: displayer = display_util.FileDisplay(sys.stdout) + zope.component.provideUtility(displayer) if args.view_config_changes: client.view_config_changes(config) sys.exit() + # TODO: if revoke, rev_cert... if args.revoke: client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() @@ -135,32 +136,30 @@ def main(): # pylint: disable=too-many-branches # Make sure we actually get an installer that is functioning properly # before we begin to try to use it. - try: - auth = client.determine_authenticator(config) - except errors.LetsEncryptMisconfigurationError as err: - logging.fatal("Please fix your configuration before proceeding.%s" - "The Authenticator exited with the following message: " - "%s", os.linesep, err) - sys.exit(1) + auth = client.determine_authenticator(config) + if auth is None: + logging.critical("Unable to find a way to authenticate the server.") + sys.exit(4) # Use the same object if possible if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member installer = auth else: - installer = client.determine_installer(config) + # This is simple and avoids confusion right now. + installer = None doms = ops.choose_names(installer) if args.domains is None else args.domains # Prepare for init of Client - if args.privkey is None: - privkey = client.init_key(args.rsa_key_size, config.key_dir) + if args.authkey is None: + authkey = client.init_key(args.rsa_key_size, config.key_dir) else: - privkey = le_util.Key(args.authkey[0], args.authkey[1]) + authkey = le_util.Key(args.authkey[0], args.authkey[1]) - acme = client.Client(config, privkey, auth, installer) + acme = client.Client(config, authkey, auth, installer) # Validate the key and csr - client.validate_key_csr(privkey) + client.validate_key_csr(authkey) # This more closely mimics the capabilities of the CLI # It should be possible for reconfig only, install-only, no-install @@ -170,7 +169,7 @@ def main(): # pylint: disable=too-many-branches if auth is not None: cert_file, chain_file = acme.obtain_certificate(doms) if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, privkey, cert_file, chain_file) + acme.deploy_certificate(doms, authkey, cert_file, chain_file) if installer is not None: acme.enhance_config(doms, args.redirect) From 119863d1ea827449f8a72872faf0e8f2fd5cb309 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 19 Feb 2015 19:33:54 -0800 Subject: [PATCH 21/46] determine_authenticator tests --- letsencrypt/client/client.py | 23 +++----- letsencrypt/client/display/ops.py | 3 +- letsencrypt/client/display/util.py | 3 + letsencrypt/client/interfaces.py | 2 +- .../client/standalone_authenticator.py | 2 +- letsencrypt/client/tests/client_test.py | 58 ++++++++++++++++++- letsencrypt/scripts/main.py | 12 +++- 7 files changed, 82 insertions(+), 21 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 76665c327..e1ffd9551 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -19,7 +19,6 @@ from letsencrypt.client import le_util from letsencrypt.client import network from letsencrypt.client import reverter from letsencrypt.client import revoker -from letsencrypt.client import standalone_authenticator from letsencrypt.client.apache import configurator from letsencrypt.client.display import ops @@ -346,22 +345,17 @@ def init_csr(privkey, names, cert_dir): return le_util.CSR(csr_filename, csr_der, "der") # This should be controlled by commandline parameters -def determine_authenticator(config): +def determine_authenticator(all_auths): """Returns a valid IAuthenticator. - :param config: Configuration. - :type config: :class:`letsencrypt.client.interfaces.IConfig` + :param list all_auths: Where each is a tuple of the form + ('description', 'IAuthenticator', *options..) where IAuthenticator is a + :class:`letsencrypt.client.interfaces.IAuthenticator` object or class + and options are the parameters used to initialize the authenticator. :returns: Valid Authenticator object or None """ - # list of (Description, Known Authenticator classes, init arguments) - all_auths = [ - ("Apache Web Server", configurator.ApacheConfigurator, config), - ("Standalone Authenticator", - standalone_authenticator.StandaloneAuthenticator), - ] - # Available Authenticator objects avail_auths = [] # Error messages for misconfigured authenticators @@ -370,14 +364,15 @@ def determine_authenticator(config): for pot_auth in all_auths: try: # I do not think this a great solution but haven't come up with - # anything better yet... + # anything better yet... other than constricting init functions for + # authenticators if len(pot_auth) == 2: # pylint: disable=no-value-for-parameter avail_auths.append((pot_auth[0], pot_auth[1]())) elif len(pot_auth) == 3: avail_auths.append((pot_auth[0], pot_auth[1](pot_auth[2]))) else: - errors.LetsEncryptClientError( + raise errors.LetsEncryptClientError( "IAuthenticator: Number of parameters not supported") except errors.LetsEncryptMisconfigurationError as err: errs[pot_auth[1]] = err @@ -388,7 +383,7 @@ def determine_authenticator(config): if len(avail_auths) > 1: auth = ops.choose_authenticator(avail_auths, errs) elif len(avail_auths) == 1: - auth = avail_auths[0] + auth = avail_auths[0][1] else: auth = None diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 34a036c71..9cd8e16e5 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -23,7 +23,8 @@ def choose_authenticator(auths, errs): :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - descs = [auth[0] for auth in auths] + descs = [auth[0] if auth[1] not in errs else "%s (Misconfigured)" % auth[0] + for auth in auths] iauths = [auth[1] for auth in auths] while True: diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index 04db3ebb2..ad324b1b8 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -175,6 +175,9 @@ class FileDisplay(object): # pylint: disable=unused-argument """Display a menu. + .. todo:: This doesn't enable the help label/button (I wasn't sold on + any interface I came up with for this). It would be a nice feature + :param str message: title of menu :param choices: Menu lines, len must be > 0 :type choices: list of tuples (tag, item) or diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index e6ed243c4..1dbe930d8 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -57,7 +57,7 @@ class IAuthenticator(zope.interface.Interface): """ - def more_info(self): + def more_info(): """Human-readable string to help the user. Should describe the steps taken and any relevant info to help the user diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index 2c870068f..d7b78c9cf 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -414,4 +414,4 @@ class StandaloneAuthenticator(object): "is attained, it will be saved in the " "(TODO) current working directory.{0}{0}" "Port 443 must be open in order to use the " - "Standalone Authenticator.") + "Standalone Authenticator.".format(os.linesep)) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index c490df770..41aaf38a4 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -6,10 +6,66 @@ import mock from letsencrypt.client import errors +class DetermineAuthenticatorTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.apache.configurator import ApacheConfigurator + from letsencrypt.client.standalone_authenticator \ + import StandaloneAuthenticator + + self.mock_stand = mock.MagicMock(spec=StandaloneAuthenticator) + self.mock_apache = mock.MagicMock(spec=ApacheConfigurator) + + self.mock_config = mock.Mock() + + self.all_auths = [ + ("Apache Web Server", self.mock_apache, self.mock_config), + ("Standalone", self.mock_stand), + ] + + @classmethod + def _call(cls, all_auths): + from letsencrypt.client.client import determine_authenticator + return determine_authenticator(all_auths) + + @mock.patch("letsencrypt.client.client.ops.choose_authenticator") + def test_accept_two(self, mock_choose): + mock_choose.return_value = self.mock_stand() + self.assertEqual(self._call(self.all_auths), self.mock_stand()) + + def test_accept_one(self): + self.assertEqual( + self._call(self.all_auths[:1]), self.mock_apache(self.mock_config)) + + def test_no_installation_one(self): + self.mock_apache.side_effect = errors.LetsEncryptNoInstallationError + + self.assertEqual(self._call(self.all_auths), self.mock_stand()) + + def test_no_installations(self): + self.mock_apache.side_effect = errors.LetsEncryptNoInstallationError + self.mock_stand.side_effect = errors.LetsEncryptNoInstallationError + + self.assertTrue(self._call(self.all_auths) is None) + + @mock.patch("letsencrypt.client.client.logging") + @mock.patch("letsencrypt.client.client.ops.choose_authenticator") + def test_misconfigured(self, mock_choose, mock_log): + self.mock_apache.side_effect = errors.LetsEncryptMisconfigurationError + mock_choose.return_value = self.mock_apache + + self.assertRaises(SystemExit, self._call, self.all_auths) + + def test_too_many_params(self): + self.assertRaises( + errors.LetsEncryptClientError, + self._call, + [("desc", self.mock_apache, "1", "2", "3", "4", "5")]) + class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): - self.m_install = mock.MagicMock() + from letsencrypt.client.apache.configurator import ApacheConfigurator + self.m_install = mock.MagicMock(spec=ApacheConfigurator) @classmethod def _call(cls, checkpoints): diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 9ec42bd44..2e102ffa6 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -19,6 +19,8 @@ from letsencrypt.client import client from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log +from letsencrypt.client import standalone_authenticator +from letsencrypt.client.apache import configurator from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops @@ -134,9 +136,13 @@ def main(): # pylint: disable=too-many-branches if not args.eula: display_eula() - # Make sure we actually get an installer that is functioning properly - # before we begin to try to use it. - auth = client.determine_authenticator(config) + # list of (Description, Known Authenticator classes, init arguments) + all_auths = [ + ("Apache Web Server", configurator.ApacheConfigurator, config), + ("Standalone Authenticator", + standalone_authenticator.StandaloneAuthenticator), + ] + auth = client.determine_authenticator(all_auths) if auth is None: logging.critical("Unable to find a way to authenticate the server.") sys.exit(4) From 5d76c0feb1cc4690149be589a96e61217b9488ad Mon Sep 17 00:00:00 2001 From: James Kasten Date: Thu, 19 Feb 2015 22:30:11 -0800 Subject: [PATCH 22/46] Final cleanup for revoker/display --- letsencrypt/client/client.py | 2 + letsencrypt/client/crypto_util.py | 1 - letsencrypt/client/display/ops.py | 4 +- letsencrypt/client/display/util.py | 3 +- letsencrypt/client/interfaces.py | 1 + letsencrypt/client/le_util.py | 1 + letsencrypt/client/revoker.py | 20 +++++++-- letsencrypt/client/tests/client_test.py | 3 +- .../client/tests/display/revocation_test.py | 1 + letsencrypt/client/tests/display/util_test.py | 2 - letsencrypt/client/tests/revoker_test.py | 41 +++++++++++++++++++ letsencrypt/scripts/main.py | 8 ++-- tox.ini | 2 +- 13 files changed, 74 insertions(+), 15 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index e1ffd9551..4084df2dc 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -320,6 +320,7 @@ def init_key(key_size, key_dir): return le_util.Key(key_filename, key_pem) + def init_csr(privkey, names, cert_dir): """Initialize a CSR with the given private key. @@ -344,6 +345,7 @@ def init_csr(privkey, names, cert_dir): return le_util.CSR(csr_filename, csr_der, "der") + # This should be controlled by commandline parameters def determine_authenticator(all_auths): """Returns a valid IAuthenticator. diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index c9bae885e..4c053aeae 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -161,4 +161,3 @@ def make_ss_cert(key_str, domains, not_before=None, assert m2_cert.verify() # print check_purpose(,0 return m2_cert.as_pem() - diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 9cd8e16e5..29ce6929d 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -109,12 +109,14 @@ def _choose_names_manually(): def success_installation(domains): """Display a box confirming the installation of HTTPS. + .. todo:: This should be centered on the screen + :param list domains: domain names which were enabled """ util(interfaces.IDisplay).notification( "Congratulations! You have successfully enabled " - "%s!" % _gen_https_names(domains), pause=True) + "%s!" % _gen_https_names(domains), pause=False) def _gen_https_names(domains): diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index ad324b1b8..111e46500 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -143,6 +143,7 @@ class NcursesDisplay(object): return self.dialog.checklist( message, width=self.width, height=self.height, choices=choices) + class FileDisplay(object): """File-based display.""" @@ -305,7 +306,6 @@ class FileDisplay(object): # 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. @@ -400,6 +400,7 @@ def separate_list_input(input_): # Each string is naturally unicode, this causes problems with M2Crypto SANs return [str(string) for string in no_commas.split()] + def _parens_around_char(label): """Place parens around first character of label. diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 1dbe930d8..f53354390 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -65,6 +65,7 @@ class IAuthenticator(zope.interface.Interface): """ + class IConfig(zope.interface.Interface): """Let's Encrypt user-supplied configuration. diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 7c3ef0762..1615fc29d 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -11,6 +11,7 @@ Key = collections.namedtuple("Key", "file pem") # Note: form is the type of data, "pem" or "der" CSR = collections.namedtuple("CSR", "file data form") + def make_or_verify_dir(directory, mode=0o755, uid=0): """Make sure directory exists with proper permissions. diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 73ac779c9..58b64b6b2 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -40,6 +40,8 @@ class Revoker(object): :ivar config: Configuration. :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar bool no_confirm: Whether or not to ask for confirmation for revocation + """ def __init__(self, installer, config, no_confirm=False): self.network = network.Network(config.server) @@ -62,25 +64,31 @@ class Revoker(object): """ certs = [] + clean_pem = Crypto.PublicKey.RSA.importKey(authkey.pem).exportKey("PEM") with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) for row in csvreader: # idx, cert, key # Add all keys that match to marked list - # TODO: This doesn't account for padding in the file that might - # differ. This should only consider the key material. # Note: The key can be different than the pub key found in the # certificate. _, b_k = self._row_to_backup(row) - if authkey.pem == open(b_k).read(): + if clean_pem == Crypto.PublicKey.RSA.importKey( + open(b_k).read()).exportKey("PEM"): certs.append(Cert.fromrow(row, self.config.cert_key_backup)) if certs: self._safe_revoke(certs) + else: + logging.info("No certificates using the authorized key were found.") def revoke_from_cert(self, cert_path): """Revoke a certificate by specifying a file path. + .. todo:: Add the ability to revoke the certificate even if the cert + is not stored locally. A path to the auth key will need to be + attained from the user. + :param str cert_path: path to ACME certificate in pem form """ @@ -94,6 +102,9 @@ class Revoker(object): if cert == cert_to_revoke: self._safe_revoke([cert]) + return + + logging.info("Associated ACME certificate was not found.") def revoke_from_menu(self): """List trusted Let's Encrypt certificates.""" @@ -128,6 +139,9 @@ class Revoker(object): Namely, additional certs/keys may exist. There should never be any certs/keys in the LIST that don't exist in the directory however. + :param dict csha1_vhlist: map from cert sha1 fingerprints to a list + of it's installed location paths. + """ certs = [] with open(self.list_path, "rb") as csvfile: diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 41aaf38a4..79bf799c9 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -49,7 +49,7 @@ class DetermineAuthenticatorTest(unittest.TestCase): @mock.patch("letsencrypt.client.client.logging") @mock.patch("letsencrypt.client.client.ops.choose_authenticator") - def test_misconfigured(self, mock_choose, mock_log): + def test_misconfigured(self, mock_choose, mock_log): # pylint: disable=unused-argument self.mock_apache.side_effect = errors.LetsEncryptMisconfigurationError mock_choose.return_value = self.mock_apache @@ -61,6 +61,7 @@ class DetermineAuthenticatorTest(unittest.TestCase): self._call, [("desc", self.mock_apache, "1", "2", "3", "4", "5")]) + class RollbackTest(unittest.TestCase): """Test the rollback function.""" def setUp(self): diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index ecf247757..db37effb2 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -54,6 +54,7 @@ class ChooseCertsTest(unittest.TestCase): self.assertTrue(self.certs[choice] == self.cert1) self.assertEqual(mock_util().notification.call_count, 1) + class SuccessRevocationTest(unittest.TestCase): def setUp(self): from letsencrypt.client.revoker import Cert diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 89dc3cfe3..5a0bb4c1f 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -67,7 +67,6 @@ class NcursesDisplayTest(DisplayT): ret = self.displayer.menu("Message", self.choices) - mock_menu.assert_called_with( "Message", choices=self.choices, ok_label="OK", cancel_label="Cancel", @@ -82,7 +81,6 @@ class NcursesDisplayTest(DisplayT): ret = self.displayer.menu("Message", self.tags, help_label="More Info") - mock_menu.assert_called_with( "Message", choices=self.tags_choices, ok_label="OK", cancel_label="Cancel", diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index f5c9feace..b415e2154 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -77,6 +77,26 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 2) + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_wrong_key(self, mock_display, mock_net): + mock_display().confirm_revocation.return_value = True + + key_path = pkg_resources.resource_filename( + "letsencrypt.client.tests", os.path.join( + "testdata", "rsa256_key.pem")) + + wrong_key = le_util.Key(key_path, open(key_path).read()) + self.revoker.revoke_from_key(wrong_key) + + # Nothing was removed + self.assertEqual(len(self._get_rows()), 2) + # No revocation went through + self.assertEqual(mock_net.call_count, 0) + + + @mock.patch("letsencrypt.client.revoker.network." "Network.send_and_receive_expected") @mock.patch("letsencrypt.client.revoker.revocation") @@ -95,6 +115,26 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 1) + @mock.patch("letsencrypt.client.revoker.network." + "Network.send_and_receive_expected") + @mock.patch("letsencrypt.client.revoker.revocation") + def test_revoke_by_cert_not_found(self, mock_display, mock_net): + mock_display().confirm_revocation.return_value = True + + self.revoker.revoke_from_cert(self.paths[0]) + self.revoker.revoke_from_cert(self.paths[0]) + + row0 = self.certs[0].get_row() + row1 = self.certs[1].get_row() + + # Same check as last time... just reversed. + self.assertEqual(self._get_rows(), [row1]) + + self.assertTrue(self._backups_exist(row1)) + self.assertFalse(self._backups_exist(row0)) + + self.assertEqual(mock_net.call_count, 1) + @mock.patch("letsencrypt.client.revoker.network." "Network.send_and_receive_expected") @mock.patch("letsencrypt.client.revoker.revocation") @@ -228,6 +268,7 @@ class RevokerInstallerTest(RevokerBase): # pylint: disable=protected-access self.assertEqual(revoker._get_installed_locations(), {}) + class RevokerClassMethodsTest(RevokerBase): def setUp(self): super(RevokerClassMethodsTest, self).setUp() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 2e102ffa6..06add9add 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -19,7 +19,7 @@ from letsencrypt.client import client from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log -from letsencrypt.client import standalone_authenticator +from letsencrypt.client import standalone_authenticator as standalone from letsencrypt.client.apache import configurator from letsencrypt.client.display import util as display_util from letsencrypt.client.display import ops @@ -124,8 +124,7 @@ def main(): # pylint: disable=too-many-branches client.view_config_changes(config) sys.exit() - # TODO: if revoke, rev_cert... - if args.revoke: + if args.revoke or args.rev_cert or args.rev_key: client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() @@ -139,8 +138,7 @@ def main(): # pylint: disable=too-many-branches # list of (Description, Known Authenticator classes, init arguments) all_auths = [ ("Apache Web Server", configurator.ApacheConfigurator, config), - ("Standalone Authenticator", - standalone_authenticator.StandaloneAuthenticator), + ("Standalone Authenticator", standalone.StandaloneAuthenticator), ] auth = client.determine_authenticator(all_auths) if auth is None: diff --git a/tox.ini b/tox.ini index d4af50fa5..59d943a3e 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=83 + python setup.py nosetests --with-coverage --cover-min-percentage=84 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From b8ba6fe4e1f4699a2fd6882cfd31dd60f674d3eb Mon Sep 17 00:00:00 2001 From: Scott Barr Date: Fri, 20 Feb 2015 21:31:32 +1000 Subject: [PATCH 23/46] Added ConfArgParse and zope.component requirements.txt --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index a364c4e8a..4ed1d7b70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,5 @@ requests==2.4.3 argparse==1.2.2 mock==1.0.1 PyOpenSSL==0.13 +ConfArgParse==1.0.15 +zope.component==4.2.1 From 1382c0c969b42785454f677262da2bdb705d7c3e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 20 Feb 2015 14:11:55 +0000 Subject: [PATCH 24/46] Remove requirements.txt --- requirements.txt | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4ed1d7b70..000000000 --- a/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -M2Crypto==0.22.3 -python2-pythondialog -jsonschema==2.4.0 -python-augeas==0.5.0 -requests==2.4.3 -argparse==1.2.2 -mock==1.0.1 -PyOpenSSL==0.13 -ConfArgParse==1.0.15 -zope.component==4.2.1 From 84f0685929550f957cd775e724e8a9214e15ab32 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Fri, 20 Feb 2015 14:37:05 +0000 Subject: [PATCH 25/46] Add CONTRIBUTING.rst --- CONTRIBUTING.rst | 72 +++++++++++++++++++++++++++++++++++++++++++++++ docs/project.rst | 73 +----------------------------------------------- 2 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 CONTRIBUTING.rst diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 000000000..9cb73a654 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,72 @@ +.. _hacking: + +Hacking +======= + +In order to start hacking, you will first have to create a development +environment: + +:: + + ./venv/bin/python setup.py dev + +The code base, including your pull requests, **must** have 100% test statement +coverage **and** be compliant with the :ref:`coding-style`. + +The following tools are there to help you: + +- ``./venv/bin/tox`` starts a full set of tests. Please make sure you + run it before submitting a new pull request. + +- ``./venv/bin/tox -e cover`` checks the test coverage only. + +- ``./venv/bin/tox -e lint`` checks the style of the whole project, + while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only. + + +.. _coding-style: + +Coding style +============ + +Please: + +1. **Be consistent with the rest of the code**. + +2. Read `PEP 8 - Style Guide for Python Code`_. + +3. Follow the `Google Python Style Guide`_, with the exception that we + use `Sphinx-style`_ documentation: + + :: + + def foo(arg): + """Short description. + + :param int arg: Some number. + + :returns: Argument + :rtype: int + + """ + return arg + +4. Remember to use ``./venv/bin/pylint``. + +.. _Google Python Style Guide: https://google-styleguide.googlecode.com/svn/trunk/pyguide.html +.. _Sphinx-style: http://sphinx-doc.org/ +.. _PEP 8 - Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008 + + +Updating the Documentation +========================== + +In order to generate the Sphinx documentation, run the following commands. + +:: + + cd docs + make clean html SPHINXBUILD=../venv/bin/sphinx-build + + +This should generate documentation in the ``docs/_build/html`` directory. diff --git a/docs/project.rst b/docs/project.rst index 5da350cfb..421f0b062 100644 --- a/docs/project.rst +++ b/docs/project.rst @@ -2,75 +2,4 @@ The Let's Encrypt Client Project ================================ -.. _hacking: - -Hacking -======= - -In order to start hacking, you will first have to create a development -environment: - -:: - - ./venv/bin/python setup.py dev - -The code base, including your pull requests, **must** have 100% test statement -coverage **and** be compliant with the :ref:`coding-style`. - -The following tools are there to help you: - -- ``./venv/bin/tox`` starts a full set of tests. Please make sure you - run it before submitting a new pull request. - -- ``./venv/bin/tox -e cover`` checks the test coverage only. - -- ``./venv/bin/tox -e lint`` checks the style of the whole project, - while ``./venv/bin/pylint --rcfile=.pylintrc file`` will check a single `file` only. - - -.. _coding-style: - -Coding style -============ - -Please: - -1. **Be consistent with the rest of the code**. - -2. Read `PEP 8 - Style Guide for Python Code`_. - -3. Follow the `Google Python Style Guide`_, with the exception that we - use `Sphinx-style`_ documentation: - - :: - - def foo(arg): - """Short description. - - :param int arg: Some number. - - :returns: Argument - :rtype: int - - """ - return arg - -4. Remember to use ``./venv/bin/pylint``. - -.. _Google Python Style Guide: https://google-styleguide.googlecode.com/svn/trunk/pyguide.html -.. _Sphinx-style: http://sphinx-doc.org/ -.. _PEP 8 - Style Guide for Python Code: https://www.python.org/dev/peps/pep-0008 - - -Updating the Documentation -========================== - -In order to generate the Sphinx documentation, run the following commands. - -:: - - cd docs - make clean html SPHINXBUILD=../venv/bin/sphinx-build - - -This should generate documentation in the ``docs/_build/html`` directory. +.. include:: ../CONTRIBUTING.rst From fa0c3d2b9f7b3568814719b5d92f62c173b871e9 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 23 Feb 2015 04:26:43 -0800 Subject: [PATCH 26/46] revisions --- letsencrypt/client/apache/configurator.py | 24 +++- letsencrypt/client/client.py | 110 ++++-------------- letsencrypt/client/crypto_util.py | 28 ++--- letsencrypt/client/display/enhancements.py | 14 ++- letsencrypt/client/display/ops.py | 19 ++- letsencrypt/client/display/revocation.py | 33 ++---- letsencrypt/client/display/util.py | 20 ++-- letsencrypt/client/interfaces.py | 20 ++++ letsencrypt/client/revoker.py | 102 ++++++++-------- .../client/standalone_authenticator.py | 10 ++ letsencrypt/client/tests/apache/util.py | 2 + letsencrypt/client/tests/client_test.py | 97 ++++----------- letsencrypt/client/tests/crypto_util_test.py | 41 +------ letsencrypt/client/tests/display/ops_test.py | 9 +- .../client/tests/display/revocation_test.py | 28 +++-- letsencrypt/client/tests/reverter_test.py | 2 +- letsencrypt/client/tests/revoker_test.py | 38 ++++-- .../tests/standalone_authenticator_test.py | 18 ++- letsencrypt/scripts/main.py | 14 ++- 19 files changed, 273 insertions(+), 356 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 1bfa83613..2ecf30104 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -74,6 +74,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + description = "Apache Web Server" + def __init__(self, config, version=None): """Initialize an Apache Configurator. @@ -87,6 +89,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if os.geteuid() == 0: self.verify_setup() + # Add name_server association dict + self.assoc = dict() + # Add number of outstanding challenges + self.chall_out = 0 + + # These will be set in the prepare function + self.parser = None + self.version = version + self.vhosts = None + self.enhance_func = {"redirect": self._enable_redirect} + + def prepare(self): + """Prepare the authenticator/installer.""" self.parser = parser.ApacheParser( self.aug, self.config.apache_server_root, self.config.apache_mod_ssl_conf) @@ -94,14 +109,11 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.check_parsing_errors("httpd.aug") # Set Version - self.version = self.get_version() if version is None else version + if self.version is None: + self.version = self.get_version() # Get all of the available vhosts self.vhosts = self.get_virtual_hosts() - # Add name_server association dict - self.assoc = dict() - # Add number of outstanding challenges - self.chall_out = 0 # Enable mod_ssl if it isn't already enabled # This is Let's Encrypt... we enable mod_ssl on initialization :) @@ -110,9 +122,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # on initialization self._prepare_server_https() - self.enhance_func = {"redirect": self._enable_redirect} temp_install(self.config.apache_mod_ssl_conf) + def deploy_cert(self, domain, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 4084df2dc..107a5dfc4 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -5,7 +5,6 @@ import sys import Crypto.PublicKey.RSA import M2Crypto -import zope.component from letsencrypt.acme import messages from letsencrypt.acme import util as acme_util @@ -14,14 +13,13 @@ from letsencrypt.client import auth_handler from letsencrypt.client import client_authenticator from letsencrypt.client import crypto_util from letsencrypt.client import errors -from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network from letsencrypt.client import reverter from letsencrypt.client import revoker from letsencrypt.client.apache import configurator -from letsencrypt.client.display import ops +from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -50,7 +48,8 @@ class Client(object): """Initialize a client. :param dv_auth: IAuthenticator that can solve the - :const:`letsencrypt.client.constants.DV_CHALLENGES` + :const:`letsencrypt.client.constants.DV_CHALLENGES`. + :func:`letsencrypt.client.interfaces.IAuthenticator.prepare` :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ @@ -200,7 +199,7 @@ class Client(object): # sites may have been enabled / final cleanup self.installer.restart() - ops.success_installation(domains) + display_ops.success_installation(domains) def enhance_config(self, domains, redirect=None): """Enhance the configuration. @@ -357,6 +356,8 @@ def determine_authenticator(all_auths): :returns: Valid Authenticator object or None + :raises :class:`letsencrypt.client.errors.LetsEncryptClientError` + """ # Available Authenticator objects avail_auths = [] @@ -365,29 +366,20 @@ def determine_authenticator(all_auths): for pot_auth in all_auths: try: - # I do not think this a great solution but haven't come up with - # anything better yet... other than constricting init functions for - # authenticators - if len(pot_auth) == 2: - # pylint: disable=no-value-for-parameter - avail_auths.append((pot_auth[0], pot_auth[1]())) - elif len(pot_auth) == 3: - avail_auths.append((pot_auth[0], pot_auth[1](pot_auth[2]))) - else: - raise errors.LetsEncryptClientError( - "IAuthenticator: Number of parameters not supported") + pot_auth.prepare() + avail_auths.append(pot_auth) except errors.LetsEncryptMisconfigurationError as err: - errs[pot_auth[1]] = err - avail_auths.append((pot_auth[0], pot_auth[1])) + errs[pot_auth] = err + avail_auths.append(pot_auth) except errors.LetsEncryptNoInstallationError: - pass + continue if len(avail_auths) > 1: - auth = ops.choose_authenticator(avail_auths, errs) + auth = display_ops.choose_authenticator(avail_auths, errs) elif len(avail_auths) == 1: - auth = avail_auths[0][1] + auth = avail_auths[0] else: - auth = None + raise errors.LetsEncryptClientError("No Authenticators available.") if auth in errs: logging.error("Please fix the configuration for the Authenticator. " @@ -406,26 +398,20 @@ def determine_installer(config): """ try: - return configurator.ApacheConfigurator(config) + installer = configurator.ApacheConfigurator(config) + installer.prepare() + return installer except errors.LetsEncryptNoInstallationError: logging.info("Unable to find a way to install the certificate.") + return None + except errors.LetsEncryptMisconfigurationError: + # This will have to be changed in the future... + return installer def rollback(checkpoints, config): """Revert configuration the specified number of checkpoints. - .. note:: If another installer uses something other than the reverter class - to do their configuration changes, the correct reverter will have to be - determined. - - .. note:: This function restarts the server even if there weren't any - rollbacks. The user may be confused or made an error and simply needs - to restart the server. - - .. todo:: This function will have to change depending on the functionality - of future installers. Perhaps the interface should define errors that - are thrown for the various functions. - :param int checkpoints: Number of checkpoints to revert. :param config: Configuration. @@ -433,11 +419,7 @@ def rollback(checkpoints, config): """ # Misconfigurations are only a slight problems... allow the user to rollback - try: - installer = determine_installer(config) - except errors.LetsEncryptMisconfigurationError: - _misconfigured_rollback(checkpoints, config) - return + installer = determine_installer(config) # No Errors occurred during init... proceed normally # If installer is None... couldn't find an installer... there shouldn't be @@ -447,43 +429,6 @@ def rollback(checkpoints, config): installer.restart() -def _misconfigured_rollback(checkpoints, config): - """Handles the case where the Installer is misconfigured. - - :param int checkpoints: Number of checkpoints to revert. - - :param config: Configuration. - :type config: :class:`letsencrypt.client.interfaces.IConfig` - - """ - yes = zope.component.getUtility(interfaces.IDisplay).yesno( - "Oh, no! The web server is currently misconfigured.{0}{0}" - "Would you still like to rollback the " - "configuration?".format(os.linesep)) - if not yes: - logging.info("The error message is above.") - logging.info("Configuration was not rolled back.") - return - - logging.info("Rolling back using the Reverter module") - # recovery routine has probably already been run by installer - # in the__init__ attempt, run it again for safety... it shouldn't hurt - # Also... not sure how future installers will handle recovery. - rev = reverter.Reverter(config) - rev.recovery_routine() - rev.rollback_checkpoints(checkpoints) - - # We should try to restart the server - try: - installer = determine_installer(config) - installer.restart() - logging.info("Hooray! Rollback solved the misconfiguration!") - logging.info("Your web server is back up and running.") - except errors.LetsEncryptMisconfigurationError: - logging.warning( - "Rollback was unable to solve the misconfiguration issues") - - def revoke(config, no_confirm, cert, authkey): """Revoke certificates. @@ -493,14 +438,9 @@ def revoke(config, no_confirm, cert, authkey): """ # Misconfigurations don't really matter. Determine installer better choose # correctly though. - try: - installer = determine_installer(config) - except errors.LetsEncryptMisconfigurationError: - zope.component.getUtility(interfaces.IDisplay).notification( - "The web server is currently misconfigured. Some " - "abilities like seeing which certificates are currently " - "installed may not be available.") - installer = None + # This will need some better prepared or properly configured parameter... + # I will figure it out later... + installer = determine_installer(config) revoc = revoker.Revoker(installer, config, no_confirm) # Cert is most selective, so it is chosen first. diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index 4c053aeae..e3d0d1c4d 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -129,35 +129,35 @@ def make_ss_cert(key_str, domains, not_before=None, pubkey = M2Crypto.EVP.PKey() pubkey.assign_rsa(rsa_key) - m2_cert = M2Crypto.X509.X509() - m2_cert.set_pubkey(pubkey) - m2_cert.set_serial_number(1337) - m2_cert.set_version(2) + cert = M2Crypto.X509.X509() + cert.set_pubkey(pubkey) + cert.set_serial_number(1337) + cert.set_version(2) current_ts = long(time.time() if not_before is None else not_before) current = M2Crypto.ASN1.ASN1_UTCTIME() current.set_time(current_ts) expire = M2Crypto.ASN1.ASN1_UTCTIME() expire.set_time(current_ts + validity) - m2_cert.set_not_before(current) - m2_cert.set_not_after(expire) + cert.set_not_before(current) + cert.set_not_after(expire) - subject = m2_cert.get_subject() + subject = cert.get_subject() subject.C = "US" subject.ST = "Michigan" subject.L = "Ann Arbor" subject.O = "University of Michigan and the EFF" subject.CN = domains[0] - m2_cert.set_issuer(m2_cert.get_subject()) + cert.set_issuer(cert.get_subject()) if len(domains) > 1: - m2_cert.add_ext(M2Crypto.X509.new_extension( + cert.add_ext(M2Crypto.X509.new_extension( "basicConstraints", "CA:FALSE")) - m2_cert.add_ext(M2Crypto.X509.new_extension( + cert.add_ext(M2Crypto.X509.new_extension( "subjectAltName", ", ".join(["DNS:%s" % d for d in domains]))) - m2_cert.sign(pubkey, "sha256") - assert m2_cert.verify(pubkey) - assert m2_cert.verify() + cert.sign(pubkey, "sha256") + assert cert.verify(pubkey) + assert cert.verify() # print check_purpose(,0 - return m2_cert.as_pem() + return cert.as_pem() diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index c9a81a4b1..32a1a4eb4 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -8,6 +8,10 @@ from letsencrypt.client import interfaces from letsencrypt.client.display import util as display_util +# Used to make easier to read code. +util = zope.component.getUtility # pylint: disable=invalid-name + + def ask(enhancement): """Display the enhancement to the user. @@ -22,9 +26,10 @@ def ask(enhancement): """ try: - return dispatch[enhancement]() + # Call the appropriate function based on the enhancement + return DISPATCH[enhancement]() except KeyError: - logging.error("Unsupported enhancement given to ask()") + logging.error("Unsupported enhancement given to ask(): %s", enhancement) raise errors.LetsEncryptClientError("Unsupported Enhancement") @@ -50,9 +55,6 @@ def redirect_by_default(): return selection == 1 -util = zope.component.getUtility # pylint: disable=invalid-name - - -dispatch = { # pylint: disable=invalid-name +DISPATCH = { "redirect": redirect_by_default } diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 29ce6929d..d126b0bc6 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -14,18 +14,17 @@ util = zope.component.getUtility # pylint: disable=invalid-name def choose_authenticator(auths, errs): """Allow the user to choose their authenticator. - :param list auths: Where each is a tuple of the form - ('description', 'IAuthenticator') where IAuthenticator is a - :class:`letsencrypt.client.interfaces.IAuthenticator` object or class + :param list auths: Where each of type + :class:`letsencrypt.client.interfaces.IAuthenticator` object :param dict errs: Mapping IAuthenticator objects to error messages :returns: Authenticator selected :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - descs = [auth[0] if auth[1] not in errs else "%s (Misconfigured)" % auth[0] + descs = [auth.description if auth not in errs + else "%s (Misconfigured)" % auth.description for auth in auths] - iauths = [auth[1] for auth in auths] while True: code, index = util(interfaces.IDisplay).menu( @@ -33,12 +32,12 @@ def choose_authenticator(auths, errs): descs, help_label="More Info") if code == display_util.OK: - return iauths[index] + return auths[index] elif code == display_util.HELP: - if iauths[index] in errs: - msg = "Reported Error: %s" % errs[iauths[index]] + if auths[index] in errs: + msg = "Reported Error: %s" % errs[auths[index]] else: - msg = iauths[index].more_info() + msg = auths[index].more_info() util(interfaces.IDisplay).notification( msg, height=display_util.HEIGHT) else: @@ -95,7 +94,7 @@ def _filter_names(names): def _choose_names_manually(): - """Manualy input names for those without an installer.""" + """Manually 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) ") diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 8db334731..055eec6a8 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -6,29 +6,11 @@ import zope.component from letsencrypt.client import interfaces from letsencrypt.client.display import util as display_util +# Convenience call to make for easier to read lines. util = zope.component.getUtility # pylint: disable=invalid-name -def choose_certs(certs): - """Choose a certificate from a menu. - - :param list certs: List of cert dicts. - - :returns: selection (zero-based index) - :rtype: int - - """ - while True: - code, selection = _display_certs(certs) - if code == display_util.OK: - return selection - elif code == display_util.HELP: - more_info_cert(certs[selection]) - else: - exit(0) - - -def _display_certs(certs): +def display_certs(certs): """Display the certificates in a menu for revocation. :param list certs: each is a :class:`letsencrypt.client.revoker.Cert` @@ -65,10 +47,9 @@ def confirm_revocation(cert): :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!" + text = ("Are you sure you would like to revoke the following " + "certificate:{0}{cert}This action cannot be " + "reversed!".format(os.linesep, cert=cert.pretty_print())) return util(interfaces.IDisplay).yesno(text) @@ -78,8 +59,8 @@ def more_info_cert(cert): :param dict cert: cert dict used throughout revoker.py """ - text = "{0}Certificate Information:{0}".format(os.linesep) - text += cert.pretty_print() + text = "Certificate Information:{0}{1}".format( + os.linesep, cert.pretty_print()) util(interfaces.IDisplay).notification(text, height=display_util.HEIGHT) diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index 111e46500..4b2cc3f2c 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -1,4 +1,4 @@ -"""Lets Encrypt display.""" +"""Let's Encrypt display.""" import os import textwrap @@ -13,10 +13,10 @@ HEIGHT = 20 # Display exit codes OK = "ok" -"""Display exit code indicating user acceptance""" +"""Display exit code indicating user acceptance.""" CANCEL = "cancel" -"""Display exit code for a user canceling the display""" +"""Display exit code for a user canceling the display.""" HELP = "help" """Display exit code when for when the user requests more help.""" @@ -63,10 +63,7 @@ class NcursesDisplay(object): :rtype: tuple """ - if help_label: - help_button = True - else: - help_button = False + help_button = bool(help_label) # Can accept either tuples or just the actual choices if choices and isinstance(choices[0], tuple): @@ -111,7 +108,7 @@ class NcursesDisplay(object): return self.dialog.inputbox(message, width=self.width) def yesno(self, message, yes_label="Yes", no_label="No"): - """Display a Yes/No dialog box + """Display a Yes/No dialog box. Yes and No label must begin with different letters. @@ -198,7 +195,7 @@ class FileDisplay(object): def input(self, message): # pylint: disable=no-self-use - """Accept input from the user + """Accept input from the user. :param str message: message to display to the user @@ -219,7 +216,8 @@ class FileDisplay(object): def yesno(self, message, yes_label="Yes", no_label="No"): """Query the user with a yes/no question. - Yes and No label must begin with different letters + Yes and No label must begin with different letters, and must contain at + least one letter each. :param str message: question for the user :param str yes_label: Label of the "Yes" parameter @@ -336,7 +334,7 @@ class FileDisplay(object): self.outfile.write(side_frame) def _wrap_lines(self, msg): # pylint: disable=no-self-use - """Format lines nicely to 80 chars + """Format lines nicely to 80 chars. :param str msg: Original message diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index f53354390..f0b2bf99f 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -13,6 +13,16 @@ class IAuthenticator(zope.interface.Interface): """ + def prepare(): + """Prepare the authenticator. + + Finish up any additional initialization. + + :raises + :class:`letsencrypt.client.errors.LetsEncryptMisconfigurationError` + when full initialization cannot be completed. + """ + def get_chall_pref(domain): """Return list of challenge preferences. @@ -118,6 +128,16 @@ class IInstaller(zope.interface.Interface): """ + def prepare(): + """Prepare the installer. + + Finish up any additional initialization. + + :raises + :class:`letsencrypt.client.errors.LetsEncryptMisconfigurationError` + when full initialization cannot be completed. + """ + def get_all_names(): """Returns all names that may be authenticated.""" diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 58b64b6b2..88c07e569 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -11,6 +11,7 @@ import csv import logging import os import shutil +import tempfile import Crypto.PublicKey.RSA import M2Crypto @@ -64,7 +65,14 @@ class Revoker(object): """ certs = [] - clean_pem = Crypto.PublicKey.RSA.importKey(authkey.pem).exportKey("PEM") + try: + clean_pem = Crypto.PublicKey.RSA.importKey( + authkey.pem).exportKey("PEM") + # https://www.dlitz.net/software/pycrypto/api/current/Crypto.PublicKey.RSA-module.html + except (IndexError, ValueError, TypeError): + raise errors.LetsEncryptRevokerError( + "Invalid key file specified to revoke_from_key") + with open(self.list_path, "rb") as csvfile: csvreader = csv.reader(csvfile) for row in csvreader: @@ -73,9 +81,17 @@ class Revoker(object): # Note: The key can be different than the pub key found in the # certificate. _, b_k = self._row_to_backup(row) - if clean_pem == Crypto.PublicKey.RSA.importKey( - open(b_k).read()).exportKey("PEM"): - certs.append(Cert.fromrow(row, self.config.cert_key_backup)) + try: + if clean_pem == Crypto.PublicKey.RSA.importKey( + open(b_k).read()).exportKey("PEM"): + certs.append( + Cert.fromrow(row, self.config.cert_key_backup)) + except (IndexError, ValueError, TypeError): + # This should never happen given the assumptions of the + # module. If it does, it is probably best to delete the + # the offending key/cert. For now... just raise an exception + raise errors.LetsEncryptRevokerError( + "%s - backup file is corrupted.") if certs: self._safe_revoke(certs) @@ -100,7 +116,7 @@ class Revoker(object): for row in csvreader: cert = Cert.fromrow(row, self.config.cert_key_backup) - if cert == cert_to_revoke: + if cert.get_der() == cert_to_revoke.get_der(): self._safe_revoke([cert]) return @@ -114,15 +130,17 @@ class Revoker(object): while True: if certs: - selection = revocation.choose_certs(certs) + code, selection = revocation.display_certs(certs) - revoked_certs = self._safe_revoke([certs[selection]]) - # Since we are currently only revoking one cert at a time... - if revoked_certs: - # This is safer than using remove as Revoker.Certs only - # check the DER value of the cert. There could potentially - # be multiple backup certs with the same value. - del certs[selection] + if code == display_util.OK: + revoked_certs = self._safe_revoke([certs[selection]]) + # Since we are currently only revoking one cert at a time... + if revoked_certs: + del certs[selection] + elif code == display_util.HELP: + revocation.more_info_cert(certs[selection]) + else: + return else: logging.info( "There are not any trusted Let's Encrypt " @@ -158,7 +176,7 @@ class Revoker(object): return certs def _get_installed_locations(self): - """Get installed locations of certificates + """Get installed locations of certificates. :returns: map from cert sha1 fingerprint to :class:`list` of vhosts where the certificate is installed. @@ -257,8 +275,7 @@ class Revoker(object): :class:`letsencrypt.client.revoker.Cert` """ - list_path2 = os.path.join(self.config.cert_key_backup, "LIST.tmp") - + list_path2 = tempfile.mktemp(".tmp", "LIST") idx = 0 with open(self.list_path, "rb") as orgfile: @@ -447,24 +464,14 @@ class Cert(object): self.backup_path = backup self.backup_key_path = backup_key - def get_installed_msg(self): - """Access installed message.""" - return ", ".join(self.installed) - - def get_subject(self): - """Get subject.""" - return self.cert.get_subject().as_text() - + # I would rather not have outside classes messing with Cert directly. + # (I would like to change M2Crypto -> something else without issues) def get_cn(self): """Get common name.""" return self.cert.get_subject().CN - def get_issuer(self): - """Get issuer.""" - return self.cert.get_issuer().as_text() - def get_fingerprint(self): - """Get sha1 fingerprint.""" + """Get SHA1""" return self.cert.get_fingerprint(md="sha1") def get_not_before(self): @@ -475,13 +482,16 @@ class Cert(object): """Get not_valid_after field.""" return self.cert.get_not_after().get_datetime() - def get_serial(self): - """Get serial number.""" - self.cert.get_serial_number() + def get_der(self): + """Get certificate in der format.""" + return self.cert.as_der() def get_pub_key(self): - """Get public key size.""" - # .. todo:: M2Crypto doesn't support ECC, this will have to be updated + """Get public key size. + + .. todo:: M2Crypto doesn't support ECC, this will have to be updated + + """ return "RSA " + str(self.cert.get_pubkey().size() * 8) def get_san(self): @@ -492,16 +502,17 @@ class Cert(object): return "" def __str__(self): - text = [] - text.append("Subject: %s" % self.get_subject()) - text.append("SAN: %s" % self.get_san()) - text.append("Issuer: %s" % self.get_issuer()) - text.append("Public Key: %s" % self.get_pub_key()) - text.append("Not Before: %s" % str(self.get_not_before())) - text.append("Not After: %s" % str(self.get_not_after())) - text.append("Serial Number: %s" % self.get_serial()) - text.append("SHA1: %s%s" % (self.get_fingerprint(), os.linesep)) - text.append("Installed: %s" % self.get_installed_msg()) + text = [ + "Subject: %s" % self.cert.get_subject().as_text(), + "SAN: %s" % self.get_san(), + "Issuer: %s" % self.cert.get_issuer().as_text(), + "Public Key: %s" % self.get_pub_key(), + "Not Before: %s" % str(self.get_not_before()), + "Not After: %s" % str(self.get_not_after()), + "Serial Number: %s" % self.cert.get_serial_number(), + "SHA1: %s%s" % (self.get_fingerprint(), os.linesep), + "Installed: %s" % ", ".join(self.installed), + ] if self.orig is not None: if self.orig.status == "": @@ -521,6 +532,3 @@ class Cert(object): """Nicely frames a cert str""" frame = "-" * (display_util.WIDTH - 4) + os.linesep return "{frame}{cert}{frame}".format(frame=frame, cert=str(self)) - - def __eq__(self, other): - return self.cert.as_der() == other.cert.as_der() diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index d7b78c9cf..1a4dcef76 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -29,6 +29,8 @@ class StandaloneAuthenticator(object): """ zope.interface.implements(interfaces.IAuthenticator) + description = "Standalone Authenticator" + def __init__(self): self.child_pid = None self.parent_pid = os.getpid() @@ -39,6 +41,14 @@ class StandaloneAuthenticator(object): self.private_key = None self.ssl_conn = None + def prepare(self): + """There is nothing left to setup. + + .. todo:: This should probably do the port check + + """ + pass + def client_signal_handler(self, sig, unused_frame): """Signal handler for the parent process. diff --git a/letsencrypt/client/tests/apache/util.py b/letsencrypt/client/tests/apache/util.py index 78566e1e4..6e8cf7d53 100644 --- a/letsencrypt/client/tests/apache/util.py +++ b/letsencrypt/client/tests/apache/util.py @@ -75,6 +75,8 @@ def get_apache_configurator( work_dir=work_dir), version) + config.prepare() + return config diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 79bf799c9..545485946 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -12,55 +12,55 @@ class DetermineAuthenticatorTest(unittest.TestCase): from letsencrypt.client.standalone_authenticator \ import StandaloneAuthenticator - self.mock_stand = mock.MagicMock(spec=StandaloneAuthenticator) - self.mock_apache = mock.MagicMock(spec=ApacheConfigurator) + self.mock_stand = mock.MagicMock( + spec=StandaloneAuthenticator, description="Apache Web Server") + self.mock_apache = mock.MagicMock( + spec=ApacheConfigurator, description="Standalone Authenticator") self.mock_config = mock.Mock() - self.all_auths = [ - ("Apache Web Server", self.mock_apache, self.mock_config), - ("Standalone", self.mock_stand), - ] + self.all_auths = [self.mock_apache, self.mock_stand] @classmethod def _call(cls, all_auths): from letsencrypt.client.client import determine_authenticator return determine_authenticator(all_auths) - @mock.patch("letsencrypt.client.client.ops.choose_authenticator") + @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") def test_accept_two(self, mock_choose): mock_choose.return_value = self.mock_stand() self.assertEqual(self._call(self.all_auths), self.mock_stand()) def test_accept_one(self): + self.mock_apache.prepare.return_value = self.mock_apache self.assertEqual( - self._call(self.all_auths[:1]), self.mock_apache(self.mock_config)) + self._call(self.all_auths[:1]), self.mock_apache) def test_no_installation_one(self): - self.mock_apache.side_effect = errors.LetsEncryptNoInstallationError + self.mock_apache.prepare.side_effect = \ + errors.LetsEncryptNoInstallationError - self.assertEqual(self._call(self.all_auths), self.mock_stand()) + self.assertEqual(self._call(self.all_auths), self.mock_stand) def test_no_installations(self): - self.mock_apache.side_effect = errors.LetsEncryptNoInstallationError - self.mock_stand.side_effect = errors.LetsEncryptNoInstallationError + self.mock_apache.prepare.side_effect = \ + errors.LetsEncryptNoInstallationError + self.mock_stand.prepare.side_effect = \ + errors.LetsEncryptNoInstallationError - self.assertTrue(self._call(self.all_auths) is None) + self.assertRaises(errors.LetsEncryptClientError, + self._call, + self.all_auths) @mock.patch("letsencrypt.client.client.logging") - @mock.patch("letsencrypt.client.client.ops.choose_authenticator") - def test_misconfigured(self, mock_choose, mock_log): # pylint: disable=unused-argument - self.mock_apache.side_effect = errors.LetsEncryptMisconfigurationError + @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") + def test_misconfigured(self, mock_choose, unused_log): + self.mock_apache.prepare.side_effect = \ + errors.LetsEncryptMisconfigurationError mock_choose.return_value = self.mock_apache self.assertRaises(SystemExit, self._call, self.all_auths) - def test_too_many_params(self): - self.assertRaises( - errors.LetsEncryptClientError, - self._call, - [("desc", self.mock_apache, "1", "2", "3", "4", "5")]) - class RollbackTest(unittest.TestCase): """Test the rollback function.""" @@ -82,59 +82,6 @@ class RollbackTest(unittest.TestCase): self.assertEqual(self.m_install().rollback_checkpoints.call_count, 1) self.assertEqual(self.m_install().restart.call_count, 1) - @mock.patch("letsencrypt.client.client.zope.component.getUtility") - @mock.patch("letsencrypt.client.reverter.Reverter") - @mock.patch("letsencrypt.client.client.determine_installer") - def test_misconfiguration_fixed(self, mock_det, mock_rev, mock_input): - mock_det.side_effect = [errors.LetsEncryptMisconfigurationError, - self.m_install] - mock_input().yesno.return_value = True - - self._call(1) - - # Don't rollback twice... (only on one object) - self.assertEqual(self.m_install().rollback_checkpoints.call_count, 0) - self.assertEqual(mock_rev().rollback_checkpoints.call_count, 1) - - # Only restart once - self.assertEqual(self.m_install.restart.call_count, 1) - - @mock.patch("letsencrypt.client.client.zope.component.getUtility") - @mock.patch("letsencrypt.client.client.logging.warning") - @mock.patch("letsencrypt.client.reverter.Reverter") - @mock.patch("letsencrypt.client.client.determine_installer") - def test_misconfiguration_remains( - self, mock_det, mock_rev, mock_warn, mock_input): - mock_det.side_effect = errors.LetsEncryptMisconfigurationError - - mock_input().yesno.return_value = True - - self._call(1) - - # Don't rollback twice... (only on one object) - self.assertEqual(self.m_install().rollback_checkpoints.call_count, 0) - self.assertEqual(mock_rev().rollback_checkpoints.call_count, 1) - - # Never call restart because init never succeeds - self.assertEqual(self.m_install().restart.call_count, 0) - # There should be a warning about the remaining problem - self.assertEqual(mock_warn.call_count, 1) - - @mock.patch("letsencrypt.client.client.zope.component.getUtility") - @mock.patch("letsencrypt.client.reverter.Reverter") - @mock.patch("letsencrypt.client.client.determine_installer") - def test_user_decides_to_manually_investigate( - self, mock_det, mock_rev, mock_input): - mock_det.side_effect = errors.LetsEncryptMisconfigurationError - - mock_input().yesno.return_value = False - - self._call(1) - - # Neither is ever called - self.assertEqual(self.m_install().rollback_checkpoints.call_count, 0) - self.assertEqual(mock_rev().rollback_checkpoints.call_count, 0) - @mock.patch("letsencrypt.client.client.determine_installer") def test_no_installer(self, mock_det): mock_det.return_value = None diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py index 7af5b34a9..9752c3d04 100644 --- a/letsencrypt/client/tests/crypto_util_test.py +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -62,8 +62,7 @@ class MakeKeyTest(unittest.TestCase): # pylint: disable=too-few-public-methods def test_it(self): # pylint: disable=no-self-use from letsencrypt.client.crypto_util import make_key - # This individual test was taking over 6 seconds... - # I have shortened it... to aid debugging the rest of the project + # Do not test larger keys as it takes too long. M2Crypto.RSA.load_key_string(make_key(1024)) @@ -94,43 +93,5 @@ 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') - - if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index 247a8c3e8..d0b340def 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -1,4 +1,4 @@ -"""Test display.ops.""" +"""Test letsencrypt.client.display.ops.""" import sys import unittest @@ -17,10 +17,7 @@ class ChooseAuthenticatorTest(unittest.TestCase): self.mock_apache().more_info.return_value = "Apache Info" self.mock_stand().more_info.return_value = "Standalone Info" - self.auths = [ - ("Apache Tag", self.mock_apache), - ("Standalone Tag", self.mock_stand) - ] + self.auths = [self.mock_apache, self.mock_stand] self.errs = {self.mock_apache: "This is an error message."} @@ -58,7 +55,7 @@ class ChooseAuthenticatorTest(unittest.TestCase): class GenHttpsNamesTest(unittest.TestCase): - """Test _gen_https_names""" + """Test _gen_https_names.""" def setUp(self): zope.component.provideUtility(display_util.FileDisplay(sys.stdout)) diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index db37effb2..2e0bf0046 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -10,7 +10,7 @@ import zope.component from letsencrypt.client.display import util as display_util -class ChooseCertsTest(unittest.TestCase): +class DisplayCertsTest(unittest.TestCase): def setUp(self): from letsencrypt.client.revoker import Cert base_package = "letsencrypt.client.tests" @@ -25,33 +25,37 @@ class ChooseCertsTest(unittest.TestCase): @classmethod def _call(cls, certs): - from letsencrypt.client.display.revocation import choose_certs - return choose_certs(certs) + from letsencrypt.client.display.revocation import display_certs + return display_certs(certs) @mock.patch("letsencrypt.client.display.revocation.util") def test_revocation(self, mock_util): mock_util().menu.return_value = (display_util.OK, 0) - choice = self._call(self.certs) + code, choice = self._call(self.certs) + self.assertEqual(display_util.OK, code) self.assertTrue(self.certs[choice] == self.cert0) @mock.patch("letsencrypt.client.display.revocation.util") def test_cancel(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, -1) - self.assertRaises(SystemExit, self._call, self.certs) + code, _ = self._call(self.certs) + self.assertEqual(display_util.CANCEL, code) + + +class MoreInfoCertTest(unittest.TestCase): + # pylint: disable=too-few-public-methods + @classmethod + def _call(cls, cert): + from letsencrypt.client.display.revocation import more_info_cert + more_info_cert(cert) @mock.patch("letsencrypt.client.display.revocation.util") def test_more_info(self, mock_util): - mock_util().menu.side_effect = [ - (display_util.HELP, 1), - (display_util.OK, 1), - ] + self._call(mock.MagicMock()) - choice = self._call(self.certs) - - self.assertTrue(self.certs[choice] == self.cert1) self.assertEqual(mock_util().notification.call_count, 1) diff --git a/letsencrypt/client/tests/reverter_test.py b/letsencrypt/client/tests/reverter_test.py index 248e38a59..25da75611 100644 --- a/letsencrypt/client/tests/reverter_test.py +++ b/letsencrypt/client/tests/reverter_test.py @@ -330,7 +330,7 @@ class TestFullCheckpointsReverter(unittest.TestCase): self.assertEqual(read_in(self.config2), "directive-dir2") self.assertFalse(os.path.isfile(config3)) - @mock.patch("letsencrypt.client.client.zope.component.getUtility") + @mock.patch("letsencrypt.client.reverter.zope.component.getUtility") def test_view_config_changes(self, mock_output): """This is not strict as this is subject to change.""" self._setup_three_checkpoints() diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index b415e2154..ff9542444 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -1,4 +1,4 @@ -"""Test :mod:`letsencrypt.client.revoker`.""" +"""Test letsencrypt.client.revoker.""" import csv import os import pkg_resources @@ -10,6 +10,8 @@ import mock from letsencrypt.client import errors from letsencrypt.client import le_util +from letsencrypt.client.apache import configurator +from letsencrypt.client.display import util as display_util class RevokerBase(unittest.TestCase): # pylint: disable=too-few-public-methods @@ -56,8 +58,9 @@ class RevokerTest(RevokerBase): self._store_certs() - self.mock_installer = mock.MagicMock() - self.revoker = Revoker(self.mock_installer, self.mock_config) + self.revoker = Revoker( + mock.MagicMock(spec=configurator.ApacheConfigurator), + self.mock_config) def tearDown(self): shutil.rmtree(self.backup_dir) @@ -77,6 +80,18 @@ class RevokerTest(RevokerBase): self.assertEqual(mock_net.call_count, 2) + @mock.patch("letsencrypt.client.revoker.Crypto.PublicKey.RSA.importKey") + def test_revoke_by_invalid_keys(self, mock_import): + mock_import.side_effect = ValueError + self.assertRaises(errors.LetsEncryptRevokerError, + self.revoker.revoke_from_key, + self.key) + + mock_import.side_effect = [mock.Mock(), IndexError] + self.assertRaises(errors.LetsEncryptRevokerError, + self.revoker.revoke_from_key, + self.key) + @mock.patch("letsencrypt.client.revoker.network." "Network.send_and_receive_expected") @mock.patch("letsencrypt.client.revoker.revocation") @@ -95,8 +110,6 @@ class RevokerTest(RevokerBase): # No revocation went through self.assertEqual(mock_net.call_count, 0) - - @mock.patch("letsencrypt.client.revoker.network." "Network.send_and_receive_expected") @mock.patch("letsencrypt.client.revoker.revocation") @@ -140,9 +153,13 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.client.revoker.revocation") def test_revoke_by_menu(self, mock_display, mock_net): mock_display().confirm_revocation.return_value = True - mock_display.choose_certs.side_effect = [0, SystemExit] + mock_display.display_certs.side_effect = [ + (display_util.HELP, 0), + (display_util.OK, 0), + (display_util.CANCEL, -1), + ] - self.assertRaises(SystemExit, self.revoker.revoke_from_menu) + self.revoker.revoke_from_menu() row0 = self.certs[0].get_row() row1 = self.certs[1].get_row() @@ -153,6 +170,7 @@ class RevokerTest(RevokerBase): self.assertTrue(self._backups_exist(row1)) self.assertEqual(mock_net.call_count, 1) + self.assertEqual(mock_display.more_info_cert.call_count, 1) @mock.patch("letsencrypt.client.revoker.logging") @mock.patch("letsencrypt.client.revoker.network." @@ -160,7 +178,7 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.client.revoker.revocation") def test_revoke_by_menu_delete_all(self, mock_display, mock_net, mock_log): mock_display().confirm_revocation.return_value = True - mock_display.choose_certs.return_value = 0 + mock_display.display_certs.return_value = (display_util.OK, 0) self.revoker.revoke_from_menu() @@ -288,7 +306,7 @@ class RevokerClassMethodsTest(RevokerBase): self.assertTrue(os.path.isfile(self.list_path)) rows = self._get_rows() - i = 0 + for i, row in enumerate(rows): # pylint: disable=protected-access self.assertTrue(os.path.isfile( @@ -297,7 +315,7 @@ class RevokerClassMethodsTest(RevokerBase): Revoker._get_backup(self.backup_dir, i, self.key_path))) self.assertEqual([str(i), self.paths[i], self.key_path], row) - self.assertEqual(i, 1) + self.assertEqual(len(rows), 2) def test_store_one_mixed(self): from letsencrypt.client.revoker import Revoker diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 955afc0d6..3d8f19ba8 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -549,10 +549,26 @@ class MoreInfoTest(unittest.TestCase): StandaloneAuthenticator self.authenticator = StandaloneAuthenticator() - def test_chall_pref(self): + def test_more_info(self): """Make sure exceptions aren't raised.""" self.authenticator.more_info() +class InitTest(unittest.TestCase): + """Tests for more_info() method. (trivially)""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_prepare(self): + """Make sure exceptions aren't raised. + + .. todo:: Add on more once things are setup appropriately. + + """ + self.authenticator.prepare() + + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 06add9add..16d7177d0 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -22,7 +22,7 @@ from letsencrypt.client import log from letsencrypt.client import standalone_authenticator as standalone from letsencrypt.client.apache import configurator from letsencrypt.client.display import util as display_util -from letsencrypt.client.display import ops +from letsencrypt.client.display import ops as display_ops def create_parser(): @@ -124,7 +124,7 @@ def main(): # pylint: disable=too-many-branches client.view_config_changes(config) sys.exit() - if args.revoke or args.rev_cert or args.rev_key: + if args.revoke or args.rev_cert is not None or args.rev_key is not None: client.revoke(config, args.no_confirm, args.rev_cert, args.rev_key) sys.exit() @@ -135,10 +135,9 @@ def main(): # pylint: disable=too-many-branches if not args.eula: display_eula() - # list of (Description, Known Authenticator classes, init arguments) all_auths = [ - ("Apache Web Server", configurator.ApacheConfigurator, config), - ("Standalone Authenticator", standalone.StandaloneAuthenticator), + configurator.ApacheConfigurator(config), + standalone.StandaloneAuthenticator(), ] auth = client.determine_authenticator(all_auths) if auth is None: @@ -152,7 +151,10 @@ def main(): # pylint: disable=too-many-branches # This is simple and avoids confusion right now. installer = None - doms = ops.choose_names(installer) if args.domains is None else args.domains + if args.domains is None: + doms = display_ops.choose_names(installer) + else: + doms = args.domains # Prepare for init of Client if args.authkey is None: From f5c30b383a2b02cf626aa3227eff53d0c528701a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 23 Feb 2015 23:18:07 -0800 Subject: [PATCH 27/46] second round revisions --- letsencrypt/client/apache/configurator.py | 15 +++--- letsencrypt/client/client.py | 22 ++++---- letsencrypt/client/display/enhancements.py | 2 +- letsencrypt/client/display/revocation.py | 17 +++--- letsencrypt/client/display/util.py | 37 ++++++++----- letsencrypt/client/interfaces.py | 18 ++++--- letsencrypt/client/revoker.py | 53 ++++++++++--------- .../client/standalone_authenticator.py | 1 - letsencrypt/client/tests/client_test.py | 20 +++---- .../client/tests/display/revocation_test.py | 2 +- letsencrypt/client/tests/display/util_test.py | 32 +++++------ letsencrypt/client/tests/revoker_test.py | 2 +- .../tests/standalone_authenticator_test.py | 8 +-- tox.ini | 2 +- 14 files changed, 126 insertions(+), 105 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 2ecf30104..8f066870e 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -65,6 +65,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :ivar config: Configuration. :type config: :class:`~letsencrypt.client.interfaces.IConfig` + :ivar parser: Handles low level parsing + :type parser: :class:`letsencrypt.client.apache.parser` + :ivar tup version: version of Apache :ivar list vhosts: All vhosts found in the configuration (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) @@ -92,13 +95,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Add name_server association dict self.assoc = dict() # Add number of outstanding challenges - self.chall_out = 0 + self._chall_out = 0 # These will be set in the prepare function self.parser = None self.version = version self.vhosts = None - self.enhance_func = {"redirect": self._enable_redirect} + self._enhance_func = {"redirect": self._enable_redirect} def prepare(self): """Prepare the authenticator/installer.""" @@ -533,7 +536,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ try: - return self.enhance_func[enhancement]( + return self._enhance_func[enhancement]( self.choose_vhost(domain), options) except ValueError: raise errors.LetsEncryptConfiguratorError( @@ -987,7 +990,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :rtype: list """ - self.chall_out += len(chall_list) + self._chall_out += len(chall_list) responses = [None] * len(chall_list) apache_dvsni = dvsni.ApacheDvsni(self) @@ -1013,10 +1016,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def cleanup(self, chall_list): """Revert all challenges.""" - self.chall_out -= len(chall_list) + self._chall_out -= len(chall_list) # If all of the challenges have been finished, clean up everything - if self.chall_out <= 0: + if self._chall_out <= 0: self.revert_challenge_config() self.restart() diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 107a5dfc4..17f72b68e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -49,7 +49,8 @@ class Client(object): :param dv_auth: IAuthenticator that can solve the :const:`letsencrypt.client.constants.DV_CHALLENGES`. - :func:`letsencrypt.client.interfaces.IAuthenticator.prepare` + The :meth:`~letsencrypt.client.interfaces.IAuthenticator.prepare` + must have already been run. :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ @@ -349,14 +350,13 @@ def init_csr(privkey, names, cert_dir): def determine_authenticator(all_auths): """Returns a valid IAuthenticator. - :param list all_auths: Where each is a tuple of the form - ('description', 'IAuthenticator', *options..) where IAuthenticator is a - :class:`letsencrypt.client.interfaces.IAuthenticator` object or class - and options are the parameters used to initialize the authenticator. + :param list all_auths: Where each is a + :class:`letsencrypt.client.interfaces.IAuthenticator` object :returns: Valid Authenticator object or None - :raises :class:`letsencrypt.client.errors.LetsEncryptClientError` + :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If no + authenticator is available. """ # Available Authenticator objects @@ -367,12 +367,11 @@ def determine_authenticator(all_auths): for pot_auth in all_auths: try: pot_auth.prepare() - avail_auths.append(pot_auth) except errors.LetsEncryptMisconfigurationError as err: errs[pot_auth] = err - avail_auths.append(pot_auth) except errors.LetsEncryptNoInstallationError: continue + avail_auths.append(pot_auth) if len(avail_auths) > 1: auth = display_ops.choose_authenticator(avail_auths, errs) @@ -396,14 +395,17 @@ def determine_installer(config): :param config: Configuration. :type config: :class:`letsencrypt.client.interfaces.IConfig` + :returns: IInstaller or `None` + :rtype: :class:`~letsencrypt.client.interfaces.IInstaller` or `None` + """ + installer = configurator.ApacheConfigurator(config) try: - installer = configurator.ApacheConfigurator(config) installer.prepare() return installer except errors.LetsEncryptNoInstallationError: logging.info("Unable to find a way to install the certificate.") - return None + return except errors.LetsEncryptMisconfigurationError: # This will have to be changed in the future... return installer diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index 32a1a4eb4..25ca8e5af 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -8,7 +8,7 @@ from letsencrypt.client import interfaces from letsencrypt.client.display import util as display_util -# Used to make easier to read code. +# Define a helper function to avoid verbose code util = zope.component.getUtility # pylint: disable=invalid-name diff --git a/letsencrypt/client/display/revocation.py b/letsencrypt/client/display/revocation.py index 055eec6a8..65dbd9f63 100644 --- a/letsencrypt/client/display/revocation.py +++ b/letsencrypt/client/display/revocation.py @@ -6,7 +6,7 @@ import zope.component from letsencrypt.client import interfaces from letsencrypt.client.display import util as display_util -# Convenience call to make for easier to read lines. +# Define a helper function to avoid verbose code util = zope.component.getUtility # pylint: disable=invalid-name @@ -47,10 +47,10 @@ def confirm_revocation(cert): :rtype: bool """ - text = ("Are you sure you would like to revoke the following " - "certificate:{0}{cert}This action cannot be " - "reversed!".format(os.linesep, cert=cert.pretty_print())) - return util(interfaces.IDisplay).yesno(text) + return util(interfaces.IDisplay).yesno( + "Are you sure you would like to revoke the following " + "certificate:{0}{cert}This action cannot be reversed!".format( + os.linesep, cert=cert.pretty_print())) def more_info_cert(cert): @@ -59,9 +59,10 @@ def more_info_cert(cert): :param dict cert: cert dict used throughout revoker.py """ - text = "Certificate Information:{0}{1}".format( - os.linesep, cert.pretty_print()) - util(interfaces.IDisplay).notification(text, height=display_util.HEIGHT) + util(interfaces.IDisplay).notification( + "Certificate Information:{0}{1}".format( + os.linesep, cert.pretty_print()), + height=display_util.HEIGHT) def success_revocation(cert): diff --git a/letsencrypt/client/display/util.py b/letsencrypt/client/display/util.py index 4b2cc3f2c..a55716a73 100644 --- a/letsencrypt/client/display/util.py +++ b/letsencrypt/client/display/util.py @@ -37,6 +37,11 @@ class NcursesDisplay(object): # pylint: disable=unused-argument """Display a notification to the user and wait for user acceptance. + .. todo:: It probably makes sense to use one of the transient message + types for pause. It isn't straightforward how best to approach + the matter though given the context of our messages. + http://pythondialog.sourceforge.net/doc/widgets.html#displaying-transient-messages + :param str message: Message to display :param int height: Height of the dialog box :param bool pause: Not applicable to NcursesDisplay @@ -63,15 +68,21 @@ class NcursesDisplay(object): :rtype: tuple """ - help_button = bool(help_label) + menu_options = { + "choices": choices, + "ok_label": ok_label, + "cancel_label": cancel_label, + "help_button": bool(help_label), + "help_label": help_label, + "width": self.width, + "height": self.height, + "menu_height": self.height-6, + } # 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) + # pylint: disable=star-args + code, selection = self.dialog.menu(message, **menu_options) # Return the selection index for i, choice in enumerate(choices): @@ -81,14 +92,12 @@ class NcursesDisplay(object): return code, -1 else: - choices = [ + # "choices" is not formatted the way the dialog.menu expects... + menu_options["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) + # pylint: disable=star-args + code, tag = self.dialog.menu(message, **menu_options) if code == CANCEL: return code, -1 @@ -263,8 +272,8 @@ class FileDisplay(object): while True: self._print_menu(message, tags) - code, ans = self.input("Select the appropriate numbers " - "separated by commas and/or spaces ") + code, ans = self.input("Select the appropriate numbers separated " + "by commas and/or spaces") if code == OK: indices = separate_list_input(ans) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index f0b2bf99f..c2dec8e62 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -18,9 +18,11 @@ class IAuthenticator(zope.interface.Interface): Finish up any additional initialization. - :raises - :class:`letsencrypt.client.errors.LetsEncryptMisconfigurationError` - when full initialization cannot be completed. + :raises :class:`~.errors.LetsEncryptMisconfigurationError`: when + full initialization cannot be completed. + :raises :class:`~.errors.LetsEncryptNoInstallationError`: when the + necessary programs/files cannot be located. + """ def get_chall_pref(domain): @@ -133,9 +135,11 @@ class IInstaller(zope.interface.Interface): Finish up any additional initialization. - :raises - :class:`letsencrypt.client.errors.LetsEncryptMisconfigurationError` - when full initialization cannot be completed. + :raises :class:`~.errors.LetsEncryptMisconfigurationError`: when + full initialization cannot be completed. + :raises :class:`~.errors.LetsEncryptNoInstallationError`: when the + necessary programs/files cannot be located. + """ def get_all_names(): @@ -247,7 +251,7 @@ class IDisplay(zope.interface.Interface): """ def input(message): - """Accept input from the user + """Accept input from the user. :param str message: message to display to the user diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index 88c07e569..cc075ea1f 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -191,12 +191,12 @@ class Revoker(object): try: cert_sha1 = M2Crypto.X509.load_cert( cert_path).get_fingerprint(md="sha1") - if cert_sha1 in csha1_vhlist: - csha1_vhlist[cert_sha1].append(path) - else: - csha1_vhlist[cert_sha1] = [path] except (IOError, M2Crypto.X509.X509Error): continue + if cert_sha1 in csha1_vhlist: + csha1_vhlist[cert_sha1].append(path) + else: + csha1_vhlist[cert_sha1] = [path] return csha1_vhlist @@ -216,9 +216,9 @@ class Revoker(object): if self.no_confirm or revocation.confirm_revocation(cert): try: self._acme_revoke(cert) - success_list.append(cert) revocation.success_revocation(cert) + except errors.LetsEncryptClientError: # TODO: Improve error handling when networking is set... logging.error( @@ -238,19 +238,21 @@ class Revoker(object): :returns: TODO """ + # These will both have to change in the future away from M2Crypto + # pylint: disable=protected-access + certificate = acme_util.ComparableX509(cert._cert) try: - certificate = acme_util.ComparableX509(cert.cert) with open(cert.backup_key_path, "rU") as backup_key_file: key = Crypto.PublicKey.RSA.importKey(backup_key_file.read()) # If the key file doesn't exist... or is corrupted - except (OSError, IOError): - raise errors.LetsEncryptRevokerError("Unable to read key file") + except (IndexError, ValueError, TypeError): + raise errors.LetsEncryptRevokerError( + "Corrupted backup key file: %s" % cert.backup_key_path) # TODO: Catch error associated with already revoked and proceed. return self.network.send_and_receive_expected( - messages.RevocationRequest.create( - certificate=certificate, key=key), + messages.RevocationRequest.create(certificate=certificate, key=key), messages.Revocation) def _remove_certs_keys(self, cert_list): # pylint: disable=no-self-use @@ -365,8 +367,8 @@ class Revoker(object): class Cert(object): """Cert object used for Revocation convenience. - :ivar cert: M2Crypto X509 cert - :type cert: :class:`M2Crypto.X509` + :ivar _cert: M2Crypto X509 cert + :type _cert: :class:`M2Crypto.X509` :ivar int idx: convenience index used for listing :ivar orig: (`str` path - original certificate, `str` status) @@ -394,7 +396,7 @@ class Cert(object): """ try: - self.cert = M2Crypto.X509.load_cert(cert_path) + self._cert = M2Crypto.X509.load_cert(cert_path) except (IOError, M2Crypto.X509.X509Error): raise errors.LetsEncryptRevokerError( "Error loading certificate: %s" % cert_path) @@ -464,27 +466,26 @@ class Cert(object): self.backup_path = backup self.backup_key_path = backup_key - # I would rather not have outside classes messing with Cert directly. - # (I would like to change M2Crypto -> something else without issues) + # M2Crypto is eventually going to be replaced, hence the reason for _cert def get_cn(self): """Get common name.""" - return self.cert.get_subject().CN + return self._cert.get_subject().CN def get_fingerprint(self): - """Get SHA1""" - return self.cert.get_fingerprint(md="sha1") + """Get SHA1 fingerprint.""" + return self._cert.get_fingerprint(md="sha1") def get_not_before(self): """Get not_valid_before field.""" - return self.cert.get_not_before().get_datetime() + return self._cert.get_not_before().get_datetime() def get_not_after(self): """Get not_valid_after field.""" - return self.cert.get_not_after().get_datetime() + return self._cert.get_not_after().get_datetime() def get_der(self): """Get certificate in der format.""" - return self.cert.as_der() + return self._cert.as_der() def get_pub_key(self): """Get public key size. @@ -492,24 +493,24 @@ class Cert(object): .. todo:: M2Crypto doesn't support ECC, this will have to be updated """ - return "RSA " + str(self.cert.get_pubkey().size() * 8) + return "RSA " + str(self._cert.get_pubkey().size() * 8) def get_san(self): """Get subject alternative name if available.""" try: - return self.cert.get_ext("subjectAltName").get_value() + return self._cert.get_ext("subjectAltName").get_value() except LookupError: return "" def __str__(self): text = [ - "Subject: %s" % self.cert.get_subject().as_text(), + "Subject: %s" % self._cert.get_subject().as_text(), "SAN: %s" % self.get_san(), - "Issuer: %s" % self.cert.get_issuer().as_text(), + "Issuer: %s" % self._cert.get_issuer().as_text(), "Public Key: %s" % self.get_pub_key(), "Not Before: %s" % str(self.get_not_before()), "Not After: %s" % str(self.get_not_after()), - "Serial Number: %s" % self.cert.get_serial_number(), + "Serial Number: %s" % self._cert.get_serial_number(), "SHA1: %s%s" % (self.get_fingerprint(), os.linesep), "Installed: %s" % ", ".join(self.installed), ] diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py index 1a4dcef76..b19a74f36 100755 --- a/letsencrypt/client/standalone_authenticator.py +++ b/letsencrypt/client/standalone_authenticator.py @@ -47,7 +47,6 @@ class StandaloneAuthenticator(object): .. todo:: This should probably do the port check """ - pass def client_signal_handler(self, sig, unused_frame): """Signal handler for the parent process. diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 545485946..2a3c733fb 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -9,8 +9,8 @@ from letsencrypt.client import errors class DetermineAuthenticatorTest(unittest.TestCase): def setUp(self): from letsencrypt.client.apache.configurator import ApacheConfigurator - from letsencrypt.client.standalone_authenticator \ - import StandaloneAuthenticator + from letsencrypt.client.standalone_authenticator import ( + StandaloneAuthenticator) self.mock_stand = mock.MagicMock( spec=StandaloneAuthenticator, description="Apache Web Server") @@ -37,16 +37,16 @@ class DetermineAuthenticatorTest(unittest.TestCase): self._call(self.all_auths[:1]), self.mock_apache) def test_no_installation_one(self): - self.mock_apache.prepare.side_effect = \ - errors.LetsEncryptNoInstallationError + self.mock_apache.prepare.side_effect = ( + errors.LetsEncryptNoInstallationError) self.assertEqual(self._call(self.all_auths), self.mock_stand) def test_no_installations(self): - self.mock_apache.prepare.side_effect = \ - errors.LetsEncryptNoInstallationError - self.mock_stand.prepare.side_effect = \ - errors.LetsEncryptNoInstallationError + self.mock_apache.prepare.side_effect = ( + errors.LetsEncryptNoInstallationError) + self.mock_stand.prepare.side_effect = ( + errors.LetsEncryptNoInstallationError) self.assertRaises(errors.LetsEncryptClientError, self._call, @@ -55,8 +55,8 @@ class DetermineAuthenticatorTest(unittest.TestCase): @mock.patch("letsencrypt.client.client.logging") @mock.patch("letsencrypt.client.client.display_ops.choose_authenticator") def test_misconfigured(self, mock_choose, unused_log): - self.mock_apache.prepare.side_effect = \ - errors.LetsEncryptMisconfigurationError + self.mock_apache.prepare.side_effect = ( + errors.LetsEncryptMisconfigurationError) mock_choose.return_value = self.mock_apache self.assertRaises(SystemExit, self._call, self.all_auths) diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index 2e0bf0046..ee7c0a9cb 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -35,7 +35,7 @@ class DisplayCertsTest(unittest.TestCase): code, choice = self._call(self.certs) self.assertEqual(display_util.OK, code) - self.assertTrue(self.certs[choice] == self.cert0) + self.assertEqual(self.certs[choice], self.cert0) @mock.patch("letsencrypt.client.display.revocation.util") def test_cancel(self, mock_util): diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 5a0bb4c1f..e95d313b9 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -42,6 +42,18 @@ class NcursesDisplayTest(DisplayT): super(NcursesDisplayTest, self).setUp() self.displayer = display_util.NcursesDisplay() + self.default_menu_options = { + "choices": self.choices, + "ok_label": "OK", + "cancel_label": "Cancel", + "help_button": False, + "help_label": "", + "width": display_util.WIDTH, + "height": display_util.HEIGHT, + "menu_height": display_util.HEIGHT-6, + } + + @mock.patch("letsencrypt.client.display.util.dialog.Dialog.msgbox") def test_notification(self, mock_msgbox): """Kind of worthless... one liner.""" @@ -53,11 +65,7 @@ class NcursesDisplayTest(DisplayT): mock_menu.return_value = (display_util.OK, "First") ret = self.displayer.menu("Message", self.choices) - mock_menu.assert_called_with( - "Message", choices=self.choices, ok_label="OK", - cancel_label="Cancel", - help_button=False, help_label="", - width=display_util.WIDTH, height=display_util.HEIGHT) + mock_menu.assert_called_with("Message", **self.default_menu_options) self.assertEqual(ret, (display_util.OK, 0)) @@ -67,11 +75,7 @@ class NcursesDisplayTest(DisplayT): ret = self.displayer.menu("Message", self.choices) - mock_menu.assert_called_with( - "Message", choices=self.choices, ok_label="OK", - cancel_label="Cancel", - help_button=False, help_label="", - width=display_util.WIDTH, height=display_util.HEIGHT) + mock_menu.assert_called_with("Message", **self.default_menu_options) self.assertEqual(ret, (display_util.CANCEL, -1)) @@ -81,11 +85,9 @@ class NcursesDisplayTest(DisplayT): ret = self.displayer.menu("Message", self.tags, help_label="More Info") - mock_menu.assert_called_with( - "Message", choices=self.tags_choices, ok_label="OK", - cancel_label="Cancel", - help_button=True, help_label="More Info", - width=display_util.WIDTH, height=display_util.HEIGHT) + self.default_menu_options.update( + choices=self.tags_choices, help_button=True, help_label="More Info") + mock_menu.assert_called_with("Message", **self.default_menu_options) self.assertEqual(ret, (display_util.OK, 0)) diff --git a/letsencrypt/client/tests/revoker_test.py b/letsencrypt/client/tests/revoker_test.py index ff9542444..f5a940df8 100644 --- a/letsencrypt/client/tests/revoker_test.py +++ b/letsencrypt/client/tests/revoker_test.py @@ -206,7 +206,7 @@ class RevokerTest(RevokerBase): @mock.patch("letsencrypt.client.revoker.Crypto.PublicKey.RSA.importKey") def test_acme_revoke_failure(self, mock_crypto): # pylint: disable=protected-access - mock_crypto.side_effect = IOError + mock_crypto.side_effect = ValueError self.assertRaises(errors.LetsEncryptClientError, self.revoker._acme_revoke, self.certs[0]) diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py index 3d8f19ba8..6811371df 100644 --- a/letsencrypt/client/tests/standalone_authenticator_test.py +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -545,8 +545,8 @@ class CleanupTest(unittest.TestCase): class MoreInfoTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ - StandaloneAuthenticator + from letsencrypt.client.standalone_authenticator import ( + StandaloneAuthenticator) self.authenticator = StandaloneAuthenticator() def test_more_info(self): @@ -557,8 +557,8 @@ class MoreInfoTest(unittest.TestCase): class InitTest(unittest.TestCase): """Tests for more_info() method. (trivially)""" def setUp(self): - from letsencrypt.client.standalone_authenticator import \ - StandaloneAuthenticator + from letsencrypt.client.standalone_authenticator import ( + StandaloneAuthenticator) self.authenticator = StandaloneAuthenticator() def test_prepare(self): diff --git a/tox.ini b/tox.ini index 59d943a3e..d4af50fa5 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ setenv = basepython = python2.7 commands = pip install -e .[testing] - python setup.py nosetests --with-coverage --cover-min-percentage=84 + python setup.py nosetests --with-coverage --cover-min-percentage=83 [testenv:lint] # recent versions of pylint do not support Python 2.6 (#97, #187) From 17741a0fbf3ce7381a249f270d24779315471de1 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Mon, 23 Feb 2015 23:19:28 -0800 Subject: [PATCH 28/46] remove excess whitespace --- letsencrypt/client/apache/configurator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 8f066870e..78e76239b 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -127,7 +127,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): temp_install(self.config.apache_mod_ssl_conf) - def deploy_cert(self, domain, cert, key, cert_chain=None): """Deploys certificate to specified virtual host. From e7bddb5a87c7a3745b525f49339ea1245bb60a72 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 15:01:09 +0000 Subject: [PATCH 29/46] requirements.txt for readthedocs.org (fixes #259) --- readthedocs.org.requirements.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 readthedocs.org.requirements.txt diff --git a/readthedocs.org.requirements.txt b/readthedocs.org.requirements.txt new file mode 100644 index 000000000..00d1a61ea --- /dev/null +++ b/readthedocs.org.requirements.txt @@ -0,0 +1,10 @@ +# readthedocs.org gives no way to change the install command to "pip +# install -e .[dev]" (that would in turn install documentation +# dependencies), but it allows to specify a requirements.txt file at +# https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259) + +# Although ReadTheDocs certainly doesn't need to install the project +# in --editable mode (-e), just "pip install .[dev]" does not work as +# expected and "pip install -e .[dev]" must be used instead + +-e .[dev] From d824002f01f120f7762e6307873976304190e343 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 15:30:01 +0000 Subject: [PATCH 30/46] Add readthedocs.org badge. Update alts. Move to top. --- README.rst | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 95f3cfa27..610ce38f2 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ About the Let's Encrypt Client ============================== +|build-status| |coverage| |docs| + In short: getting and installing SSL/TLS certificates made easy (`watch demo video`_). The Let's Encrypt Client is a tool to automatically receive and install @@ -25,10 +27,17 @@ All you need to do is: **Encrypt ALL the things!** -.. image:: https://travis-ci.org/letsencrypt/lets-encrypt-preview.svg?branch=master - :target: https://travis-ci.org/letsencrypt/lets-encrypt-preview -.. image:: https://coveralls.io/repos/letsencrypt/lets-encrypt-preview/badge.svg?branch=master - :target: https://coveralls.io/r/letsencrypt/lets-encrypt-preview +.. |build-status| image:: https://travis-ci.org/letsencrypt/lets-encrypt-preview.svg?branch=master + :target: https://travis-ci.org/letsencrypt/lets-encrypt-preview + :alt: Travis CI status + +.. |coverage| image:: https://coveralls.io/repos/letsencrypt/lets-encrypt-preview/badge.svg?branch=master + :target: https://coveralls.io/r/letsencrypt/lets-encrypt-preview + :alt: Coverage status + +.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/ + :target: https://readthedocs.org/projects/letsencrypt/ + :alt: Documentation status .. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU From 4bb9914fa39c2c4475d68d2c6ec66b61b5d222c5 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:01:13 +0000 Subject: [PATCH 31/46] split dev_extras into dev_extras+docs_extras --- readthedocs.org.requirements.txt | 8 ++++---- setup.cfg | 2 +- setup.py | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/readthedocs.org.requirements.txt b/readthedocs.org.requirements.txt index 00d1a61ea..27cccb0a6 100644 --- a/readthedocs.org.requirements.txt +++ b/readthedocs.org.requirements.txt @@ -1,10 +1,10 @@ # readthedocs.org gives no way to change the install command to "pip -# install -e .[dev]" (that would in turn install documentation +# install -e .[docs]" (that would in turn install documentation # dependencies), but it allows to specify a requirements.txt file at # https://readthedocs.org/dashboard/letsencrypt/advanced/ (c.f. #259) # Although ReadTheDocs certainly doesn't need to install the project -# in --editable mode (-e), just "pip install .[dev]" does not work as -# expected and "pip install -e .[dev]" must be used instead +# in --editable mode (-e), just "pip install .[docs]" does not work as +# expected and "pip install -e .[docs]" must be used instead --e .[dev] +-e .[docs] diff --git a/setup.cfg b/setup.cfg index 3369f2993..75b1ef1a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ zip_ok = false [aliases] -dev = develop easy_install letsencrypt[testing,dev] +dev = develop easy_install letsencrypt[dev,docs,testing] [nosetests] nocapture=1 diff --git a/setup.py b/setup.py index f2550446f..d35abc176 100755 --- a/setup.py +++ b/setup.py @@ -40,6 +40,9 @@ install_requires = [ dev_extras = [ 'pylint>=1.4.0', # upstream #248 +] + +docs_extras = [ 'repoze.sphinx.autointerface', 'Sphinx', ] @@ -73,6 +76,7 @@ setup( test_suite='letsencrypt', extras_require={ 'dev': dev_extras, + 'docs': docs_extras, 'testing': testing_extras, }, entry_points={ From ed99d809f1a23cb402427921fc6382933bce8d5d Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:02:01 +0000 Subject: [PATCH 32/46] docs: letsencrypt --help path (fixes #260) --- docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/using.rst b/docs/using.rst index d5b008670..2ebe1d078 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -53,7 +53,7 @@ The letsencrypt commandline tool has a builtin help: :: - letsencrypt --help + ./venv/bin/letsencrypt --help .. _augeas: http://augeas.net/ From edce44024b8593d2f8f7ae631d341dff5453e7bf Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:12:31 +0000 Subject: [PATCH 33/46] Use sphinx_rtd_theme locally. --- docs/conf.py | 10 +++++++++- setup.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 018d2afed..24792d644 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -117,7 +117,15 @@ pygments_style = 'sphinx' # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' + +# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs +# on_rtd is whether we are on readthedocs.org +on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +if not on_rtd: # only import and set the theme if we're building docs locally + import sphinx_rtd_theme + html_theme = 'sphinx_rtd_theme' + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +# otherwise, readthedocs.org uses their theme by default, so no need to specify it # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/setup.py b/setup.py index d35abc176..0568844d8 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ dev_extras = [ docs_extras = [ 'repoze.sphinx.autointerface', 'Sphinx', + 'sphinx_rtd_theme', ] testing_extras = [ From 00af984bac8f7648396e36df5233dcba0b0b3dd4 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:17:25 +0000 Subject: [PATCH 34/46] Fix docs build errors --- letsencrypt/client/interfaces.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 04c7d35e7..efdb17c0a 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -30,9 +30,10 @@ class IAuthenticator(zope.interface.Interface): :param list chall_list: List of namedtuple types defined in :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.). + - chall_list will never be empty - chall_list will only contain types found within - :func:`get_chall_pref` + :func:`get_chall_pref` :returns: ACME Challenge responses or if it cannot be completed then: @@ -52,7 +53,7 @@ class IAuthenticator(zope.interface.Interface): :mod:`letsencrypt.client.challenge_util` (``DvsniChall``, etc.) - Only challenges given previously in the perform function will be - found in chall_list. + found in chall_list. - chall_list will never be empty """ From 31f444e4bae3c50fb93b57c19025a66ba2859d3b Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:49:59 +0000 Subject: [PATCH 35/46] Mock augeas and M2Crypto in docs (fixes #262). --- docs/conf.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 24792d644..3e4690a0f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,13 @@ import os import re import sys +import mock + + +# http://docs.readthedocs.org/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules +sys.modules.update( + (mod_name, mock.MagicMock()) for mod_name in ['augeas', 'M2Crypto']) + here = os.path.abspath(os.path.dirname(__file__)) # read version number (and other metadata) from package init From a5551604c6bf6e08192cbba36dc696a7272129f4 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 16:55:25 +0000 Subject: [PATCH 36/46] Reference docs augeas/M2Crypto hack to github bug --- docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/conf.py b/docs/conf.py index 3e4690a0f..2f25c9a7b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,6 +21,7 @@ import mock # http://docs.readthedocs.org/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules +# c.f. #262 sys.modules.update( (mod_name, mock.MagicMock()) for mod_name in ['augeas', 'M2Crypto']) From 21d210b8ce36e30425beab1baaae9a1385cb602e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 18:03:19 +0000 Subject: [PATCH 37/46] Travis: quicker "install" --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index d790e94f9..ec069438d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,14 +4,14 @@ before_install: > travis_retry sudo apt-get install python python-setuptools python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev -install: - - travis_retry python setup.py dev # installs tox - - travis_retry pip install coveralls - -script: travis_retry tox +install: "travis_retry pip install tox coveralls" +script: "travis_retry tox" after_success: coveralls +# using separate envs with different TOXENVs creates 4x1 Travis build +# matrix, which allows us to clearly distinguish which component under +# test has failed env: - TOXENV=py26 - TOXENV=py27 From 05ac2bde9a4915c4543db32fd0b6ef4cf161d564 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 24 Feb 2015 18:12:26 +0000 Subject: [PATCH 38/46] travis: install libffi-dev --- .travis.yml | 2 ++ docs/using.rst | 1 + 2 files changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index ec069438d..a968acaa6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ language: python +# please keep this in sync with docs/using.rst (Ubuntu section, apt-get) before_install: > travis_retry sudo apt-get install python python-setuptools python-virtualenv python-dev gcc swig dialog libaugeas0 libssl-dev + libffi-dev ca-certificates install: "travis_retry pip install tox coveralls" script: "travis_retry tox" diff --git a/docs/using.rst b/docs/using.rst index d5b008670..53ecb000c 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -27,6 +27,7 @@ Ubuntu gcc swig dialog libaugeas0 libssl-dev libffi-dev \ ca-certificates +.. Please keep the above command in sync with .travis.yml (before_install) Mac OSX ------- From ad1dfc470192d18ffc0119184ab3b13bca92437b Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 11:53:57 -0800 Subject: [PATCH 39/46] iteration --- letsencrypt/client/client.py | 11 +++---- letsencrypt/client/display/enhancements.py | 2 +- letsencrypt/client/display/ops.py | 17 ++++++----- letsencrypt/client/interfaces.py | 16 +++++----- letsencrypt/client/reverter.py | 17 ++++++----- letsencrypt/client/revoker.py | 14 ++++----- letsencrypt/client/tests/client_test.py | 2 +- letsencrypt/client/tests/display/ops_test.py | 30 ++++++++++++------- letsencrypt/client/tests/display/util_test.py | 29 +++++++++--------- letsencrypt/scripts/main.py | 18 ++++++++--- 10 files changed, 88 insertions(+), 68 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 17f72b68e..dacb3fdcc 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -214,7 +214,7 @@ class Client(object): :param redirect: If traffic should be forwarded from HTTP to HTTPS. :type redirect: bool or None - :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: if + :raises letsencrypt.client.errors.LetsEncryptClientError: if no installer is specified in the client. """ @@ -261,7 +261,8 @@ def validate_key_csr(privkey, csr=None): :param csr: CSR :type csr: :class:`letsencrypt.client.le_util.CSR` - :raises LetsEncryptClientError: if validation fails + :raises letsencrypt.client.errors.LetsEncryptClientError: when + validation fails """ # TODO: Handle all of these problems appropriately @@ -355,7 +356,7 @@ def determine_authenticator(all_auths): :returns: Valid Authenticator object or None - :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If no + :raises letsencrypt.client.errors.LetsEncryptClientError: If no authenticator is available. """ @@ -380,11 +381,11 @@ def determine_authenticator(all_auths): else: raise errors.LetsEncryptClientError("No Authenticators available.") - if auth in errs: + if auth and auth in errs: logging.error("Please fix the configuration for the Authenticator. " "The following error message was received: " "%s", errs[auth]) - sys.exit(1) + return return auth diff --git a/letsencrypt/client/display/enhancements.py b/letsencrypt/client/display/enhancements.py index 25ca8e5af..d7ea3a66a 100644 --- a/letsencrypt/client/display/enhancements.py +++ b/letsencrypt/client/display/enhancements.py @@ -21,7 +21,7 @@ def ask(enhancement): :returns: True if feature is desired, False otherwise :rtype: bool - :raises :class:`letsencrypt.client.errors.LetsEncryptClientError`: If + :raises letsencrypt.client.errors.LetsEncryptClientError: If the enhancement provided is not supported. """ diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index d126b0bc6..5c644246c 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -1,6 +1,5 @@ """Contains UI methods for LE user operations.""" import os -import sys import zope.component @@ -19,7 +18,7 @@ def choose_authenticator(auths, errs): :param dict errs: Mapping IAuthenticator objects to error messages :returns: Authenticator selected - :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` + :rtype: :class:`letsencrypt.client.interfaces.IAuthenticator` or `None` """ descs = [auth.description if auth not in errs @@ -41,8 +40,7 @@ def choose_authenticator(auths, errs): util(interfaces.IDisplay).notification( msg, height=display_util.HEIGHT) else: - sys.exit(0) - + return def choose_names(installer): """Display screen to select domains to validate. @@ -50,6 +48,9 @@ def choose_names(installer): :param installer: An installer object :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + :returns: List of selected names + :rtype: `list` of `str` + """ if installer is None: return _choose_names_manually() @@ -67,13 +68,13 @@ def choose_names(installer): if manual: return _choose_names_manually() else: - sys.exit(0) + return [] code, names = _filter_names(names) if code == display_util.OK and names: return names else: - sys.exit(0) + return [] def _filter_names(names): @@ -101,8 +102,8 @@ def _choose_names_manually(): if code == display_util.OK: return display_util.separate_list_input(input_) - - sys.exit(0) + # Else just return None + return [] def success_installation(domains): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index c2dec8e62..2cb4389e1 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -18,10 +18,10 @@ class IAuthenticator(zope.interface.Interface): Finish up any additional initialization. - :raises :class:`~.errors.LetsEncryptMisconfigurationError`: when - full initialization cannot be completed. - :raises :class:`~.errors.LetsEncryptNoInstallationError`: when the - necessary programs/files cannot be located. + :raises letsencrypt.client.errors.LetsEncryptMisconfigurationError: + when full initialization cannot be completed. + :raises letsencrypt.client.errors.LetsEncryptNoInstallationError: + when the necessary programs/files cannot be located. """ @@ -135,10 +135,10 @@ class IInstaller(zope.interface.Interface): Finish up any additional initialization. - :raises :class:`~.errors.LetsEncryptMisconfigurationError`: when - full initialization cannot be completed. - :raises :class:`~.errors.LetsEncryptNoInstallationError`: when the - necessary programs/files cannot be located. + :raises letsencrypt.client.errors.LetsEncryptMisconfigurationError`: + when full initialization cannot be completed. + :raises letsencrypt.errors.LetsEncryptNoInstallationError`: + when the necessary programs/files cannot be located. """ diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py index 75ff8b9f6..715b44f80 100644 --- a/letsencrypt/client/reverter.py +++ b/letsencrypt/client/reverter.py @@ -28,8 +28,8 @@ class Reverter(object): This function should reinstall the users original configuration files for all saves with temporary=True - :raises :class:`errors.LetsEncryptReverterError`: - Unable to revert config + :raises letsencrypt.client.errors.LetsEncryptReverterError: when + unable to revert config """ if os.path.isdir(self.config.temp_checkpoint_dir): @@ -48,7 +48,7 @@ class Reverter(object): :param int rollback: Number of checkpoints to reverse. A str num will be cast to an integer. So "2" is also acceptable. - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: If + :raises letsencrypt.client.errors.LetsEncryptReverterError: If there is a problem with the input or if the function is unable to correctly revert the configuration checkpoints. @@ -159,7 +159,7 @@ class Reverter(object): :param str save_notes: notes about changes made during the save :raises IOError: If unable to open cp_dir + FILEPATHS file - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError: If + :raises letsencrypt.client.errors.LetsEncryptReverterError: If unable to add checkpoint """ @@ -252,7 +252,7 @@ class Reverter(object): :param set save_files: Set of files about to be saved. - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: + :raises letsencrypt.client.errors.LetsEncryptReverterError: when save is attempting to overwrite a temporary file. """ @@ -288,7 +288,7 @@ class Reverter(object): a temp or permanent save. :param \*files: file paths (str) to be registered - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: If + :raises letsencrypt.client.errors.LetsEncryptReverterError: If call does not contain necessary parameters or if the file creation is unable to be registered. @@ -354,7 +354,7 @@ class Reverter(object): :returns: Success :rtype: bool - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: If + :raises letsencrypt.client.errors.LetsEncryptReverterError: If all files within file_list cannot be removed """ @@ -393,7 +393,8 @@ class Reverter(object): :param str title: Title describing checkpoint - :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError` + :raises letsencrypt.client.errors.LetsEncryptReverterError: when the + checkpoint is not able to be finalized. """ # Check to make sure an "in progress" directory exists diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py index cc075ea1f..98cf1704e 100644 --- a/letsencrypt/client/revoker.py +++ b/letsencrypt/client/revoker.py @@ -82,10 +82,8 @@ class Revoker(object): # certificate. _, b_k = self._row_to_backup(row) try: - if clean_pem == Crypto.PublicKey.RSA.importKey( - open(b_k).read()).exportKey("PEM"): - certs.append( - Cert.fromrow(row, self.config.cert_key_backup)) + test_pem = Crypto.PublicKey.RSA.importKey( + open(b_k).read()).exportKey("PEM") except (IndexError, ValueError, TypeError): # This should never happen given the assumptions of the # module. If it does, it is probably best to delete the @@ -93,6 +91,9 @@ class Revoker(object): raise errors.LetsEncryptRevokerError( "%s - backup file is corrupted.") + if clean_pem == test_pem: + certs.append( + Cert.fromrow(row, self.config.cert_key_backup)) if certs: self._safe_revoke(certs) else: @@ -216,13 +217,12 @@ class Revoker(object): if self.no_confirm or revocation.confirm_revocation(cert): try: self._acme_revoke(cert) - success_list.append(cert) - revocation.success_revocation(cert) - except errors.LetsEncryptClientError: # TODO: Improve error handling when networking is set... logging.error( "Unable to revoke cert:%s%s", os.linesep, str(cert)) + success_list.append(cert) + revocation.success_revocation(cert) finally: if success_list: self._remove_certs_keys(success_list) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 2a3c733fb..5b8abe91c 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -59,7 +59,7 @@ class DetermineAuthenticatorTest(unittest.TestCase): errors.LetsEncryptMisconfigurationError) mock_choose.return_value = self.mock_apache - self.assertRaises(SystemExit, self._call, self.all_auths) + self.assertRaises(self._call(self.all_auths), None) class RollbackTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index d0b340def..ae4bf419d 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -51,7 +51,7 @@ class ChooseAuthenticatorTest(unittest.TestCase): def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertRaises(SystemExit, self._call, self.auths, {}) + self.assertEqual(self._call(self.auths, {}), None) class GenHttpsNamesTest(unittest.TestCase): @@ -68,14 +68,22 @@ class GenHttpsNamesTest(unittest.TestCase): self.assertEqual(self._call([]), "") def test_one(self): - dom = "example.com" - self.assertEqual(self._call([dom]), "https://%s" % dom) + doms = [ + "example.com", + "asllkjsadfljasdf.c", + ] + for dom in doms: + 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)) + domains_list = [ + ["foo.bar.org", "bar.org"], + ["paypal.google.facebook.live.com", "*.zombo.example.com"], + ] + for doms in domains_list: + 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"] @@ -112,7 +120,7 @@ class ChooseNamesTest(unittest.TestCase): @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) + self.assertEqual(self._call(None), []) @mock.patch("letsencrypt.client.display.ops.util") def test_no_names_choose(self, mock_util): @@ -130,7 +138,7 @@ class ChooseNamesTest(unittest.TestCase): self.mock_install().get_all_names.return_value = set() mock_util().yesno.return_value = False - self.assertRaises(SystemExit, self._call, self.mock_install) + self.assertEqual(self._call(self.mock_install), []) @mock.patch("letsencrypt.client.display.ops.util") def test_filter_names_valid_return(self, mock_util): @@ -146,7 +154,7 @@ class ChooseNamesTest(unittest.TestCase): 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) + self.assertEqual(self._call(self.mock_install), []) @mock.patch("letsencrypt.client.display.ops.util") def test_filter_names_cancel(self, mock_util): @@ -154,7 +162,7 @@ class ChooseNamesTest(unittest.TestCase): mock_util().checklist.return_value = ( display_util.CANCEL, ["example.com"]) - self.assertRaises(SystemExit, self._call, self.mock_install) + self.assertEqual(self._call(self.mock_install), []) class SuccessInstallationTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index e95d313b9..097401697 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -265,10 +265,10 @@ class FileOutputDisplayTest(DisplayT): def test_wrap_lines(self): # pylint: disable=protected-access - msg = ("This is just a weak test\n" - "This function is only meant to be for easy viewing\n" + msg = ("This is just a weak test{0}" + "This function is only meant to be for easy viewing{0}" "Test a really really really really really really really really " - "really really really really really long line...") + "really really really really long line...".format(os.linesep)) text = self.displayer._wrap_lines(msg) self.assertEqual(text.count(os.linesep), 3) @@ -313,20 +313,20 @@ class SeparateListInputTest(unittest.TestCase): return separate_list_input(input_) def test_commas(self): - actual = self._call("a,b,c,test") - self.assertEqual(actual, self.exp) + self.assertEqual(self._call("a,b,c,test"), self.exp) def test_spaces(self): - actual = self._call("a b c test") - self.assertEqual(actual, self.exp) + self.assertEqual(self._call("a b c test"), self.exp) def test_both(self): - actual = self._call("a, b, c, test") - self.assertEqual(actual, self.exp) + self.assertEqual(self._call("a, b, c, test"), self.exp) def test_mess(self): - actual = [self._call(" a , b c \t test")] - actual.append(self._call(",a, ,, , b c test ")) + actual = [ + self._call(" a , b c \t test"), + self._call(",a, ,, , b c test "), + self._call(",,,,, , a b,,, , c,test"), + ] for act in actual: self.assertEqual(act, self.exp) @@ -339,12 +339,11 @@ class PlaceParensTest(unittest.TestCase): return _parens_around_char(label) def test_single_letter(self): - ret = self._call("a") - self.assertEqual("(a)", ret) + self.assertEqual("(a)", self._call("a")) def test_multiple(self): - ret = self._call("Label") - self.assertEqual("(L)abel", ret) + self.assertEqual("(L)abel", self._call("Label")) + self.assertEqual("(y)es please", self._call("yes please")) if __name__ == "__main__": diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 16d7177d0..892f34b7d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -16,6 +16,7 @@ import letsencrypt from letsencrypt.client import configuration from letsencrypt.client import client +from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import log @@ -97,7 +98,7 @@ def create_parser(): return parser -def main(): # pylint: disable=too-many-branches +def main(): # pylint: disable=too-many-branches, too-many-statements """Command line argument parsing and main script execution.""" # note: arg parser internally handles --help (and exits afterwards) args = create_parser().parse_args() @@ -139,10 +140,16 @@ def main(): # pylint: disable=too-many-branches configurator.ApacheConfigurator(config), standalone.StandaloneAuthenticator(), ] - auth = client.determine_authenticator(all_auths) + try: + auth = client.determine_authenticator(all_auths) + except errors.LetsEncryptClientError: + logging.critical("No authentication mechanisms were found on your " + "system.") + sys.exit(1) + if auth is None: - logging.critical("Unable to find a way to authenticate the server.") - sys.exit(4) + logging.info("Cannot authenticate to the ACME server.") + sys.exit(0) # Use the same object if possible if interfaces.IInstaller.providedBy(auth): # pylint: disable=no-member @@ -156,6 +163,9 @@ def main(): # pylint: disable=too-many-branches else: doms = args.domains + if not doms: + sys.exit(0) + # Prepare for init of Client if args.authkey is None: authkey = client.init_key(args.rsa_key_size, config.key_dir) From 8e66c58ec97a22205b5f01d6284356db90cdd5ae Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 12:13:08 -0800 Subject: [PATCH 40/46] remove incorrect comment --- letsencrypt/client/display/ops.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index 5c644246c..d5a869500 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -102,7 +102,6 @@ def _choose_names_manually(): if code == display_util.OK: return display_util.separate_list_input(input_) - # Else just return None return [] From 31fb733e05668b74a227040e8a82780b8afd3e26 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 12:23:02 -0800 Subject: [PATCH 41/46] fix spacing --- letsencrypt/client/auth_handler.py | 1 - letsencrypt/client/display/ops.py | 1 + letsencrypt/client/tests/display/revocation_test.py | 1 + letsencrypt/client/tests/display/util_test.py | 1 - 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index c0eeeb0cd..3a2b28648 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -233,7 +233,6 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes if client_list: self.client_auth.cleanup(client_list) - def _cleanup_state(self, delete_list): """Cleanup state after an authorization is received. diff --git a/letsencrypt/client/display/ops.py b/letsencrypt/client/display/ops.py index d5a869500..1cffe2846 100644 --- a/letsencrypt/client/display/ops.py +++ b/letsencrypt/client/display/ops.py @@ -42,6 +42,7 @@ def choose_authenticator(auths, errs): else: return + def choose_names(installer): """Display screen to select domains to validate. diff --git a/letsencrypt/client/tests/display/revocation_test.py b/letsencrypt/client/tests/display/revocation_test.py index ee7c0a9cb..557648d9d 100644 --- a/letsencrypt/client/tests/display/revocation_test.py +++ b/letsencrypt/client/tests/display/revocation_test.py @@ -98,5 +98,6 @@ class ConfirmRevocationTest(unittest.TestCase): mock_util().yesno.return_value = False self.assertFalse(self._call(self.cert)) + if __name__ == "__main__": unittest.main() diff --git a/letsencrypt/client/tests/display/util_test.py b/letsencrypt/client/tests/display/util_test.py index 097401697..69dea26ea 100644 --- a/letsencrypt/client/tests/display/util_test.py +++ b/letsencrypt/client/tests/display/util_test.py @@ -53,7 +53,6 @@ class NcursesDisplayTest(DisplayT): "menu_height": display_util.HEIGHT-6, } - @mock.patch("letsencrypt.client.display.util.dialog.Dialog.msgbox") def test_notification(self, mock_msgbox): """Kind of worthless... one liner.""" From 2920e797c9cd140dfb402956cac6568d27baaa48 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 12:43:28 -0800 Subject: [PATCH 42/46] tests/client_test.py --- letsencrypt/client/tests/client_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 5b8abe91c..0246ae77b 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -59,7 +59,7 @@ class DetermineAuthenticatorTest(unittest.TestCase): errors.LetsEncryptMisconfigurationError) mock_choose.return_value = self.mock_apache - self.assertRaises(self._call(self.all_auths), None) + self.assertEqual(self._call(self.all_auths), None) class RollbackTest(unittest.TestCase): From 34de7a17477578b66e559368d82be97cf2c3dd01 Mon Sep 17 00:00:00 2001 From: "J.C. Jones" Date: Tue, 24 Feb 2015 15:53:03 -0700 Subject: [PATCH 43/46] Make coveralls dependent on the TOXENV variable being "cover" --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d790e94f9..b39d603ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ install: script: travis_retry tox -after_success: coveralls +after_success: '[ "$TOXENV" == "cover" ] && coveralls' env: - TOXENV=py26 From ba8604a3dfa71e267d1cc8e9984ba95dd225163a Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 16:39:26 -0800 Subject: [PATCH 44/46] Correctly handle None --- letsencrypt/client/client.py | 2 +- letsencrypt/client/tests/client_test.py | 2 +- letsencrypt/client/tests/display/ops_test.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index dacb3fdcc..d415403f3 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -381,7 +381,7 @@ def determine_authenticator(all_auths): else: raise errors.LetsEncryptClientError("No Authenticators available.") - if auth and auth in errs: + if auth is not None and auth in errs: logging.error("Please fix the configuration for the Authenticator. " "The following error message was received: " "%s", errs[auth]) diff --git a/letsencrypt/client/tests/client_test.py b/letsencrypt/client/tests/client_test.py index 0246ae77b..5ae6d6107 100644 --- a/letsencrypt/client/tests/client_test.py +++ b/letsencrypt/client/tests/client_test.py @@ -59,7 +59,7 @@ class DetermineAuthenticatorTest(unittest.TestCase): errors.LetsEncryptMisconfigurationError) mock_choose.return_value = self.mock_apache - self.assertEqual(self._call(self.all_auths), None) + self.assertTrue(self._call(self.all_auths) is None) class RollbackTest(unittest.TestCase): diff --git a/letsencrypt/client/tests/display/ops_test.py b/letsencrypt/client/tests/display/ops_test.py index ae4bf419d..11edfe4e3 100644 --- a/letsencrypt/client/tests/display/ops_test.py +++ b/letsencrypt/client/tests/display/ops_test.py @@ -51,7 +51,7 @@ class ChooseAuthenticatorTest(unittest.TestCase): def test_no_choice(self, mock_util): mock_util().menu.return_value = (display_util.CANCEL, 0) - self.assertEqual(self._call(self.auths, {}), None) + self.assertTrue(self._call(self.auths, {}) is None) class GenHttpsNamesTest(unittest.TestCase): From 3160df3ede972e2ec901fd649ac01708fbb1e1c4 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 17:33:19 -0800 Subject: [PATCH 45/46] Remove cannot authenticate message for cancel --- letsencrypt/scripts/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 892f34b7d..989e07f96 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -148,7 +148,6 @@ def main(): # pylint: disable=too-many-branches, too-many-statements sys.exit(1) if auth is None: - logging.info("Cannot authenticate to the ACME server.") sys.exit(0) # Use the same object if possible From a358feb82e41a35b826595d9500733d4bcf9c5bc Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 24 Feb 2015 18:09:50 -0800 Subject: [PATCH 46/46] restore names --- letsencrypt/client/apache/configurator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index 78e76239b..af71ff5f7 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1023,13 +1023,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.restart() -def enable_mod(mod_name, apache_init, apache_enmod): +def enable_mod(mod_name, apache_init_script, apache_enmod): """Enables module in Apache. Both enables and restarts Apache so module is active. :param str mod_name: Name of the module to enable. - :param str apache_init: Path to the Apache init script. + :param str apache_init_script: Path to the Apache init script. :param str apache_enmod: Path to the Apache a2enmod script. """ @@ -1039,7 +1039,7 @@ def enable_mod(mod_name, apache_init, apache_enmod): subprocess.check_call(["sudo", apache_enmod, mod_name], # TODO: sudo? stdout=open("/dev/null", 'w'), stderr=open("/dev/null", 'w')) - apache_restart(apache_init) + apache_restart(apache_init_script) except (OSError, subprocess.CalledProcessError) as err: logging.error("Error enabling mod_%s", mod_name) logging.error("Exception: %s", err)