mirror of
https://github.com/certbot/certbot.git
synced 2026-06-05 14:54:24 -04:00
Address startup issues, attempt to identify misconfiguration issues with proper errors
This commit is contained in:
parent
66884e05cf
commit
1dc7bfccc4
6 changed files with 471 additions and 363 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 += "</IfModule>\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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
343
letsencrypt/client/reverter.py
Normal file
343
letsencrypt/client/reverter.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue