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