apache.py
1 #!/usr/bin/env python 2 """Let's Encrypt Apache configuration submission script""" 3 import argparse 4 import atexit 5 import contextlib 6 import os 7 import re 8 import shutil 9 import subprocess 10 import sys 11 import tarfile 12 import tempfile 13 import textwrap 14 15 16 _DESCRIPTION = """ 17 Let's Help is a simple script you can run to help out the Let's Encrypt 18 project. Since Let's Encrypt will support automatically configuring HTTPS on 19 many servers, we want to test this functionality on as many configurations as 20 possible. This script will create a sanitized copy of your Apache 21 configuration, notifying you of the files that have been selected. If (and only 22 if) you approve this selection, these files will be sent to the Let's Encrypt 23 developers. 24 25 """ 26 27 28 _NO_APACHECTL = """ 29 Unable to find `apachectl` which is required for this script to work. If it is 30 installed, please run this script again with the --apache-ctl command line 31 argument and the path to the binary. 32 33 """ 34 35 36 # Keywords likely to be found in filenames of sensitive files 37 _SENSITIVE_FILENAME_REGEX = re.compile(r"^(?!.*proxy_fdpass).*pass.*$|private|" 38 r"secret|cert|crt|key|rsa|dsa|pw|\.pem|" 39 r"\.der|\.p12|\.pfx|\.p7b") 40 41 42 def make_and_verify_selection(server_root, temp_dir): 43 """Copies server_root to temp_dir and verifies selection with the user 44 45 :param str server_root: Path to the Apache server root 46 :param str temp_dir: Path to the temporary directory to copy files to 47 48 """ 49 copied_files, copied_dirs = copy_config(server_root, temp_dir) 50 51 print textwrap.fill("A secure copy of the files that have been selected " 52 "for submission has been created under {0}. All " 53 "comments have been removed and the files are only " 54 "accessible by the current user. A list of the files " 55 "that have been included is shown below. Please make " 56 "sure that this selection does not contain private " 57 "keys, passwords, or any other sensitive " 58 "information.".format(temp_dir)) 59 print "\nFiles:" 60 for copied_file in copied_files: 61 print copied_file 62 print "Directories (including all contained files):" 63 for copied_dir in copied_dirs: 64 print copied_dir 65 66 sys.stdout.write("\nIs it safe to submit these files? ") 67 while True: 68 ans = raw_input("(Y)es/(N)o: ").lower() 69 if ans.startswith("y"): 70 return 71 elif ans.startswith("n"): 72 sys.exit("Your files were not submitted") 73 74 75 def copy_config(server_root, temp_dir): 76 """Safely copies server_root to temp_dir and returns copied files 77 78 :param str server_root: Absolute path to the Apache server root 79 :param str temp_dir: Path to the temporary directory to copy files to 80 81 :returns: List of copied files and a list of leaf directories where 82 all contained files were copied 83 :rtype: `tuple` of `list` of `str` 84 85 """ 86 copied_files, copied_dirs = [], [] 87 dir_len = len(os.path.dirname(server_root)) 88 89 for config_path, config_dirs, config_files in os.walk(server_root): 90 temp_path = os.path.join(temp_dir, config_path[dir_len + 1:]) 91 os.mkdir(temp_path) 92 93 copied_all = True 94 copied_files_in_current_dir = [] 95 for config_file in config_files: 96 config_file_path = os.path.join(config_path, config_file) 97 temp_file_path = os.path.join(temp_path, config_file) 98 if os.path.islink(config_file_path): 99 os.symlink(os.readlink(config_file_path), temp_file_path) 100 elif safe_config_file(config_file_path): 101 copy_file_without_comments(config_file_path, temp_file_path) 102 copied_files_in_current_dir.append(config_file_path) 103 else: 104 copied_all = False 105 106 # If copied all files in leaf directory 107 if copied_all and not config_dirs: 108 copied_dirs.append(config_path) 109 else: 110 copied_files += copied_files_in_current_dir 111 112 return copied_files, copied_dirs 113 114 115 def copy_file_without_comments(source, destination): 116 """Copies source to destination, removing comments 117 118 :param str source: Path to the file to be copied 119 :param str destination: Path where source should be copied to 120 121 """ 122 with open(source, "r") as infile: 123 with open(destination, "w") as outfile: 124 for line in infile: 125 if not (line.isspace() or line.lstrip().startswith("#")): 126 outfile.write(line) 127 128 129 def safe_config_file(config_file): 130 """Returns True if config_file can be safely copied 131 132 :param str config_file: Path to an Apache configuration file 133 134 :returns: True if config_file can be safely copied 135 :rtype: bool 136 137 """ 138 config_file_lower = config_file.lower() 139 if _SENSITIVE_FILENAME_REGEX.search(config_file_lower): 140 return False 141 142 proc = subprocess.Popen(["file", config_file], 143 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 144 file_output, _ = proc.communicate() 145 146 if "ASCII" in file_output: 147 possible_password_file = empty_or_all_comments = True 148 with open(config_file) as config_fd: 149 for line in config_fd: 150 if not (line.isspace() or line.lstrip().startswith("#")): 151 empty_or_all_comments = False 152 if line.startswith("-----BEGIN"): 153 return False 154 elif ":" not in line: 155 possible_password_file = False 156 # If file isn't empty or commented out and could be a password file, 157 # don't include it in selection. It is safe to include the file if 158 # it consists solely of comments because comments are removed before 159 # submission. 160 return empty_or_all_comments or not possible_password_file 161 162 return False 163 164 165 def setup_tempdir(args): 166 """Creates a temporary directory and necessary files for config 167 168 :param argparse.Namespace args: Parsed command line arguments 169 170 :returns: Path to temporary directory 171 :rtype: str 172 173 """ 174 tempdir = tempfile.mkdtemp() 175 176 with open(os.path.join(tempdir, "config_file"), "w") as config_fd: 177 config_fd.write(args.config_file + "\n") 178 179 proc = subprocess.Popen([args.apache_ctl, "-v"], 180 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 181 with open(os.path.join(tempdir, "version"), "w") as version_fd: 182 version_fd.write(proc.communicate()[0]) 183 184 proc = subprocess.Popen([args.apache_ctl, "-d", args.server_root, "-f", 185 args.config_file, "-M"], 186 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 187 with open(os.path.join(tempdir, "modules"), "w") as modules_fd: 188 modules_fd.write(proc.communicate()[0]) 189 190 proc = subprocess.Popen([args.apache_ctl, "-d", args.server_root, "-f", 191 args.config_file, "-t", "-D", "DUMP_VHOSTS"], 192 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 193 with open(os.path.join(tempdir, "vhosts"), "w") as vhosts_fd: 194 vhosts_fd.write(proc.communicate()[0]) 195 196 return tempdir 197 198 199 def verify_config(args): 200 """Verifies server_root and config_file specify a valid config 201 202 :param argparse.Namespace args: Parsed command line arguments 203 204 """ 205 with open(os.devnull, "w") as devnull: 206 try: 207 subprocess.check_call([args.apache_ctl, "-d", args.server_root, 208 "-f", args.config_file, "-t"], 209 stdout=devnull, stderr=subprocess.STDOUT) 210 except OSError: 211 sys.exit(_NO_APACHECTL) 212 except subprocess.CalledProcessError: 213 sys.exit("Syntax check from apachectl failed") 214 215 216 def locate_config(apache_ctl): 217 """Uses the apachectl binary to find configuration files 218 219 :param str apache_ctl: Path to `apachectl` binary 220 221 222 :returns: Path to Apache server root and main configuration file 223 :rtype: `tuple` of `str` 224 225 """ 226 try: 227 proc = subprocess.Popen([apache_ctl, "-V"], 228 stdout=subprocess.PIPE, stderr=subprocess.PIPE) 229 output, _ = proc.communicate() 230 except OSError: 231 sys.exit(_NO_APACHECTL) 232 233 server_root = config_file = "" 234 for line in output.splitlines(): 235 # Relevant output lines are of the form: -D DIRECTIVE="VALUE" 236 if "HTTPD_ROOT" in line: 237 server_root = line[line.find('"') + 1:-1] 238 elif "SERVER_CONFIG_FILE" in line: 239 config_file = line[line.find('"') + 1:-1] 240 241 if not (server_root and config_file): 242 sys.exit("Unable to locate Apache configuration. Please run this " 243 "script again and specify --server-root and --config-file") 244 245 return server_root, config_file 246 247 248 def get_args(): 249 """Parses command line arguments 250 251 :returns: Parsed command line options 252 :rtype: argparse.Namespace 253 254 """ 255 parser = argparse.ArgumentParser(description=_DESCRIPTION) 256 parser.add_argument("-c", "--apache-ctl", default="apachectl", 257 help="path to the `apachectl` binary") 258 parser.add_argument("-d", "--server-root", 259 help=("location of the root directory of your Apache " 260 "configuration")) 261 parser.add_argument("-f", "--config-file", 262 help=("location of your main Apache configuration " 263 "file relative to the server root")) 264 args = parser.parse_args() 265 266 # args.server_root XOR args.config_file 267 if bool(args.server_root) != bool(args.config_file): 268 sys.exit("If either --server-root and --config-file are specified, " 269 "they both must be included") 270 elif args.server_root and args.config_file: 271 args.server_root = os.path.abspath(args.server_root) 272 args.config_file = os.path.abspath(args.config_file) 273 274 if args.config_file.startswith(args.server_root): 275 args.config_file = args.config_file[len(args.server_root) + 1:] 276 else: 277 sys.exit("This script expects the Apache configuration file to be " 278 "inside the server root") 279 280 return args 281 282 283 def main(): 284 """Main script execution""" 285 args = get_args() 286 if args.server_root is None: 287 args.server_root, args.config_file = locate_config(args.apache_ctl) 288 289 verify_config(args) 290 tempdir = setup_tempdir(args) 291 atexit.register(lambda: shutil.rmtree(tempdir)) 292 make_and_verify_selection(args.server_root, tempdir) 293 294 tarpath = os.path.join(tempdir, "config.tar.gz") 295 # contextlib.closing used for py26 support 296 with contextlib.closing(tarfile.open(tarpath, mode="w:gz")) as tar: 297 tar.add(tempdir, arcname=".") 298 299 # TODO: Submit tarpath 300 301 302 if __name__ == "__main__": 303 main() # pragma: no cover