diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py index c9c64da93..33b6eb078 100644 --- a/letsencrypt/client/apache/configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -95,12 +95,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "work": CONFIG.WORK_DIR} super(ApacheConfigurator, self).__init__(direc) - - # See if any temporary changes need to be recovered - # This needs to occur before VirtualHost objects are setup... - # because this will change the underlying configuration and potential - # vhosts - self.recovery_routine() + self.direc = direc # Verify that all directories and files exist with proper permissions if os.geteuid() == 0: @@ -184,7 +179,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.aug.set(path["cert_chain"][0], cert_chain) self.save_notes += ("Changed vhost at %s with addresses of %s\n" % - (vhost.filep, vhost.addrs)) + (vhost.filep, + ", ".join(str(addr) for addr in vhost.addrs))) self.save_notes += "\tSSLCertificateFile %s\n" % cert self.save_notes += "\tSSLCertificateKeyFile %s\n" % key if cert_chain: @@ -450,7 +446,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # First register the creation so that it is properly removed if # configuration is rolled back - self.register_file_creation(False, ssl_fp) + self.reverter.register_file_creation(False, ssl_fp) try: orig_file = open(avail_fp, 'r') @@ -704,7 +700,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # Register the new file that will be created # Note: always register the creation before writing to ensure file will # be removed in case of unexpected program exit - self.register_file_creation(False, redirect_filepath) + self.reverter.register_file_creation(False, redirect_filepath) # Write out file with open(redirect_filepath, 'w') as redirect_fd: @@ -872,7 +868,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if "/sites-available/" in vhost.filep: enabled_path = ("%ssites-enabled/%s" % (self.parser.root, os.path.basename(vhost.filep))) - self.register_file_creation(False, enabled_path) + self.reverter.register_file_creation(False, enabled_path) os.symlink(vhost.filep, enabled_path) vhost.enabled = True logging.info("Enabling available site: %s", vhost.filep) @@ -1033,9 +1029,7 @@ def enable_mod(mod_name): stdout=open("/dev/null", 'w'), stderr=open("/dev/null", 'w')) # Hopefully this waits for output - subprocess.check_call(["sudo", CONFIG.APACHE2, "restart"], - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) + apache_restart() except (OSError, subprocess.CalledProcessError) as err: logging.error("Error enabling mod_%s", mod_name) logging.error("Exception: %s", err) @@ -1055,15 +1049,22 @@ def mod_loaded(module): proc = subprocess.Popen( [CONFIG.APACHE_CTL, '-M'], stdout=subprocess.PIPE, - stderr=open("/dev/null", 'w')).communicate()[0] + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() except (OSError, ValueError): logging.error( "Error accessing %s for loaded modules!", CONFIG.APACHE_CTL) - logging.error("This may be caused by an Apache Configuration Error") - return False + raise errors.LetsEncryptConfiguratorError( + "Error accessing loaded modules") + # Small errors that do not impede + if proc.returncode != 0: + logging.warn("Error in checking loaded module list: %s", stderr) + raise errors.LetsEncryptMisconfigurationError( + "Apache is unable to check whether or not the module is " + "loaded because Apache is misconfigured.") - if module in proc: + if module in stdout: return True return False @@ -1079,13 +1080,13 @@ def apache_restart(): proc = subprocess.Popen([CONFIG.APACHE2, 'restart'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) - text = proc.communicate() + stdout, stderr = proc.communicate() if proc.returncode != 0: # Enter recovery routine... logging.error("Configtest failed") - logging.error(text[0]) - logging.error(text[1]) + logging.error(stdout) + logging.error(stderr) return False except (OSError, ValueError): diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index b513275da..6da3cdbbb 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -88,7 +88,7 @@ class ApacheDvsni(object): # Create all of the challenge certs for chall in self.dvsni_chall: cert_path = self.get_cert_file(chall.nonce) - self.config.register_file_creation(cert_path) + self.config.reverter.register_file_creation(cert_path) s_b64 = challenge_util.dvsni_gen_cert( cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) @@ -120,7 +120,7 @@ class ApacheDvsni(object): config_text += "\n" self._conf_include_check(self.config.parser.loc["default"]) - self.config.register_file_creation(True, self.challenge_conf) + self.config.reverter.register_file_creation(True, self.challenge_conf) with open(self.challenge_conf, 'w') as new_conf: new_conf.write(config_text) diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 1c366c60e..56758a8e7 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -8,7 +8,7 @@ import time import augeas from letsencrypt.client import CONFIG -from letsencrypt.client import le_util +from letsencrypt.client import reverter class AugeasConfigurator(object): @@ -19,6 +19,8 @@ class AugeasConfigurator(object): :ivar str save_notes: Human-readable configuration change notes :ivar dict direc: dictionary containing save directory paths + :ivar reverter: saves and reverts checkpoints + :type reverter: :class:`letsencrypt.client.reverter.Reverter` """ @@ -35,13 +37,19 @@ class AugeasConfigurator(object): "temp": CONFIG.TEMP_CHECKPOINT_DIR, "progress": CONFIG.IN_PROGRESS_DIR} - self.direc = direc # TODO: this instantiation can be optimized to only load # relevant files - I believe -> NO_MODL_AUTOLOAD # Set Augeas flags to save backup self.aug = augeas.Augeas(flags=augeas.Augeas.NONE) self.save_notes = "" + # See if any temporary changes need to be recovered + # This needs to occur before VirtualHost objects are setup... + # because this will change the underlying configuration and potential + # vhosts + self.reverter = reverter.Reverter(direc) + self.reverter.recovery_routine() + def check_parsing_errors(self, lens): """Verify Augeas can parse all of the lens files. @@ -84,16 +92,7 @@ class AugeasConfigurator(object): # This is a noop save self.aug.save() except (RuntimeError, IOError): - # Check for the root of save problems - new_errs = self.aug.match("/augeas//error") - # logging.error("During Save - %s", mod_conf) - # Only print new errors caused by recent save - for err in new_errs: - if err not in ex_errs: - logging.error( - "Unable to save file - %s", err[13:len(err) - 6]) - logging.error("Attempted Save Notes") - logging.error(self.save_notes) + self._log_save_errors() # Erase Save Notes self.save_notes = "" return False @@ -110,27 +109,15 @@ class AugeasConfigurator(object): for path in save_paths: save_files.add(self.aug.get(path)[6:]) - valid, message = self.check_tempfile_saves(save_files) - - if not valid: - logging.fatal(message) - # What is the protocol in this situation? - # This shouldn't happen if the challenge codebase is correct - return False - # Create Checkpoint if temporary: - self.add_to_checkpoint(self.direc["temp"], save_files) + self.reverter.add_to_temp_checkpoint( + save_files, self.save_notes) else: - self.add_to_checkpoint(self.direc["progress"], save_files) + self.reverter.add_to_checkpoint(save_files, self.save_notes) - if title and not temporary and os.path.isdir(self.direc["progress"]): - success = self._finalize_checkpoint(self.direc["progress"], title) - if not success: - # This should never happen - # This will be hopefully be cleaned up on the recovery - # routine startup - sys.exit(9) + if title and not temporary: + success = self.reverter.finalize_checkpoint(title) self.aug.set("/augeas/save", save_state) self.save_notes = "" @@ -138,308 +125,44 @@ class AugeasConfigurator(object): return True - def revert_challenge_config(self): - """Reload users original configuration files after a challenge. - - This function should reload the users original configuration files - for all saves with temporary=True - - """ - if os.path.isdir(self.direc["temp"]): - result = self._recover_checkpoint(self.direc["temp"]) - if result != 0: - # We have a partial or incomplete recovery - logging.fatal("Incomplete or failed recovery for %s", - self.direc["temp"]) - sys.exit(67) - # Remember to reload Augeas - self.aug.load() - - def rollback_checkpoints(self, rollback=1): - """Revert 'rollback' number of configuration checkpoints. - - :param int rollback: Number of checkpoints to reverse - - """ - try: - rollback = int(rollback) - except ValueError: - logging.error("Rollback argument must be a positive integer") - # Sanity check input - if rollback < 1: - logging.error("Rollback argument must be a positive integer") - return - - backups = os.listdir(self.direc["backup"]) - backups.sort() - - if len(backups) < rollback: - logging.error("Unable to rollback %d checkpoints, only %d exist", - rollback, len(backups)) - - while rollback > 0 and backups: - cp_dir = self.direc["backup"] + backups.pop() - result = self._recover_checkpoint(cp_dir) - if result != 0: - logging.fatal("Failed to load checkpoint during rollback") - sys.exit(39) - rollback -= 1 - - self.aug.load() - - def show_config_changes(self): - """Displays all saved checkpoints. - - All checkpoints are printed to the console. - - Note: Any 'IN_PROGRESS' checkpoints will be removed by the cleanup - script found in the constructor, before this function would ever be - called. - - """ - backups = os.listdir(self.direc["backup"]) - backups.sort(reverse=True) - - if not backups: - print ("Letsencrypt has not saved any backups of your " - "apache configuration") - # Make sure there isn't anything unexpected in the backup folder - # There should only be timestamped (float) directories - try: - for bkup in backups: - float(bkup) - except ValueError: - assert False, "Invalid files in %s" % self.direc["backup"] - - for bkup in backups: - print time.ctime(float(bkup)) - cur_dir = self.direc["backup"] + bkup - with open(os.path.join(cur_dir, "CHANGES_SINCE")) as changes_fd: - print changes_fd.read() - - print "Affected files:" - with open(os.path.join(cur_dir, "FILEPATHS")) as paths_fd: - filepaths = paths_fd.read().splitlines() - for path in filepaths: - print " %s" % path - - try: - with open(os.path.join(cur_dir, "NEW_FILES")) as new_fd: - print "New Configuration Files:" - filepaths = new_fd.read().splitlines() - for path in filepaths: - print " %s" % path - except (IOError, OSError) as exc: - print exc - print "" - - def add_to_checkpoint(self, cp_dir, save_files): - """Add save files to checkpoint directory. - - :param str cp_dir: Checkpoint directory filepath - :param set save_files: set of files to save - - """ - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) - - existing_filepaths = [] - filepaths_path = os.path.join(cp_dir, "FILEPATHS") - - # Open up FILEPATHS differently depending on if it already exists - if os.path.isfile(filepaths_path): - op_fd = open(filepaths_path, 'r+') - existing_filepaths = op_fd.read().splitlines() - else: - op_fd = open(filepaths_path, 'w') - - idx = len(existing_filepaths) - for filename in save_files: - if filename not in existing_filepaths: - # Tag files with index so multiple files can - # have the same filename - logging.debug("Creating backup of %s", filename) - shutil.copy2(filename, os.path.join( - cp_dir, os.path.basename(filename) + "_" + str(idx))) - op_fd.write(filename + '\n') - idx += 1 - op_fd.close() - - with open(os.path.join(cp_dir, "CHANGES_SINCE"), 'a') as notes_fd: - notes_fd.write(self.save_notes) - - def _recover_checkpoint(self, cp_dir): - """Recover a specific checkpoint. - - Recover a specific checkpoint provided by cp_dir - Note: this function does not reload augeas. - - :param str cp_dir: checkpoint directory file path - - :returns: 0 success, 1 Unable to revert, -1 Unable to delete - :rtype: int - - """ - if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")): - try: - with open(os.path.join(cp_dir, "FILEPATHS")) as paths_fd: - filepaths = paths_fd.read().splitlines() - for idx, path in enumerate(filepaths): - shutil.copy2(os.path.join( - cp_dir, - 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) - return 1 - - # Remove any newly added files if they exist - self._remove_contained_files(os.path.join(cp_dir, "NEW_FILES")) - - try: - shutil.rmtree(cp_dir) - except OSError: - logging.error("Unable to remove directory: %s", cp_dir) - return -1 - - return 0 - - def check_tempfile_saves(self, save_files): # pylint: disable=no-self-use - """Verify save isn't overwriting any temporary files. - - :param set save_files: Set of files about to be saved. - - :returns: Success, error message - :rtype: bool, str - - """ - temp_path = "%sFILEPATHS" % self.direc["temp"] - if os.path.isfile(temp_path): - with open(temp_path, 'r') as protected_fd: - protected_files = protected_fd.read().splitlines() - for filename in protected_files: - if filename in save_files: - return False, ("Attempting to overwrite challenge " - "file - %s" % filename) - - return True, "" - - # pylint: disable=no-self-use, anomalous-backslash-in-string - def register_file_creation(self, temporary, *files): - """Register the creation of all files during letsencrypt execution. - - Call this method before writing to the file to make sure that the - file will be cleaned up if the program exits unexpectedly. - (Before a save occurs) - - :param bool temporary: If the file creation registry is for - a temp or permanent save. - - :param \*files: file paths (str) to be registered - - """ - if temporary: - cp_dir = self.direc["temp"] - else: - cp_dir = self.direc["progress"] - - le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) - try: - with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: - for file_path in files: - new_fd.write("%s\n" % file_path) - except (IOError, OSError): - logging.error("ERROR: Unable to register file creation") + def _log_save_errors(self): + """Log errors due to bad Augeas save.""" + # Check for the root of save problems + new_errs = self.aug.match("/augeas//error") + # logging.error("During Save - %s", mod_conf) + # Only print new errors caused by recent save + for err in new_errs: + if err not in ex_errs: + logging.error( + "Unable to save file - %s", err[13:len(err) - 6]) + logging.error("Attempted Save Notes") + logging.error(self.save_notes) + # Wrapper functions for Reverter class def recovery_routine(self): """Revert all previously modified files. - 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 - 'latest' occurrence of the file. + Reverts all modified files that have not been saved as a checkpoint """ - self.revert_challenge_config() - if os.path.isdir(self.direc["progress"]): - result = self._recover_checkpoint(self.direc["progress"]) - if result != 0: - # We have a partial or incomplete recovery - # Not as egregious - # TODO: Additional tests? recovery - logging.fatal("Incomplete or failed recovery for %s", - self.direc["progress"]) - sys.exit(68) + self.reverter.recovery_routine() + # Need to reload configuration after these changes take effect + self.aug.load() - # Need to reload configuration after these changes take effect - self.aug.load() + def revert_challenge_config(self): + """Used to cleanup challenge configurations.""" + self.reverter.revert_temporary_config() + self.aug.load() - # pylint: disable=no-self-use - def _remove_contained_files(self, file_list): - """Erase all files contained within file_list. + def rollback_checkpoints(self, rollback=1): + """Rollback saved checkpoints. - :param str file_list: file containing list of file paths to be deleted - - :returns: Success - :rtype: bool + :param int rollback: Number of checkpoints to revert """ - # Check to see that file exists to differentiate can't find file_list - # and can't remove filepaths within file_list errors. - if not os.path.isfile(file_list): - return False - try: - 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 - # check to see if file exists first - if os.path.lexists(path): - os.remove(path) - else: - logging.warn( - "File: %s - Could not be found to be deleted\n" - "LE probably shut down unexpectedly", path) - except (IOError, OSError): - logging.fatal( - "Unable to remove filepaths contained within %s", file_list) - sys.exit(41) + self.reverter.rollback_checkpoints(rollback) + self.aug.load() - return True - - # pylint: disable=no-self-use - def _finalize_checkpoint(self, cp_dir, title): - """Move IN_PROGRESS checkpoint to timestamped checkpoint. - - Adds title to cp_dir CHANGES_SINCE - Move cp_dir to Backups directory and rename with timestamp - - :param cp_dir: "IN PROGRESS" directory - :type cp_dir: str - - :returns: Success - :rtype: bool - - """ - final_dir = os.path.join(self.direc["backup"], str(time.time())) - changes_since_path = os.path.join(cp_dir, "CHANGES_SINCE") - changes_since_tmp_path = os.path.join(cp_dir, "CHANGES_SINCE.tmp") - - try: - 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: - changes_tmp.write(changes_orig.read()) - - shutil.move(changes_since_tmp_path, changes_since_path) - - except (IOError, OSError): - logging.error("Unable to finalize checkpoint - adding title") - return False - try: - os.rename(cp_dir, final_dir) - except OSError: - logging.error( - "Unable to finalize checkpoint, %s -> %s", cp_dir, final_dir) - return False - return True + def view_config_changes(self): + """Show all of the configuration changes that have taken place.""" + self.reverter.show_config_changes() \ No newline at end of file diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index ec046c0a5..98aa1a2f0 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,6 +5,10 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" +class LetsEncryptReverterError(LetsEncryptClientError): + """Let's Encrypt Reverter error.""" + + class LetsEncryptAuthHandlerError(LetsEncryptClientError): """Let's Encrypt Auth Handler error.""" @@ -17,5 +21,9 @@ class LetsEncryptConfiguratorError(LetsEncryptClientError): """Let's Encrypt Configurator error.""" +class LetsEncryptMisconfigurationError(LetsEncryptClientError): + """Let's Encrypt Misconfiguration Error.""" + + class LetsEncryptDvsniError(LetsEncryptConfiguratorError): """Let's Encrypt DVSNI error.""" diff --git a/letsencrypt/client/reverter.py b/letsencrypt/client/reverter.py new file mode 100644 index 000000000..5952cdb5e --- /dev/null +++ b/letsencrypt/client/reverter.py @@ -0,0 +1,343 @@ +"""Reverter class saves configuration checkpoints and allows for recovery.""" +import logging +import os +import shutil +import sys +import time + +from letsencrypt.client import CONFIG +from letsencrypt.client import errors +from letsencrypt.client import le_util + +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} + self.direc = direc + + def revert_temporary_config(self): + """Reload users original configuration files after a temporary save. + + This function should reinstall the users original configuration files + for all saves with temporary=True + + :raises :class:`errors.LetsEncryptReverterError`: + Unable to revert config + + """ + if os.path.isdir(self.direc["temp"]): + result = self._recover_checkpoint(self.direc["temp"]) + if result != 0: + # We have a partial or incomplete recovery + logging.fatal("Incomplete or failed recovery for %s", + self.direc["temp"]) + raise errors.LetsEncryptReverterError( + "Unable to revert temporary config") + + def rollback_checkpoints(self, rollback=1): + """Revert 'rollback' number of configuration checkpoints. + + :param int rollback: Number of checkpoints to reverse + + """ + try: + rollback = int(rollback) + except ValueError: + logging.error("Rollback argument must be a positive integer") + # Sanity check input + if rollback < 1: + logging.error("Rollback argument must be a positive integer") + return + + backups = os.listdir(self.direc["backup"]) + backups.sort() + + if len(backups) < rollback: + logging.error("Unable to rollback %d checkpoints, only %d exist", + rollback, len(backups)) + + while rollback > 0 and backups: + cp_dir = self.direc["backup"] + backups.pop() + result = self._recover_checkpoint(cp_dir) + if result != 0: + logging.fatal("Failed to load checkpoint during rollback") + sys.exit(39) + rollback -= 1 + + def view_config_changes(self): + """Displays all saved checkpoints. + + All checkpoints are printed to the console. + + Note: Any 'IN_PROGRESS' checkpoints will be removed by the cleanup + script found in the constructor, before this function would ever be + called. + + """ + backups = os.listdir(self.direc["backup"]) + backups.sort(reverse=True) + + if not backups: + print ("Letsencrypt has not saved any backups of your " + "configuration") + # Make sure there isn't anything unexpected in the backup folder + # There should only be timestamped (float) directories + try: + for bkup in backups: + float(bkup) + except ValueError: + assert False, "Invalid files in %s" % self.direc['backup'] + + for bkup in backups: + print time.ctime(float(bkup)) + cur_dir = self.direc['backup'] + bkup + with open(os.path.join(cur_dir, 'CHANGES_SINCE')) as changes_fd: + print changes_fd.read() + + print "Affected files:" + with open(os.path.join(cur_dir, 'FILEPATHS')) as paths_fd: + filepaths = paths_fd.read().splitlines() + for path in filepaths: + print " %s" % path + + try: + if os.path.isfile(os.path.join(cur_dir, 'NEW_FILES')): + with open(os.path.join(cur_dir, 'NEW_FILES')) as new_fd: + print "New Configuration Files:" + filepaths = new_fd.read().splitlines() + for path in filepaths: + print " %s" % path + except (IOError, OSError) as err: + logging.warn(str(err)) + print "" + + def add_to_temp_checkpoint(self, save_files, save_notes): + """Add files to temporary checkpoint + + param set save_files: set of filepaths to save + param str save_notes: notes about changes during the save + + """ + 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 + + :param set save_files: set of filepaths to save + :param str save_notes: notes about changes during the save + + """ + self._add_to_checkpoint_dir( + 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. + + :param str cp_dir: Checkpoint directory filepath + :param set save_files: set of files to save + :param str save_notes: notes about changes made during the save + + """ + self._check_tempfile_saves(save_files) + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + + existing_filepaths = [] + filepaths_path = os.path.join(cp_dir, "FILEPATHS") + + # Open up FILEPATHS differently depending on if it already exists + if os.path.isfile(filepaths_path): + op_fd = open(filepaths_path, 'r+') + existing_filepaths = op_fd.read().splitlines() + else: + op_fd = open(filepaths_path, 'w') + + idx = len(existing_filepaths) + for filename in save_files: + if filename not in existing_filepaths: + # Tag files with index so multiple files can + # have the same filename + logging.debug("Creating backup of %s", filename) + shutil.copy2(filename, os.path.join( + cp_dir, os.path.basename(filename) + "_" + str(idx))) + op_fd.write(filename + '\n') + idx += 1 + op_fd.close() + + with open(os.path.join(cp_dir, "CHANGES_SINCE"), 'a') as notes_fd: + notes_fd.write(save_notes) + + def _recover_checkpoint(self, cp_dir): + """Recover a specific checkpoint. + + Recover a specific checkpoint provided by cp_dir + Note: this function does not reload augeas. + + :param str cp_dir: checkpoint directory file path + + :returns: 0 success, 1 Unable to revert, -1 Unable to delete + :rtype: int + + """ + if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")): + try: + with open(os.path.join(cp_dir, "FILEPATHS")) as paths_fd: + filepaths = paths_fd.read().splitlines() + for idx, path in enumerate(filepaths): + shutil.copy2(os.path.join( + cp_dir, + 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) + return 1 + + # Remove any newly added files if they exist + self._remove_contained_files(os.path.join(cp_dir, "NEW_FILES")) + + try: + shutil.rmtree(cp_dir) + except OSError: + logging.error("Unable to remove directory: %s", cp_dir) + return -1 + + return 0 + + def _check_tempfile_saves(self, save_files): # pylint: disable=no-self-use + """Verify save isn't overwriting any temporary files. + + :param set save_files: Set of files about to be saved. + + :raises :class:`letsencrypt.client.errors.LetsEncryptReverterError`: + when save is attempting to overwrite a temporary file. + + """ + temp_path = "%sFILEPATHS" % self.direc["temp"] + if os.path.isfile(temp_path): + with open(temp_path, 'r') as protected_fd: + protected_files = protected_fd.read().splitlines() + for filename in protected_files: + if filename in save_files: + raise errors.LetsEncryptReverterError( + "Attempting to overwrite challenge " + "file - %s" % filename) + + # pylint: disable=no-self-use, anomalous-backslash-in-string + def register_file_creation(self, temporary, *files): + """Register the creation of all files during letsencrypt execution. + + Call this method before writing to the file to make sure that the + file will be cleaned up if the program exits unexpectedly. + (Before a save occurs) + + :param bool temporary: If the file creation registry is for + a temp or permanent save. + + :param \*files: file paths (str) to be registered + + """ + if temporary: + cp_dir = self.direc["temp"] + else: + cp_dir = self.direc["progress"] + + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) + try: + with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: + for file_path in files: + new_fd.write("%s\n" % file_path) + except (IOError, OSError): + logging.error("ERROR: Unable to register file creation") + + def recovery_routine(self): + """Revert all previously modified files. + + 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 + 'latest' occurrence of the file. + + """ + self.revert_temporary_config() + if os.path.isdir(self.direc["progress"]): + result = self._recover_checkpoint(self.direc["progress"]) + if result != 0: + # We have a partial or incomplete recovery + # Not as egregious + # TODO: Additional tests? recovery + logging.fatal("Incomplete or failed recovery for %s", + self.direc["progress"]) + sys.exit(68) + + # pylint: disable=no-self-use + def _remove_contained_files(self, file_list): + """Erase all files contained within file_list. + + :param str file_list: file containing list of file paths to be deleted + + :returns: Success + :rtype: bool + + """ + # Check to see that file exists to differentiate can't find file_list + # and can't remove filepaths within file_list errors. + if not os.path.isfile(file_list): + return False + try: + 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 + # check to see if file exists first + if os.path.lexists(path): + os.remove(path) + else: + logging.warn( + "File: %s - Could not be found to be deleted\n" + "LE probably shut down unexpectedly", path) + except (IOError, OSError): + logging.fatal( + "Unable to remove filepaths contained within %s", file_list) + sys.exit(41) + + return True + + # pylint: disable=no-self-use + 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 rename with timestamp + + """ + # Check to make sure an "in progress" directory exists + if not os.path.isdir(self.direc['progress']): + return + + final_dir = os.path.join(self.direc['backup'], str(time.time())) + changes_since_path = os.path.join( + self.direc['progress'], 'CHANGES_SINCE') + changes_since_tmp_path = os.path.join( + self.direc['progress'], 'CHANGES_SINCE.tmp') + + try: + 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: + changes_tmp.write(changes_orig.read()) + + shutil.move(changes_since_tmp_path, changes_since_path) + + except (IOError, OSError): + logging.error("Unable to finalize checkpoint - adding title") + raise errors.LetsEncryptReverterError("Unable to add title") + try: + os.rename(self.direc['progress'], final_dir) + except OSError: + logging.error( + "Unable to finalize checkpoint, %s -> %s", cp_dir, final_dir) + raise errors.LetsEncryptReverterError( + "Unable to finalize checkpoint renaming") diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index ff3c3c792..5ddfd6f69 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -13,6 +13,7 @@ from letsencrypt.client import display from letsencrypt.client import interfaces from letsencrypt.client import errors from letsencrypt.client import log +from letsencrypt.client import reverter from letsencrypt.client import revoker from letsencrypt.client.apache import configurator @@ -71,25 +72,32 @@ def main(): displayer = display.FileDisplay(sys.stdout) zope.component.provideUtility(displayer) - installer = determine_installer() server = CONFIG.ACME_SERVER if args.server is None else args.server + if args.view_config_changes: + view_config_changes() + sys.exit() + if args.revoke: - revoc = revoker.Revoker(server, installer) - revoc.list_certs_keys() + revoke(server) sys.exit() if args.rollback > 0: - rollback(installer, args.rollback) - sys.exit() - - if args.view_config_changes: - view_config_changes(installer) + rollback(args.rollback) sys.exit() 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. + try: + installer = determine_installer() + except errors.LetsEncryptMisconfigurationError as err: + logging.fatal("Please fix your configuration before proceeding. " + "The Installer exited with the following message: " + "%s", str(err)) + # Use the same object if possible if interfaces.IAuthenticator.providedBy(installer): auth = installer @@ -198,27 +206,52 @@ def read_file(filename): raise argparse.ArgumentTypeError(exc.strerror) -def rollback(installer, checkpoints): +def rollback(checkpoints): """Revert configuration the specified number of checkpoints. - :param installer: Installer object - :type installer: :class:`letsencrypt.client.interfaces.IInstaller` - :param int checkpoints: Number of checkpoints to revert. """ - installer.rollback_checkpoints(checkpoints) - installer.restart() + # Misconfigurations are only a slight problems... allow the user to rollback + try: + installer = determine_installer() + installer.rollback_checkpoints(checkpoints) + installer.restart() + except errors.LetsEncryptMisconfigurationError: + logging.warn("Installer is misconfigured before rollback.") + logging.info("Rolling back using Reverter module") + # recovery routine has already been run by installer __init__ attempt + reverter.Reverter().rollback_checkpoints(checkpoints) + try: + installer = determine_installer() + installer.restart() + logging.info("Rollback solved misconfiguration!") + except errors.LetsEncryptMisconfigurationError: + logging.warn("Rollback was unable to solve misconfiguration issues") -def view_config_changes(installer): - """View checkpoints and associated configuration changes. +def revoke(server): + """Revoke certificates. - :param installer: Installer object - :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + :param str server: ACME server client wishes to revoke certificates from """ - installer.view_config_changes() + # Misconfigurations don't really matter. Determine installer better choose + # correctly though. + try: + installer = determine_installer() + except errors.LetsEncryptMisconfigurationError: + logging.warn("Installer is currently misconfigured.") + + revoc = revoker.Revoker(server, installer) + revoc.list_certs_keys() + + +def view_config_changes(): + """View checkpoints and associated configuration changes.""" + rev = reverter.Reverter() + rev.recovery_routine() + rev.view_config_changes() if __name__ == "__main__": main()