Reworked Configurator save/checkpoint system

This commit is contained in:
James Kasten
2013-05-21 23:36:14 -04:00
parent bed2c5049a
commit b5e374ee2b
3 changed files with 180 additions and 97 deletions
+5
View File
@@ -6,6 +6,10 @@ CONFIG_DIR = "/etc/trustify/"
WORK_DIR = "/var/lib/trustify/" WORK_DIR = "/var/lib/trustify/"
# Directory where configuration backups are stored # Directory where configuration backups are stored
BACKUP_DIR = WORK_DIR + "backups/" BACKUP_DIR = WORK_DIR + "backups/"
# Replaces MODIFIED_FILES, directory where temp checkpoint is created
TEMP_CHECKPOINT_DIR = WORK_DIR + "temp_checkpoint/"
# Directory used before a permanent checkpoint is finalized
IN_PROGRESS_DIR = BACKUP_DIR + "IN_PROGRESS/"
# Where all keys should be stored # Where all keys should be stored
KEY_DIR = SERVER_ROOT + "ssl/" KEY_DIR = SERVER_ROOT + "ssl/"
# Certificate storage # Certificate storage
@@ -19,6 +23,7 @@ OPTIONS_SSL_CONF = CONFIG_DIR + "options-ssl.conf"
APACHE_CHALLENGE_CONF = CONFIG_DIR + "choc_sni_cert_challenge.conf" APACHE_CHALLENGE_CONF = CONFIG_DIR + "choc_sni_cert_challenge.conf"
# Modified files intended to be reset (for challenges/tmp config changes) # Modified files intended to be reset (for challenges/tmp config changes)
MODIFIED_FILES = WORK_DIR + "modified_files" MODIFIED_FILES = WORK_DIR + "modified_files"
# Byte size of S and Nonce # Byte size of S and Nonce
S_SIZE = 32 S_SIZE = 32
NONCE_SIZE = 32 NONCE_SIZE = 32
+170 -92
View File
@@ -11,6 +11,7 @@ import shutil
from trustify.client.CONFIG import SERVER_ROOT, BACKUP_DIR, MODIFIED_FILES from trustify.client.CONFIG import SERVER_ROOT, BACKUP_DIR, MODIFIED_FILES
#from CONFIG import SERVER_ROOT, BACKUP_DIR, MODIFIED_FILES, REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR #from CONFIG import SERVER_ROOT, BACKUP_DIR, MODIFIED_FILES, REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR
from trustify.client.CONFIG import REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR from trustify.client.CONFIG import REWRITE_HTTPS_ARGS, CONFIG_DIR, WORK_DIR
from trustify.client.CONFIG import TEMP_CHECKPOINT_DIR, IN_PROGRESS_DIR
from trustify.client import logger from trustify.client import logger
#import logger #import logger
@@ -34,6 +35,8 @@ from trustify.client import logger
# Apache configuration. It may be wise to warn the user if they are trying # Apache configuration. It may be wise to warn the user if they are trying
# to use vhost filenames that contain spaces and offer to change ' ' to '_' # to use vhost filenames that contain spaces and offer to change ' ' to '_'
# TODO: Make IfModule completely case-insensitive
class VH(object): class VH(object):
def __init__(self, filename_path, vh_path, vh_addrs, is_ssl, is_enabled): def __init__(self, filename_path, vh_path, vh_addrs, is_ssl, is_enabled):
self.file = filename_path self.file = filename_path
@@ -73,6 +76,11 @@ class Configurator(object):
# See if any temporary changes need to be recovered # See if any temporary changes need to be recovered
self.recovery_routine() self.recovery_routine()
# Note: initialization doesn't check to see if the config is correct
# by Apache's standards. This should be done by the client if it is
# desired. There may be instances where correct configuration isn't
# required on startup.
# TODO: This function can be improved to ensure that the final directives # TODO: This function can be improved to ensure that the final directives
# are being modified whether that be in the include files or in the # are being modified whether that be in the include files or in the
# virtualhost declaration - these directives can be overwritten # virtualhost declaration - these directives can be overwritten
@@ -120,7 +128,7 @@ class Configurator(object):
if cert_chain: if cert_chain:
self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain
# This is a significant operation, make a checkpoint # This is a significant operation, make a checkpoint
return self.save("Virtual Server - deploying certificate", False) return self.save()
def choose_virtual_host(self, name, ssl=True): def choose_virtual_host(self, name, ssl=True):
""" """
@@ -533,7 +541,7 @@ class Configurator(object):
# Log actions and create save notes # Log actions and create save notes
logger.info("Created an SSL vhost at %s" % ssl_fp) logger.info("Created an SSL vhost at %s" % ssl_fp)
self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp
self.save("Making new ssl vhost at " + ssl_fp) self.save()
# We know the length is one because of the assertion above # We know the length is one because of the assertion above
ssl_vhost = self.__create_vhost(vh_p[0]) ssl_vhost = self.__create_vhost(vh_p[0])
@@ -552,7 +560,7 @@ class Configurator(object):
need_to_save = True need_to_save = True
if need_to_save: if need_to_save:
self.save("Added permanent NameVirtualHost for " + ssl_addrs[i]) self.save()
return ssl_vhost return ssl_vhost
@@ -583,7 +591,7 @@ class Configurator(object):
self.add_dir(general_v.path, "RewriteEngine", "On") self.add_dir(general_v.path, "RewriteEngine", "On")
self.add_dir(general_v.path, "RewriteRule", REWRITE_HTTPS_ARGS) self.add_dir(general_v.path, "RewriteRule", REWRITE_HTTPS_ARGS)
self.save_notes += 'Redirecting host in %s to ssl vhost in %s\n' % (general_v.file, ssl_vhost.file) self.save_notes += 'Redirecting host in %s to ssl vhost in %s\n' % (general_v.file, ssl_vhost.file)
self.save("Redirect all to ssl") self.save()
return True, general_v return True, general_v
def existing_redirect(self, vhost): def existing_redirect(self, vhost):
@@ -868,18 +876,25 @@ LogLevel warn \n\
shutil.copytree(SERVER_ROOT, BACKUP_DIR + "apache2-" + str(time.time())) shutil.copytree(SERVER_ROOT, BACKUP_DIR + "apache2-" + str(time.time()))
def recovery_routine(self): def recovery_routine(self):
''' """
Revert all previously modified files. Set up log if it doesn't exist. Revert all previously modified files. First, any changes found in a
''' TEMP_CHECKPOINT_DIR are removed, then IN_PROGRESS changes are removed
if not os.path.isfile(MODIFIED_FILES): The order is important. IN_PROGRESS is unable to add files that are
fd = open(MODIFIED_FILES, 'w') already added by a TEMP change. Thus TEMP must be rolled back first
fd.close() because that will be the 'latest' occurance of the file.
else: """
fd = open(MODIFIED_FILES) self.revert_challenge_config()
files = fd.readlines() if os.path.isdir(IN_PROGRESS_DIR):
fd.close() result = self.__recover_checkpoint(IN_PROGRESS_DIR)
if len(files) != 0: if result != 0:
self.revert_config(files) # We have a partial or incomplete recovery
# Not as egregious
# TODO: Additional tests? recovery
logger.fatal("Incomplete or failed recovery for %s" % IN_PROGRESS_DIR)
sys.exit(68)
# Need to reload configuration after these changes take effect
self.aug.load()
def verify_dir_setup(self): def verify_dir_setup(self):
''' '''
@@ -897,6 +912,8 @@ LogLevel warn \n\
logger.fatal(directory + " exists and does not contain the proper permissions or owner (root)") logger.fatal(directory + " exists and does not contain the proper permissions or owner (root)")
sys.exit(57) sys.exit(57)
else: else:
# If this throws errors... meaning a race condition attack.
# Allow program to fail...
os.makedirs(directory, permissions) os.makedirs(directory, permissions)
def __check_permissions(self, filepath, mode, uid=0): def __check_permissions(self, filepath, mode, uid=0):
@@ -906,12 +923,12 @@ LogLevel warn \n\
return file_stat.st_uid == uid return file_stat.st_uid == uid
def standardize_excl(self): def standardize_excl(self):
''' """
Standardize the excl arguments for the Httpd lens in Augeas Standardize the excl arguments for the Httpd lens in Augeas
Servers sometimes give incorrect defaults Servers sometimes give incorrect defaults
Note: This problem should be fixed in Augeas 1.0. Unfortunately, Note: This problem should be fixed in Augeas 1.0. Unfortunately,
Augeas 0.10 appears to be the most popular version currently. Augeas 0.10 appears to be the most popular version currently.
''' """
# attempt to protect against augeas error in 0.10.0 - ubuntu # attempt to protect against augeas error in 0.10.0 - ubuntu
# *.augsave -> /*.augsave upon augeas.load() # *.augsave -> /*.augsave upon augeas.load()
# Try to avoid bad httpd files # Try to avoid bad httpd files
@@ -927,10 +944,10 @@ LogLevel warn \n\
self.aug.load() self.aug.load()
def check_parsing_errors(self): def check_parsing_errors(self):
''' """
This function checks to see if Augeas was unable to parse any of the This function checks to see if Augeas was unable to parse any of the
Httpd lens files Httpd lens files
''' """
error_files = self.aug.match("/augeas//error") error_files = self.aug.match("/augeas//error")
for e in error_files: for e in error_files:
@@ -941,32 +958,20 @@ LogLevel warn \n\
logger.error('There has been an error in parsing the file: %s' % e[13:len(e) - 6]) logger.error('There has been an error in parsing the file: %s' % e[13:len(e) - 6])
logger.error(self.aug.get(e + '/message')) logger.error(self.aug.get(e + '/message'))
def revert_config(self, mod_files = None): def revert_challenge_config(self):
""" """
This function should reload the users original configuration files This function should reload the users original configuration files
for all saves with reversible=True for all saves with reversible=True
""" """
if mod_files is None: if os.path.isdir(TEMP_CHECKPOINT_DIR):
try: result = self.__recover_checkpoint(TEMP_CHECKPOINT_DIR)
mod_fd = open(MODIFIED_FILES, 'r') changes = True
mod_files = mod_fd.readlines() if result != 0:
mod_fd.close() # We have a partial or incomplete recovery
except: logger.fatal("Incomplete or failed recovery for %s" % TEMP_CHECKPOINT_DIR)
logger.fatal("Error opening:", MODIFIED_FILES) sys.exit(67)
sys.exit() # Remember to reload Augeas
try:
for f in mod_files:
shutil.copy2(f.rstrip() + ".augsave", f.rstrip())
self.aug.load() self.aug.load()
# Clear file
mod_fd = open(MODIFIED_FILES, 'w')
mod_fd.close()
except Exception as e:
logger.fatal("Error reverting configuration")
logger.fatal(e)
sys.exit(36)
def restart(self, quiet=False): def restart(self, quiet=False):
""" """
@@ -1015,19 +1020,22 @@ LogLevel warn \n\
return True return True
def save(self, mod_conf="Augeas Configuration", reversible=False): def save(self, title=None, temporary=False):
""" """
Saves all changes to the configuration files Saves all changes to the configuration files
Backups are stored as *.augsave files
This function is not transactional This function is not transactional
TODO: Instead rely on challenge to backup all files before modifications TODO: Instead rely on challenge to backup all files before modifications
mod_conf: string - The title of the save. title: string - The title of the save. If a title is given, the
reversible: boolean - Indicates whether the changes made will be configuration will be saved as a new checkpoint
quickly reversed in the future (challenges) and put in a timestamped directory.
`title` has no effect if temporary is true.
temporary: boolean - Indicates whether the changes made will be
quickly reversed in the future (challenges)
""" """
save_state = self.aug.get("/augeas/save") save_state = self.aug.get("/augeas/save")
self.aug.set("/augeas/save", "noop") self.aug.set("/augeas/save", "noop")
# Existing Errors
ex_errs = self.aug.match("/augeas//error") ex_errs = self.aug.match("/augeas//error")
try: try:
# This is a noop save # This is a noop save
@@ -1048,7 +1056,7 @@ LogLevel warn \n\
# Retrieve list of modified files # Retrieve list of modified files
# Note: Noop saves can cause the file to be listed twice, I used a # Note: Noop saves can cause the file to be listed twice, I used a
# set to remove this possibility. This is a known augeas error. # set to remove this possibility. This is a known augeas 0.10 error.
save_paths = self.aug.match("/augeas/events/saved") save_paths = self.aug.match("/augeas/events/saved")
# If the augeas tree didn't change, no files were saved and a backup # If the augeas tree didn't change, no files were saved and a backup
@@ -1058,7 +1066,7 @@ LogLevel warn \n\
for p in save_paths: for p in save_paths:
save_files.add(self.aug.get(p)[6:]) save_files.add(self.aug.get(p)[6:])
valid, message = self.check_tempfile_saves(save_files, reversible) valid, message = self.check_tempfile_saves(save_files, temporary)
if not valid: if not valid:
logger.fatal(message) logger.fatal(message)
@@ -1067,8 +1075,18 @@ LogLevel warn \n\
return False return False
# Create Checkpoint # Create Checkpoint
if not reversible: if temporary:
self.create_checkpoint(save_files, mod_conf) self.__add_to_checkpoint(TEMP_CHECKPOINT_DIR, save_files)
else:
self.__add_to_checkpoint(IN_PROGRESS_DIR, save_files)
if title:
success = self.__finalize_checkpoint(progress_dir, title)
if not success:
# This should never happen
# This will be hopefully be cleaned up on the recovery
# routine startup
sys.exit(9)
self.aug.set("/augeas/save", save_state) self.aug.set("/augeas/save", save_state)
self.save_notes = "" self.save_notes = ""
@@ -1077,35 +1095,56 @@ LogLevel warn \n\
return True return True
def create_checkpoint(self, save_files, mod_conf): def __finalize_checkpoint(self, cp_dir, title):
cp_dir = BACKUP_DIR + str(time.time()) """
Add title to cp_dir CHANGES_SINCE
Move cp_dir to Backups directory and rename with timestamp
"""
final_dir = BACKUP_DIR + str(time.time())
try:
with open(cp_dir + "CHANGES_SINCE.tmp", 'w') as ft:
ft.write("-- %s --" % title)
with open(cp_dir + "CHANGES_SINCE", 'r') as f:
ft.write(f.read())
except:
logger.error("Unable to finalize checkpoint - adding title")
return False
try:
os.rename(cp_dir, final_dir)
except:
logger.error("Unable to finalize checkpoint, %s -> %s" % cp_dir, final_dir)
return False
return True
def __add_to_checkpoint(self, cp_dir, save_files):
try: try:
os.makedirs(cp_dir) os.makedirs(cp_dir)
except OSError as exception: except OSError as exception:
if exception.errno != errno.EEXIST: if exception.errno != errno.EEXIST:
raise raise
#Update cp_dir for cleaner path creation
cp_dir = cp_dir + "/"
with open(cp_dir + "FILEPATHS", 'w') as op_fd: with open(cp_dir + "FILEPATHS", 'r+') as op_fd:
existing_filepaths = op_fd.read().splitlines()
for idx, filename in enumerate(save_files): for idx, filename in enumerate(save_files):
# Tag files with index so multiple files can have same basename if filename not in existing_filepaths:
logger.debug("Creating backup of %s" % filename) # Tag files with index so multiple files can have same name
shutil.copy2(filename, cp_dir + os.path.basename(filename) + "_" + str(idx)) logger.debug("Creating backup of %s" % filename)
op_fd.write(filename + '\n') shutil.copy2(filename, cp_dir + os.path.basename(filename) + "_" + str(idx))
op_fd.write(filename + '\n')
with open(cp_dir + "CHANGES_SINCE", 'w') as notes_fd: with open(cp_dir + "CHANGES_SINCE", 'a') as notes_fd:
notes_fd.write("-- %s --\n" % mod_conf) #notes_fd.write("-- %s --\n" % mod_conf)
notes_fd.write(self.save_notes) notes_fd.write(self.save_notes)
# Mark any new files that have been created # Mark any new files that have been created
# The files will be deleted if the checkpoint is rolledback # The files will be deleted if the checkpoint is rolledback
# Note: This should naturally be a `set` of files
if self.new_files: if self.new_files:
with open(cp_dir + "NEW_FILES", 'w') as nf_fd: with open(cp_dir + "NEW_FILES", 'a') as nf_fd:
for filename in self.new_files: for filename in self.new_files:
nf_fd.write(filename + '\n') nf_fd.write(filename + '\n')
def recover_checkpoint(self, rollback = 1): def rollback_checkpoints(self, rollback = 1):
# Sanity check input # Sanity check input
if type(rollback) is not int or rollbackrollback < 1: if type(rollback) is not int or rollbackrollback < 1:
logger.error("Rollback argument must be a positive integer") logger.error("Rollback argument must be a positive integer")
@@ -1119,48 +1158,87 @@ LogLevel warn \n\
while rollback > 0 and backups: while rollback > 0 and backups:
cp_dir = BACKUP_DIR + backups.pop() cp_dir = BACKUP_DIR + backups.pop()
with open(cp_dir + "/FILEPATHS") as f: result = self.__recover_checkpoint(cp_dir)
filepaths = f.read().splitlines() if result != 0:
for idx, fp in enumerate(filepaths): logger.fatal("Failed to load checkpoint during rollback")
shutil.copy2(cp_dir + '/' + os.path.basename(fp) + '_' + str(idx), fp) sys.exit(39)
try:
# Remove any newly added files if they exist
with open(cp_dir + "/NEW_FILES") as f:
filepaths = f.read().splitlines()
for fp in filepaths:
os.remove(fp)
except:
pass
try:
shutil.rmtree(cp_dir)
except:
logger.error("Unable to remove directory: %s" % cp_dir)
rollback -= 1 rollback -= 1
self.aug.load() self.aug.load()
def check_tempfile_saves(self, save_files, reversible): def __recover_checkpoint(self, cp_dir):
protected_fd = open(MODIFIED_FILES, 'r+') """
protected_files = protected_fd.read().splitlines() Recover a specific checkpoint provided by cp_dir
for filename in save_files:
if filename in protected_files: returns: 0 success, 1 Unable to revert, -1 Unable to delete
protected_fd.close() """
return False, "Attempting to overwrite a reversible file - %s" %filename try:
# No protected files are trying to be overwritten with open(cp_dir + "/FILEPATHS") as f:
if reversible: filepaths = f.read().splitlines()
for filename in save_files: for idx, fp in enumerate(filepaths):
protected_fd.write(filename + "\n") shutil.copy2(cp_dir + '/' + os.path.basename(fp) + '_' + str(idx), fp)
except:
# This file is required in all checkpoints.
return 1
try:
# Remove any newly added files if they exist
with open(cp_dir + "/NEW_FILES") as f:
filepaths = f.read().splitlines()
for fp in filepaths:
os.remove(fp)
except:
# This file is optional
pass
try:
shutil.rmtree(cp_dir)
except:
logger.error("Unable to remove directory: %s" % cp_dir)
return -1
return 0
def check_tempfile_saves(self, save_files, temporary):
temp_path = "%sFILEPATHS" % TEMP_CHECKPOINT_DIR
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
protected_fd.close()
return True, "Successful" return True, "Successful"
# protected_fd = open(MODIFIED_FILES, 'r+')
# protected_files = protected_fd.read().splitlines()
# for filename in save_files:
# if filename in protected_files:
# protected_fd.close()
# return False, "Attempting to overwrite a reversible file - %s" %filename
# # No protected files are trying to be overwritten
# if reversible:
# for filename in save_files:
# protected_fd.write(filename + "\n")
# protected_fd.close()
# return True, "Successful"
def display_checkpoints(self): def display_checkpoints(self):
"""
Displays all saved checkpoints
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(BACKUP_DIR) backups = os.listdir(BACKUP_DIR)
backups.sort(reverse=True) backups.sort(reverse=True)
if not backups: if not backups:
print "Trustify has not saved any backups of your apache configuration" print "Trustify 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
assert(all(bu.isdigit() for bu in backups))
for bu in backups: for bu in backups:
print time.ctime(float(bu)) print time.ctime(float(bu))
+3 -3
View File
@@ -201,11 +201,11 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
result: Apache server is restored to the pre-challenge state result: Apache server is restored to the pre-challenge state
""" """
self.configurator.revert_config() self.configurator.revert_challenge_config()
self.configurator.restart(True) self.configurator.restart(True)
self.__remove_files() self.__remove_files()
# TODO: This should be done within configuration NEW_FILES temp cp
def __remove_files(self): def __remove_files(self):
""" """
Removes all of the temporary SNI files Removes all of the temporary SNI files
@@ -228,7 +228,7 @@ DocumentRoot " + CONFIG_DIR + "challenge_page/ \n \
""" """
# Save any changes to the configuration as a precaution # Save any changes to the configuration as a precaution
# About to make temporary changes to the config # About to make temporary changes to the config
self.configurator.save("Before performing sni_challenge") self.configurator.save()
addresses = [] addresses = []
default_addr = "*:443" default_addr = "*:443"