mirror of
https://github.com/certbot/certbot.git
synced 2026-01-26 07:41:33 +03:00
1776 lines
64 KiB
Python
1776 lines
64 KiB
Python
"""Apache Configuration based off of Augeas Configurator."""
|
|
import hashlib
|
|
import os
|
|
import pkg_resources
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
|
|
from Crypto import Random
|
|
|
|
from letsencrypt.client import augeas_configurator
|
|
from letsencrypt.client import CONFIG
|
|
from letsencrypt.client import crypto_util
|
|
from letsencrypt.client import errors
|
|
from letsencrypt.client import le_util
|
|
from letsencrypt.client import logger
|
|
|
|
|
|
# Configurator should be turned into a Singleton
|
|
|
|
# Note: Apache 2.4 NameVirtualHost directive is deprecated... all vhost twins
|
|
# are considered name based vhosts by default. The use of the directive will
|
|
# emit a warning.
|
|
|
|
# TODO: Augeas sections ie. <VirtualHost>, <IfModule> beginning and closing
|
|
# tags need to be the same case, otherwise Augeas doesn't recognize them.
|
|
# This is not able to be completely remedied by regular expressions because
|
|
# Augeas views <VirtualHost> </Virtualhost> as an error. This will just
|
|
# require another check_parsing_errors() after all files are included...
|
|
# (after a find_directive search is executed currently). It can be a one
|
|
# time check however because all of Trustifies transactions will ensure
|
|
# only properly formed sections are added.
|
|
|
|
# Note: This protocol works for filenames with spaces in it, the sites are
|
|
# properly set up and directives are changed appropriately, but Apache won't
|
|
# recognize names in sites-enabled that have spaces. These are not added to the
|
|
# 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 '_'
|
|
|
|
# Note: FILEPATHS and changes to files are transactional. They are copied
|
|
# over before the updates are made to the existing files. NEW_FILES is
|
|
# transactional due to the use of register_file_creation()
|
|
|
|
class VH(object):
|
|
"""Represents an Apache Virtualhost.
|
|
|
|
:ivar str filep: file path of VH
|
|
:ivar str path: Augeas path to virtual host
|
|
:ivar list addrs: Virtual Host addresses (:class:`list` of :class:`str`)
|
|
:ivar list names: Server names/aliases of vhost
|
|
(:class:`list` of :class:`str`)
|
|
|
|
:ivar bool ssl: SSLEngine on in vhost
|
|
:ivar bool enabled: Virtual host is enabled
|
|
|
|
"""
|
|
|
|
def __init__(self, filep, path, addrs, ssl, enabled, names=None):
|
|
"""Initialize a VH."""
|
|
self.filep = filep
|
|
self.path = path
|
|
self.addrs = addrs
|
|
self.names = [] if names is None else names
|
|
self.ssl = ssl
|
|
self.enabled = enabled
|
|
|
|
def add_name(self, name):
|
|
"""Add name to vhost."""
|
|
self.names.append(name)
|
|
|
|
def __str__(self):
|
|
return ("file: %s\n"
|
|
"vh_path: %s\n"
|
|
"addrs: %s\n"
|
|
"names: %s\n"
|
|
"ssl: %s\n"
|
|
"enabled: %s" % (self.filep, self.path, self.addrs,
|
|
self.names, self.ssl, self.enabled))
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, self.__class__):
|
|
return (self.filep == other.filep and self.path == other.path and
|
|
set(self.addrs) == set(other.addrs) and
|
|
set(self.names) == set(other.names) and
|
|
self.ssl == other.ssl and self.enabled == other.enabled)
|
|
|
|
return False
|
|
|
|
|
|
class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
|
|
"""Apache configurator.
|
|
|
|
State of Configurator: This code has been tested under Ubuntu 12.04
|
|
Apache 2.2 and this code works for Ubuntu 14.04 Apache 2.4. Further
|
|
notes below.
|
|
|
|
This class was originally developed for Apache 2.2 and has not seen a
|
|
an overhaul to include proper setup of new Apache configurations.
|
|
I have implemented most of the changes... the missing ones are
|
|
mod_ssl.c vs ssl_mod, and I need to account for configuration variables.
|
|
That being said, this class can still adequately configure most typical
|
|
Apache 2.4 servers as the deprecated NameVirtualHost has no effect
|
|
and the typical directories are parsed by the Augeas configuration
|
|
parser automatically.
|
|
|
|
.. todo:: Add support for config file variables Define rootDir /var/www/
|
|
|
|
The API of this class will change in the coming weeks as the exact
|
|
needs of client's are clarified with the new and developing protocol.
|
|
|
|
:ivar str server_root: Path to Apache root directory
|
|
:ivar dict location: Path to various files associated
|
|
with the configuration
|
|
:ivar float version: version of Apache
|
|
:ivar list vhosts: All vhosts found in the configuration
|
|
(:class:`list` of :class:`VH`)
|
|
|
|
:ivar dict assoc: Mapping between domains and vhosts
|
|
|
|
"""
|
|
def __init__(self, server_root=CONFIG.SERVER_ROOT, direc=None,
|
|
ssl_options=CONFIG.OPTIONS_SSL_CONF, version=None):
|
|
"""Initialize an Apache Configurator.
|
|
|
|
:param str server_root: the apache server root directory
|
|
:param dict direc: locations of various config directories
|
|
(used mostly for unittesting)
|
|
:param str ssl_options: path of options-ssl.conf
|
|
(used mostly for unittesting)
|
|
:param tup version: version of Apache as a tuple (2, 4, 7)
|
|
(used mostly for unittesting)
|
|
|
|
"""
|
|
if direc is None:
|
|
direc = {"backup": CONFIG.BACKUP_DIR,
|
|
"temp": CONFIG.TEMP_CHECKPOINT_DIR,
|
|
"progress": CONFIG.IN_PROGRESS_DIR,
|
|
"config": CONFIG.CONFIG_DIR,
|
|
"work": CONFIG.WORK_DIR}
|
|
|
|
super(ApacheConfigurator, self).__init__(direc)
|
|
|
|
self.server_root = server_root
|
|
|
|
# See if any temporary changes need to be recovered
|
|
# This needs to occur before VH objects are setup...
|
|
# because this will change the underlying configuration and potential
|
|
# vhosts
|
|
self.recovery_routine()
|
|
|
|
# Verify that all directories and files exist with proper permissions
|
|
if os.geteuid() == 0:
|
|
self.verify_setup()
|
|
|
|
# Find configuration root and make sure augeas can parse it.
|
|
self.location = self._set_locations(ssl_options)
|
|
self._parse_file(self.location["root"])
|
|
|
|
# Must also attempt to parse sites-available or equivalent
|
|
# Sites-available is not included naturally in configuration
|
|
self._parse_file(os.path.join(self.server_root, "sites-available/*"))
|
|
|
|
# Set Version
|
|
self.version = self.get_version() if version is None else version
|
|
|
|
# Check for errors in parsing files with Augeas
|
|
self.check_parsing_errors("httpd.aug")
|
|
# This problem has been fixed in Augeas 1.0
|
|
self.standardize_excl()
|
|
|
|
# Get all of the available vhosts
|
|
self.vhosts = self.get_virtual_hosts()
|
|
# Add name_server association dict
|
|
self.assoc = dict()
|
|
|
|
# Enable mod_ssl if it isn't already enabled
|
|
# This is Let's Encrypt... we enable mod_ssl on initialization :)
|
|
# TODO: attempt to make the check faster... this enable should
|
|
# be asynchronous as it shouldn't be that time sensitive
|
|
# on initialization
|
|
self._prepare_server_https()
|
|
|
|
# Note: initialization doesn't check to see if the config is correct
|
|
# by Apache's standards. This should be done by the client (client.py)
|
|
# 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
|
|
# are being modified whether that be in the include files or in the
|
|
# virtualhost declaration - these directives can be overwritten
|
|
def deploy_cert(self, vhost, cert, key, cert_chain=None):
|
|
"""Deploys certificate to specified virtual host.
|
|
|
|
Currently tries to find the last directives to deploy the cert in
|
|
the given virtualhost. If it can't find the directives, it searches
|
|
the "included" confs. The function verifies that it has located
|
|
the three directives and finally modifies them to point to the correct
|
|
destination
|
|
|
|
.. todo:: Make sure last directive is changed
|
|
|
|
.. todo:: Might be nice to remove chain directive if none exists
|
|
This shouldn't happen within letsencrypt though
|
|
|
|
:param vhost: ssl vhost to deploy certificate
|
|
:type vhost: :class:`VH`
|
|
|
|
:param str cert: certificate filename
|
|
:param str key: private key filename
|
|
:param str cert_chain: certificate chain filename
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
path = {}
|
|
|
|
path["cert_file"] = self.find_directive(case_i(
|
|
"SSLCertificateFile"), None, vhost.path)
|
|
path["cert_key"] = self.find_directive(case_i(
|
|
"SSLCertificateKeyFile"), None, vhost.path)
|
|
|
|
# Only include if a certificate chain is specified
|
|
if cert_chain is not None:
|
|
path["cert_chain"] = self.find_directive(
|
|
case_i("SSLCertificateChainFile"), None, vhost.path)
|
|
|
|
if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0:
|
|
# Throw some "can't find all of the directives error"
|
|
logger.warn(("Cannot find a cert or key directive in %s"
|
|
% vhost.path))
|
|
logger.warn("VirtualHost was not modified")
|
|
# Presumably break here so that the virtualhost is not modified
|
|
return False
|
|
|
|
logger.info("Deploying Certificate to VirtualHost %s" % vhost.filep)
|
|
|
|
self.aug.set(path["cert_file"][0], cert)
|
|
self.aug.set(path["cert_key"][0], key)
|
|
if cert_chain is not None:
|
|
if len(path["cert_chain"]) == 0:
|
|
self.add_dir(vhost.path, "SSLCertificateChainFile", cert_chain)
|
|
else:
|
|
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))
|
|
self.save_notes += "\tSSLCertificateFile %s\n" % cert
|
|
self.save_notes += "\tSSLCertificateKeyFile %s\n" % key
|
|
if cert_chain:
|
|
self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain
|
|
# This is a significant operation, make a checkpoint
|
|
return self.save()
|
|
|
|
def choose_virtual_host(self, target_name):
|
|
""" Chooses a virtual host based on the given domain name.
|
|
|
|
.. todo:: This should maybe return list if no obvious answer
|
|
is presented.
|
|
|
|
:param str name: domain name
|
|
|
|
:returns: ssl vhost associated with name
|
|
:rtype: :class:`VH`
|
|
|
|
"""
|
|
# Allows for domain names to be associated with a virtual host
|
|
# Client isn't using create_dn_server_assoc(self, dn, vh) yet
|
|
for domain, vhost in self.assoc:
|
|
if domain == target_name:
|
|
return vhost
|
|
# Check for servernames/aliases for ssl hosts
|
|
for vhost in self.vhosts:
|
|
if vhost.ssl:
|
|
for name in vhost.names:
|
|
if name == target_name:
|
|
return vhost
|
|
# Checking for domain name in vhost address
|
|
# This technique is not recommended by Apache but is technically valid
|
|
for vhost in self.vhosts:
|
|
for addr in vhost.addrs:
|
|
tup = addr.partition(":")
|
|
if tup[0] == target_name and tup[2] == "443":
|
|
return vhost
|
|
|
|
# Check for non ssl vhosts with servernames/aliases == 'name'
|
|
for vhost in self.vhosts:
|
|
if not vhost.ssl:
|
|
for name in vhost.names:
|
|
if name == target_name:
|
|
# When do we need to self.make_vhost_ssl(v)
|
|
return self.make_vhost_ssl(vhost)
|
|
|
|
# No matches, search for the default
|
|
for vhost in self.vhosts:
|
|
for addr in vhost.addrs:
|
|
if addr == "_default_:443":
|
|
return vhost
|
|
return None
|
|
|
|
def create_dn_server_assoc(self, domain, vhost):
|
|
"""Create an association between a domain name and virtual host.
|
|
|
|
Helps to choose an appropriate vhost
|
|
|
|
:param str domain: domain name to associate
|
|
|
|
:param vhost: virtual host to associate with domain
|
|
:type vhost: :class:`VH`
|
|
|
|
"""
|
|
self.assoc[domain] = vhost
|
|
|
|
def get_all_names(self):
|
|
"""Returns all names found in the Apache Configuration.
|
|
|
|
:returns: All ServerNames, ServerAliases, and reverse DNS entries for
|
|
virtual host addresses
|
|
:rtype: set
|
|
|
|
"""
|
|
all_names = set()
|
|
|
|
# Kept in same function to avoid multiple compilations of the regex
|
|
priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|"
|
|
r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)")
|
|
private_ips = re.compile(priv_ip_regex)
|
|
|
|
for vhost in self.vhosts:
|
|
all_names.update(vhost.names)
|
|
for addr in vhost.addrs:
|
|
a_tup = addr.partition(":")
|
|
|
|
# If it isn't a private IP, do a reverse DNS lookup
|
|
if not private_ips.match(a_tup[0]):
|
|
try:
|
|
socket.inet_aton(a_tup[0])
|
|
all_names.add(socket.gethostbyaddr(a_tup[0])[0])
|
|
except (socket.error, socket.herror, socket.timeout):
|
|
continue
|
|
|
|
return all_names
|
|
|
|
def _set_locations(self, ssl_options):
|
|
"""Set default location for directives.
|
|
|
|
Locations are given as file_paths
|
|
.. todo:: Make sure that files are included
|
|
|
|
"""
|
|
root = self._find_config_root()
|
|
default = self._set_user_config_file()
|
|
|
|
temp = os.path.join(self.server_root, "ports.conf")
|
|
if os.path.isfile(temp):
|
|
listen = temp
|
|
name = temp
|
|
else:
|
|
listen = default
|
|
name = default
|
|
|
|
return {"root": root, "default": default, "listen": listen,
|
|
"name": name, "ssl_options": ssl_options}
|
|
|
|
def _find_config_root(self):
|
|
"""Find the Apache Configuration Root file."""
|
|
location = ["apache2.conf", "httpd.conf"]
|
|
|
|
for name in location:
|
|
if os.path.isfile(os.path.join(self.server_root, name)):
|
|
return os.path.join(self.server_root, name)
|
|
|
|
raise errors.LetsEncryptConfiguratorError(
|
|
"Could not find configuration root")
|
|
|
|
def _set_user_config_file(self, filename=''):
|
|
"""Set the appropriate user configuration file
|
|
|
|
.. todo:: This will have to be updated for other distros versions
|
|
|
|
:param str filename: optional filename that will be used as the
|
|
user config
|
|
|
|
"""
|
|
if filename:
|
|
return filename
|
|
else:
|
|
# Basic check to see if httpd.conf exists and
|
|
# in heirarchy via direct include
|
|
# httpd.conf was very common as a user file in Apache 2.2
|
|
if (os.path.isfile(self.server_root + 'httpd.conf') and
|
|
self.find_directive(
|
|
case_i("Include"), case_i("httpd.conf"))):
|
|
return os.path.join(self.server_root, 'httpd.conf')
|
|
else:
|
|
return os.path.join(self.server_root + 'apache2.conf')
|
|
|
|
def _add_servernames(self, host):
|
|
"""Helper function for get_virtual_hosts().
|
|
|
|
:param host: In progress vhost whose names will be added
|
|
:type host: :class:`VH`
|
|
|
|
"""
|
|
name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | "
|
|
"%s//*[self::directive=~regexp('%s')]" %
|
|
(host.path,
|
|
case_i('ServerName'),
|
|
host.path,
|
|
case_i('ServerAlias'))))
|
|
|
|
for name in name_match:
|
|
args = self.aug.match(name + "/*")
|
|
for arg in args:
|
|
host.add_name(self.aug.get(arg))
|
|
|
|
def _create_vhost(self, path):
|
|
"""Used by get_virtual_hosts to create vhost objects
|
|
|
|
:param str path: Augeas path to virtual host
|
|
|
|
:returns: newly created vhost
|
|
:rtype: :class:`VH`
|
|
|
|
"""
|
|
addrs = []
|
|
args = self.aug.match(path + "/arg")
|
|
for arg in args:
|
|
addrs.append(self.aug.get(arg))
|
|
is_ssl = False
|
|
|
|
if self.find_directive(
|
|
case_i("SSLEngine"), case_i("on"), path):
|
|
is_ssl = True
|
|
|
|
filename = get_file_path(path)
|
|
is_enabled = self.is_site_enabled(filename)
|
|
vhost = VH(filename, path, addrs, is_ssl, is_enabled)
|
|
self._add_servernames(vhost)
|
|
return vhost
|
|
|
|
# TODO: make "sites-available" a configurable directory
|
|
def get_virtual_hosts(self):
|
|
"""Returns list of virtual hosts found in the Apache configuration.
|
|
|
|
:returns: List of :class:`VH` objects found in configuration
|
|
:rtype: list
|
|
|
|
"""
|
|
# Search sites-available, httpd.conf for possible virtual hosts
|
|
paths = self.aug.match(
|
|
("/files%ssites-available//*[label()=~regexp('%s')]" %
|
|
(self.server_root, case_i('VirtualHost'))))
|
|
vhs = []
|
|
|
|
for path in paths:
|
|
vhs.append(self._create_vhost(path))
|
|
|
|
return vhs
|
|
|
|
# pylint: disable=anomalous-backslash-in-string
|
|
def is_name_vhost(self, target_addr):
|
|
"""Returns if vhost is a name based vhost
|
|
|
|
NameVirtualHost was deprecated in Apache 2.4 as all VirtualHosts are
|
|
now NameVirtualHosts. If version is earlier than 2.4, check if addr
|
|
has a NameVirtualHost directive in the Apache config
|
|
|
|
:param str addr: vhost address ie. \*:443
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
# Mixed and matched wildcard NameVirtualHost with VirtualHost
|
|
# behavior is undefined. Make sure that an exact match exists
|
|
|
|
# search for NameVirtualHost directive for ip_addr
|
|
# note ip_addr can be FQDN although Apache does not recommend it
|
|
return (self.version >= (2, 4) or
|
|
self.find_directive(
|
|
case_i("NameVirtualHost"), case_i(target_addr)))
|
|
|
|
def add_name_vhost(self, addr):
|
|
"""Adds NameVirtualHost directive for given address.
|
|
|
|
:param str addr: Address that will be added as NameVirtualHost directive
|
|
|
|
"""
|
|
path = self._add_dir_to_ifmodssl(
|
|
get_aug_path(self.location["name"]), "NameVirtualHost", addr)
|
|
|
|
self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr
|
|
self.save_notes += "\tDirective added to %s\n" % path
|
|
|
|
def _add_dir_to_ifmodssl(self, aug_conf_path, directive, val):
|
|
"""Adds directive and value to IfMod ssl block.
|
|
|
|
Adds given directive and value along configuration path within
|
|
an IfMod mod_ssl.c block. If the IfMod block does not exist in
|
|
the file, it is created.
|
|
|
|
:param str aug_conf_path: Desired Augeas config path to add directive
|
|
:param str directive: Directive you would like to add
|
|
:param str val: Value of directive ie. Listen 443, 443 is the value
|
|
|
|
"""
|
|
# TODO: Add error checking code... does the path given even exist?
|
|
# Does it throw exceptions?
|
|
if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c")
|
|
# IfModule can have only one valid argument, so append after
|
|
self.aug.insert(if_mod_path + "arg", "directive", False)
|
|
nvh_path = if_mod_path + "directive[1]"
|
|
self.aug.set(nvh_path, directive)
|
|
self.aug.set(nvh_path + "/arg", val)
|
|
|
|
def _prepare_server_https(self):
|
|
"""Prepare the server for HTTPS.
|
|
|
|
Make sure that the ssl_module is loaded and that the server
|
|
is appropriately listening on port 443.
|
|
|
|
"""
|
|
if not check_ssl_loaded():
|
|
logger.info("Loading mod_ssl into Apache Server")
|
|
enable_mod("ssl")
|
|
|
|
# Check for Listen 443
|
|
# Note: This could be made to also look for ip:443 combo
|
|
# TODO: Need to search only open directives and IfMod mod_ssl.c
|
|
if len(self.find_directive(case_i("Listen"), "443")) == 0:
|
|
logger.debug("No Listen 443 directive found")
|
|
logger.debug("Setting the Apache Server to Listen on port 443")
|
|
path = self._add_dir_to_ifmodssl(
|
|
get_aug_path(self.location["listen"]), "Listen", "443")
|
|
self.save_notes += "Added Listen 443 directive to %s\n" % path
|
|
|
|
def make_server_sni_ready(self, vhost, default_addr="*:443"):
|
|
"""Checks to see if the server is ready for SNI challenges.
|
|
|
|
:param vhost: VHost to check SNI compatibility
|
|
:type vhost: :class:`VH`
|
|
|
|
:param str default_addr: TODO - investigate function further
|
|
|
|
"""
|
|
if self.version >= (2, 4):
|
|
return
|
|
# Check for NameVirtualHost
|
|
# First see if any of the vhost addresses is a _default_ addr
|
|
for addr in vhost.addrs:
|
|
tup = addr.partition(":")
|
|
if tup[0] == "_default_":
|
|
if not self.is_name_vhost(default_addr):
|
|
logger.debug(("Setting all VirtualHosts on "
|
|
"%s to be name based vhosts" % default_addr))
|
|
self.add_name_vhost(default_addr)
|
|
|
|
# No default addresses... so set each one individually
|
|
for addr in vhost.addrs:
|
|
if not self.is_name_vhost(addr):
|
|
logger.debug(("Setting VirtualHost at %s "
|
|
"to be a name based virtual host" % addr))
|
|
self.add_name_vhost(addr)
|
|
|
|
def _get_ifmod(self, aug_conf_path, mod):
|
|
"""Returns the path to <IfMod mod> and creates one if it doesn't exist.
|
|
|
|
:param str aug_conf_path: Augeas configuration path
|
|
:param str mod: module ie. mod_ssl.c
|
|
|
|
"""
|
|
if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" %
|
|
(aug_conf_path, mod)))
|
|
if len(if_mods) == 0:
|
|
self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "")
|
|
self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod)
|
|
if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" %
|
|
(aug_conf_path, mod)))
|
|
# Strip off "arg" at end of first ifmod path
|
|
return if_mods[0][:len(if_mods[0]) - 3]
|
|
|
|
def add_dir(self, aug_conf_path, directive, arg):
|
|
"""Appends directive to the end fo the file given by aug_conf_path.
|
|
|
|
.. note:: Not added to AugeasConfigurator because it may depend
|
|
on the lens
|
|
|
|
:param str aug_conf_path: Augeas configuration path to add directive
|
|
:param str directive: Directive to add
|
|
:param str arg: Value of the directive. ie. Listen 443, 443 is arg
|
|
|
|
"""
|
|
self.aug.set(aug_conf_path + "/directive[last() + 1]", directive)
|
|
if type(arg) is not list:
|
|
self.aug.set(aug_conf_path + "/directive[last()]/arg", arg)
|
|
else:
|
|
for i in range(len(arg)):
|
|
self.aug.set("%s/directive[last()]/arg[%d]" %
|
|
(aug_conf_path, (i+1)),
|
|
arg[i])
|
|
|
|
def find_directive(self, directive, arg=None, start=None):
|
|
"""Finds directive in the configuration.
|
|
|
|
Recursively searches through config files to find directives
|
|
Directives should be in the form of a case insensitive regex currently
|
|
|
|
.. todo:: arg should probably be a list
|
|
|
|
Note: Augeas is inherently case sensitive while Apache is case
|
|
insensitive. Augeas 1.0 allows case insensitive regexes like
|
|
regexp(/Listen/, 'i'), however the version currently supported
|
|
by Ubuntu 0.10 does not. Thus I have included my own case insensitive
|
|
transformation by calling case_i() on everything to maintain
|
|
compatibility.
|
|
|
|
:param str directive: Directive to look for
|
|
|
|
:param arg: Specific value direcitve must have, None if all should
|
|
be considered
|
|
:type arg: str or None
|
|
|
|
:param str start: Beginning Augeas path to begin looking
|
|
|
|
"""
|
|
# Cannot place member variable in the definition of the function so...
|
|
if not start:
|
|
start = get_aug_path(self.location["root"])
|
|
|
|
# Debug code
|
|
# print "find_dir:", directive, "arg:", arg, " | Looking in:", start
|
|
# No regexp code
|
|
# if arg is None:
|
|
# matches = self.aug.match(start +
|
|
# "//*[self::directive='"+directive+"']/arg")
|
|
# else:
|
|
# matches = self.aug.match(start +
|
|
# "//*[self::directive='" + directive+"']/* [self::arg='" + arg + "']")
|
|
|
|
# includes = self.aug.match(start +
|
|
# "//* [self::directive='Include']/* [label()='arg']")
|
|
|
|
if arg is None:
|
|
matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg"
|
|
% (start, directive)))
|
|
else:
|
|
matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*"
|
|
"[self::arg=~regexp('%s')]" %
|
|
(start, directive, arg)))
|
|
|
|
incl_regex = "(%s)|(%s)" % (case_i('Include'),
|
|
case_i('IncludeOptional'))
|
|
|
|
includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* "
|
|
"[label()='arg']" % (start, incl_regex)))
|
|
|
|
# for inc in includes:
|
|
# print inc, self.aug.get(inc)
|
|
|
|
for include in includes:
|
|
# start[6:] to strip off /files
|
|
matches.extend(self.find_directive(
|
|
directive, arg, self._get_include_path(strip_dir(start[6:]),
|
|
self.aug.get(include))))
|
|
|
|
return matches
|
|
|
|
def _get_include_path(self, cur_dir, arg):
|
|
"""Converts an Apache Include directive into Augeas path.
|
|
|
|
Converts an Apache Include directive argument into an Augeas
|
|
searchable path
|
|
|
|
.. todo:: convert to use os.path.join()
|
|
|
|
:param str cur_dir: current working directory
|
|
|
|
:param str arg: Argument of Include directive
|
|
|
|
:returns: Augeas path string
|
|
:rtype: str
|
|
|
|
"""
|
|
# Sanity check argument - maybe
|
|
# Question: what can the attacker do with control over this string
|
|
# Effect parse file... maybe exploit unknown errors in Augeas
|
|
# If the attacker can Include anything though... and this function
|
|
# only operates on Apache real config data... then the attacker has
|
|
# already won.
|
|
# Perhaps it is better to simply check the permissions on all
|
|
# included files?
|
|
# check_config to validate apache config doesn't work because it
|
|
# would create a race condition between the check and this input
|
|
|
|
# TODO: Maybe... although I am convinced we have lost if
|
|
# Apache files can't be trusted. The augeas include path
|
|
# should be made to be exact.
|
|
|
|
# Check to make sure only expected characters are used <- maybe remove
|
|
# validChars = re.compile("[a-zA-Z0-9.*?_-/]*")
|
|
# matchObj = validChars.match(arg)
|
|
# if matchObj.group() != arg:
|
|
# logger.error("Error: Invalid regexp characters in %s" % arg)
|
|
# return []
|
|
|
|
# Standardize the include argument based on server root
|
|
if not arg.startswith("/"):
|
|
arg = cur_dir + arg
|
|
# conf/ is a special variable for ServerRoot in Apache
|
|
elif arg.startswith("conf/"):
|
|
arg = self.server_root + arg[5:]
|
|
# TODO: Test if Apache allows ../ or ~/ for Includes
|
|
|
|
# Attempts to add a transform to the file if one does not already exist
|
|
self._parse_file(arg)
|
|
|
|
# Argument represents an fnmatch regular expression, convert it
|
|
# Split up the path and convert each into an Augeas accepted regex
|
|
# then reassemble
|
|
if "*" in arg or "?" in arg:
|
|
split_arg = arg.split("/")
|
|
for idx, split in enumerate(split_arg):
|
|
# * and ? are the two special fnmatch characters
|
|
if "*" in split or "?" in split:
|
|
# Turn it into a augeas regex
|
|
# TODO: Can this instead be an augeas glob instead of regex
|
|
split_arg[idx] = ("* [label()=~regexp('%s')]" %
|
|
self.fnmatch_to_re(split))
|
|
# Reassemble the argument
|
|
arg = "/".join(split_arg)
|
|
|
|
# If the include is a directory, just return the directory as a file
|
|
if arg.endswith("/"):
|
|
return get_aug_path(arg[:len(arg)-1])
|
|
return get_aug_path(arg)
|
|
|
|
def make_vhost_ssl(self, nonssl_vhost):
|
|
"""Makes an ssl_vhost version of a nonssl_vhost.
|
|
|
|
Duplicates vhost and adds default ssl options
|
|
New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT
|
|
|
|
:param nonssl_vhost: Valid VH that doesn't have SSLEngine on
|
|
:type nonssl_vhost: :class:`VH`
|
|
|
|
:returns: SSL vhost
|
|
:rtype: :class:`VH`
|
|
|
|
"""
|
|
avail_fp = nonssl_vhost.filep
|
|
# Copy file
|
|
if avail_fp.endswith(".conf"):
|
|
ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT
|
|
else:
|
|
ssl_fp = avail_fp + CONFIG.LE_VHOST_EXT
|
|
|
|
# First register the creation so that it is properly removed if
|
|
# configuration is rolled back
|
|
self.register_file_creation(False, ssl_fp)
|
|
|
|
try:
|
|
orig_file = open(avail_fp, 'r')
|
|
new_file = open(ssl_fp, 'w')
|
|
new_file.write("<IfModule mod_ssl.c>\n")
|
|
for line in orig_file:
|
|
new_file.write(line)
|
|
new_file.write("</IfModule>\n")
|
|
except IOError:
|
|
logger.fatal("Error writing/reading to file in make_vhost_ssl")
|
|
sys.exit(49)
|
|
finally:
|
|
orig_file.close()
|
|
new_file.close()
|
|
|
|
self.aug.load()
|
|
# Delete the VH addresses because they may change here
|
|
del nonssl_vhost.addrs[:]
|
|
ssl_addrs = []
|
|
|
|
# change address to address:443, address:80
|
|
addr_match = "/files%s//* [label()=~regexp('%s')]/arg"
|
|
ssl_addr_p = self.aug.match(
|
|
addr_match % (ssl_fp, case_i('VirtualHost')))
|
|
avail_addr_p = self.aug.match(
|
|
addr_match % (avail_fp, case_i('VirtualHost')))
|
|
|
|
for i in range(len(avail_addr_p)):
|
|
avail_old_arg = str(self.aug.get(avail_addr_p[i]))
|
|
ssl_old_arg = str(self.aug.get(ssl_addr_p[i]))
|
|
avail_tup = avail_old_arg.partition(":")
|
|
ssl_tup = ssl_old_arg.partition(":")
|
|
avail_new_addr = avail_tup[0] + ":80"
|
|
ssl_new_addr = ssl_tup[0] + ":443"
|
|
self.aug.set(avail_addr_p[i], avail_new_addr)
|
|
self.aug.set(ssl_addr_p[i], ssl_new_addr)
|
|
nonssl_vhost.addrs.append(avail_new_addr)
|
|
ssl_addrs.append(ssl_new_addr)
|
|
|
|
# Add directives
|
|
vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" %
|
|
(ssl_fp, case_i('VirtualHost'))))
|
|
if len(vh_p) != 1:
|
|
logger.error("Error: should only be one vhost in %s" % avail_fp)
|
|
sys.exit(1)
|
|
|
|
self.add_dir(vh_p[0], "SSLCertificateFile",
|
|
"/etc/ssl/certs/ssl-cert-snakeoil.pem")
|
|
self.add_dir(vh_p[0], "SSLCertificateKeyFile",
|
|
"/etc/ssl/private/ssl-cert-snakeoil.key")
|
|
self.add_dir(vh_p[0], "Include", self.location["ssl_options"])
|
|
|
|
# Log actions and create save notes
|
|
logger.info("Created an SSL vhost at %s" % ssl_fp)
|
|
self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp
|
|
self.save()
|
|
|
|
# We know the length is one because of the assertion above
|
|
ssl_vhost = self._create_vhost(vh_p[0])
|
|
self.vhosts.append(ssl_vhost)
|
|
|
|
# Check if nonssl_vhost's address was NameVirtualHost
|
|
# NOTE: Searches through Augeas seem to ruin changes to directives
|
|
# The configuration must also be saved before being searched
|
|
# for the new directives; For these reasons... this is tacked
|
|
# on after fully creating the new vhost
|
|
need_to_save = False
|
|
for i in range(len(nonssl_vhost.addrs)):
|
|
|
|
if (self.is_name_vhost(nonssl_vhost.addrs[i]) and
|
|
not self.is_name_vhost(ssl_addrs[i])):
|
|
self.add_name_vhost(ssl_addrs[i])
|
|
logger.info("Enabling NameVirtualHosts on " + ssl_addrs[i])
|
|
need_to_save = True
|
|
|
|
if need_to_save:
|
|
self.save()
|
|
|
|
return ssl_vhost
|
|
|
|
def enable_redirect(self, ssl_vhost):
|
|
"""Redirect all equivalent HTTP traffic to ssl_vhost.
|
|
|
|
Adds Redirect directive to the port 80 equivalent of ssl_vhost
|
|
First the function attempts to find the vhost with equivalent
|
|
ip addresses that serves on non-ssl ports
|
|
The function then adds the directive
|
|
|
|
:param ssl_vhost: Destination of traffic, an ssl enabled vhost
|
|
:type ssl_vhost: :class:`VH`
|
|
|
|
:returns: Success, general_vhost (HTTP vhost)
|
|
:rtype: (bool, :class:`VH`)
|
|
|
|
"""
|
|
# TODO: Enable check to see if it is already there
|
|
# to avoid the extra restart
|
|
enable_mod("rewrite")
|
|
|
|
general_v = self._general_vhost(ssl_vhost)
|
|
if general_v is None:
|
|
# Add virtual_server with redirect
|
|
logger.debug(
|
|
"Did not find http version of ssl virtual host... creating")
|
|
return self.create_redirect_vhost(ssl_vhost)
|
|
else:
|
|
# Check if redirection already exists
|
|
exists, code = self.existing_redirect(general_v)
|
|
if exists:
|
|
if code == 0:
|
|
logger.debug("Redirect already added")
|
|
return True, general_v
|
|
else:
|
|
logger.debug("Unknown redirect exists for this vhost")
|
|
return False, general_v
|
|
# Add directives to server
|
|
self.add_dir(general_v.path, "RewriteEngine", "On")
|
|
self.add_dir(general_v.path,
|
|
"RewriteRule", CONFIG.REWRITE_HTTPS_ARGS)
|
|
self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' %
|
|
(general_v.filep, ssl_vhost.filep))
|
|
self.save()
|
|
return True, general_v
|
|
|
|
def existing_redirect(self, vhost):
|
|
"""Checks to see if existing redirect is in place.
|
|
|
|
Checks to see if virtualhost already contains a rewrite or redirect
|
|
returns boolean, integer
|
|
The boolean indicates whether the redirection exists...
|
|
The integer has the following code:
|
|
0 - Existing letsencrypt https rewrite rule is appropriate and in place
|
|
1 - Virtual host contains a Redirect directive
|
|
2 - Virtual host contains an unknown RewriteRule
|
|
|
|
-1 is also returned in case of no redirection/rewrite directives
|
|
|
|
:param vhost: vhost to check
|
|
:type vhost: :class:`VH`
|
|
|
|
:returns: Success, code value... see documentation
|
|
:rtype: bool, int
|
|
|
|
"""
|
|
rewrite_path = self.find_directive(
|
|
case_i("RewriteRule"), None, vhost.path)
|
|
redirect_path = self.find_directive(
|
|
case_i("Redirect"), None, vhost.path)
|
|
|
|
if redirect_path:
|
|
# "Existing Redirect directive for virtualhost"
|
|
return True, 1
|
|
if not rewrite_path:
|
|
# "No existing redirection for virtualhost"
|
|
return False, -1
|
|
if len(rewrite_path) == len(CONFIG.REWRITE_HTTPS_ARGS):
|
|
for idx, match in enumerate(rewrite_path):
|
|
if self.aug.get(match) != CONFIG.REWRITE_HTTPS_ARGS[idx]:
|
|
# Not a letsencrypt https rewrite
|
|
return True, 2
|
|
# Existing letsencrypt https rewrite rule is in place
|
|
return True, 0
|
|
# Rewrite path exists but is not a letsencrypt https rule
|
|
return True, 2
|
|
|
|
def create_redirect_vhost(self, ssl_vhost):
|
|
"""Creates an http_vhost specifically to redirect for the ssl_vhost.
|
|
|
|
:param ssl_vhost: ssl vhost
|
|
:type ssl_vhost: :class:`VH`
|
|
|
|
:returns: Success, vhost
|
|
:rtype: (bool, :class:`VH`)
|
|
|
|
"""
|
|
# Consider changing this to a dictionary check
|
|
# Make sure adding the vhost will be safe
|
|
conflict, host_or_addrs = self._conflicting_host(ssl_vhost)
|
|
if conflict:
|
|
return False, host_or_addrs
|
|
|
|
redirect_addrs = host_or_addrs
|
|
|
|
# get servernames and serveraliases
|
|
serveralias = ""
|
|
servername = ""
|
|
size_n = len(ssl_vhost.names)
|
|
if size_n > 0:
|
|
servername = "ServerName " + ssl_vhost.names[0]
|
|
if size_n > 1:
|
|
serveralias = " ".join(ssl_vhost.names[1:size_n])
|
|
serveralias = "ServerAlias " + serveralias
|
|
redirect_file = "<VirtualHost" + redirect_addrs + "> \n\
|
|
" + servername + "\n\
|
|
" + serveralias + " \n\
|
|
ServerSignature Off \n\
|
|
\n\
|
|
RewriteEngine On \n\
|
|
RewriteRule ^.*$ https://%{SERVER_NAME}%{REQUEST_URI} [L,R=permanent]\n\
|
|
\n\
|
|
ErrorLog /var/log/apache2/redirect.error.log \n\
|
|
LogLevel warn \n\
|
|
</VirtualHost>\n"
|
|
|
|
# Write out the file
|
|
# This is the default name
|
|
redirect_filename = "le-redirect.conf"
|
|
|
|
# See if a more appropriate name can be applied
|
|
if len(ssl_vhost.names) > 0:
|
|
# Sanity check...
|
|
# make sure servername doesn't exceed filename length restriction
|
|
if ssl_vhost.names[0] < (255-23):
|
|
redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0]
|
|
|
|
redirect_filepath = ("%ssites-available/%s" %
|
|
(self.server_root, redirect_filename))
|
|
|
|
# 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)
|
|
|
|
# Write out file
|
|
with open(redirect_filepath, 'w') as redirect_fd:
|
|
redirect_fd.write(redirect_file)
|
|
logger.info("Created redirect file: " + redirect_filename)
|
|
|
|
self.aug.load()
|
|
# Make a new vhost data structure and add it to the lists
|
|
new_fp = self.server_root + "sites-available/" + redirect_filename
|
|
new_vhost = self._create_vhost(get_aug_path(new_fp))
|
|
self.vhosts.append(new_vhost)
|
|
|
|
# Finally create documentation for the change
|
|
self.save_notes += ('Created a port 80 vhost, %s, for redirection to '
|
|
'ssl vhost %s\n' %
|
|
(new_vhost.filep, ssl_vhost.filep))
|
|
|
|
return True, new_vhost
|
|
|
|
def _conflicting_host(self, ssl_vhost):
|
|
"""Checks for conflicting HTTP vhost for ssl_vhost.
|
|
|
|
Checks for a conflicting host, such that a new port 80 host could not
|
|
be created without ruining the apache config
|
|
Used with redirection
|
|
|
|
returns: conflict, host_or_addrs - boolean
|
|
if conflict: returns conflicting vhost
|
|
if not conflict: returns space separated list of new host addrs
|
|
|
|
:param ssl_vhost: SSL Vhost to check for possible port 80 redirection
|
|
:type ssl_vhost: :class:`VH`
|
|
|
|
:returns: TODO
|
|
:rtype: TODO
|
|
|
|
"""
|
|
# Consider changing this to a dictionary check
|
|
redirect_addrs = ""
|
|
for ssl_a in ssl_vhost.addrs:
|
|
# Add space on each new addr, combine "VirtualHost"+redirect_addrs
|
|
redirect_addrs = redirect_addrs + " "
|
|
ssl_tup = ssl_a.partition(":")
|
|
ssl_a_vhttp = ssl_tup[0] + ":80"
|
|
# Search for a conflicting host...
|
|
for vhost in self.vhosts:
|
|
if vhost.enabled:
|
|
for addr in vhost.addrs:
|
|
# Convert :* to standard ip address
|
|
if addr.endswith(":*"):
|
|
addr = addr[:len(addr)-2]
|
|
# Would require NameBasedVirtualHosts,too complicated?
|
|
# Maybe do later... right now just return false
|
|
# or overlapping addresses... order matters
|
|
if addr == ssl_a_vhttp or addr == ssl_tup[0]:
|
|
# We have found a conflicting host... just return
|
|
return True, vhost
|
|
|
|
redirect_addrs = redirect_addrs + ssl_a_vhttp
|
|
|
|
return False, redirect_addrs
|
|
|
|
def _general_vhost(self, ssl_vhost):
|
|
"""Find appropriate HTTP vhost for ssl_vhost.
|
|
|
|
Function needs to be thoroughly tested and perhaps improved
|
|
Will not do well with malformed configurations
|
|
Consider changing this into a dict check
|
|
|
|
:param ssl_vhost: ssl vhost to check
|
|
:type ssl_vhost: :class:`VH`
|
|
|
|
:returns: HTTP vhost or None if unsuccessful
|
|
:rtype: :class:`VH` or None
|
|
|
|
"""
|
|
# _default_:443 check
|
|
# Instead... should look for vhost of the form *:80
|
|
# Should we prompt the user?
|
|
ssl_addrs = ssl_vhost.addrs
|
|
if ssl_addrs == ["_default_:443"]:
|
|
ssl_addrs = ["*:443"]
|
|
|
|
for vhost in self.vhosts:
|
|
found = 0
|
|
# Not the same vhost, and same number of addresses
|
|
if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs):
|
|
# Find each address in ssl_host in test_host
|
|
for ssl_a in ssl_addrs:
|
|
ssl_tup = ssl_a.partition(":")
|
|
for test_a in vhost.addrs:
|
|
test_tup = test_a.partition(":")
|
|
if test_tup[0] == ssl_tup[0]:
|
|
# Check if found...
|
|
if (test_tup[2] == "80" or
|
|
test_tup[2] == "" or
|
|
test_tup[2] == "*"):
|
|
found += 1
|
|
break
|
|
# Check to make sure all addresses were found
|
|
# and names are equal
|
|
if (found == len(ssl_vhost.addrs) and
|
|
set(vhost.names) == set(ssl_vhost.names)):
|
|
return vhost
|
|
return None
|
|
|
|
# TODO - both of these
|
|
def enable_ocsp_stapling(self, ssl_vhost):
|
|
return False
|
|
|
|
def enable_hsts(self, ssl_vhost):
|
|
return False
|
|
|
|
def get_all_certs_keys(self):
|
|
""" Find all existing keys, certs from configuration.
|
|
|
|
Retrieve all certs and keys set in VirtualHosts on the Apache server
|
|
|
|
:returns: list of tuples with form [(cert, key, path)]
|
|
:rtype: list
|
|
|
|
"""
|
|
c_k = set()
|
|
|
|
for vhost in self.vhosts:
|
|
if vhost.ssl:
|
|
cert_path = self.find_directive(
|
|
case_i("SSLCertificateFile"), None, vhost.path)
|
|
key_path = self.find_directive(
|
|
case_i("SSLCertificateKeyFile"), None, vhost.path)
|
|
|
|
# Can be removed once find directive can return ordered results
|
|
if len(cert_path) != 1 or len(key_path) != 1:
|
|
logger.error(("Too many cert or key directives in vhost "
|
|
"%s" % vhost.filep))
|
|
sys.exit(40)
|
|
|
|
cert = os.path.abspath(self.aug.get(cert_path[0]))
|
|
key = os.path.abspath(self.aug.get(key_path[0]))
|
|
c_k.add((cert, key, get_file_path(cert_path[0])))
|
|
|
|
return c_k
|
|
|
|
def is_site_enabled(self, avail_fp):
|
|
"""Checks to see if the given site is enabled.
|
|
|
|
.. todo:: fix hardcoded sites-enabled
|
|
|
|
:param str avail_fp: Complete file path of available site
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
enabled_dir = os.path.join(self.server_root, "sites-enabled/")
|
|
for entry in os.listdir(enabled_dir):
|
|
if os.path.realpath(enabled_dir + entry) == avail_fp:
|
|
return True
|
|
|
|
return False
|
|
|
|
def enable_site(self, vhost):
|
|
"""Enables an available site, Apache restart required.
|
|
|
|
.. todo:: This function should number subdomains before the domain vhost
|
|
|
|
.. todo:: Make sure link is not broken...
|
|
|
|
:param vhost: vhost to enable
|
|
:type vhost: :class:`VH`
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
if self.is_site_enabled(vhost.filep):
|
|
return True
|
|
|
|
if "/sites-available/" in vhost.filep:
|
|
enabled_path = ("%ssites-enabled/%s" %
|
|
(self.server_root, os.path.basename(vhost.filep)))
|
|
self.register_file_creation(False, enabled_path)
|
|
os.symlink(vhost.filep, enabled_path)
|
|
vhost.enabled = True
|
|
logger.info("Enabling available site: %s" % vhost.filep)
|
|
self.save_notes += 'Enabled site %s\n' % vhost.filep
|
|
return True
|
|
return False
|
|
|
|
def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use
|
|
"""Method converts Apache's basic fnmatch to regular expression.
|
|
|
|
:param str clean_fn_match: Apache style filename match, similar to globs
|
|
|
|
:returns: regex suitable for augeas
|
|
:rtype: str
|
|
|
|
"""
|
|
regex = ""
|
|
for letter in clean_fn_match:
|
|
if letter == '.':
|
|
regex = regex + r"\."
|
|
elif letter == '*':
|
|
regex = regex + ".*"
|
|
# According to apache.org ? shouldn't appear
|
|
# but in case it is valid...
|
|
elif letter == '?':
|
|
regex = regex + "."
|
|
else:
|
|
regex = regex + letter
|
|
return regex
|
|
|
|
def _parse_file(self, file_path):
|
|
"""Parse file with Augeas
|
|
|
|
Checks to see if file_path is parsed by Augeas
|
|
If file_path isn't parsed, the file is added and Augeas is reloaded
|
|
|
|
:param str file_path: Apache config file path
|
|
|
|
"""
|
|
# Test if augeas included file for Httpd.lens
|
|
# Note: This works for augeas globs, ie. *.conf
|
|
inc_test = self.aug.match(
|
|
"/augeas/load/Httpd/incl [. ='%s']" % file_path)
|
|
if not inc_test:
|
|
# Load up files
|
|
# self.httpd_incl.append(file_path)
|
|
# self.aug.add_transform("Httpd.lns",
|
|
# self.httpd_incl, None, self.httpd_excl)
|
|
self._add_httpd_transform(file_path)
|
|
self.aug.load()
|
|
|
|
def standardize_excl(self):
|
|
"""Standardize the excl arguments for the Httpd lens in Augeas.
|
|
|
|
Note: Hack!
|
|
Standardize the excl arguments for the Httpd lens in Augeas
|
|
Servers sometimes give incorrect defaults
|
|
Note: This problem should be fixed in Augeas 1.0. Unfortunately,
|
|
Augeas 0.10 appears to be the most popular version currently.
|
|
|
|
"""
|
|
# attempt to protect against augeas error in 0.10.0 - ubuntu
|
|
# *.augsave -> /*.augsave upon augeas.load()
|
|
# Try to avoid bad httpd files
|
|
# There has to be a better way... but after a day and a half of testing
|
|
# I had no luck
|
|
# This is a hack... work around... submit to augeas if still not fixed
|
|
|
|
excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak",
|
|
"*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew",
|
|
"*~",
|
|
self.server_root + "*.augsave",
|
|
self.server_root + "*~",
|
|
self.server_root + "*/*augsave",
|
|
self.server_root + "*/*~",
|
|
self.server_root + "*/*/*.augsave",
|
|
self.server_root + "*/*/*~"]
|
|
|
|
for i in range(len(excl)):
|
|
self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i])
|
|
|
|
self.aug.load()
|
|
|
|
def restart(self, quiet=False): # pylint: disable=no-self-use
|
|
"""Restarts apache server.
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
return apache_restart()
|
|
|
|
def _add_httpd_transform(self, incl):
|
|
"""Add a transform to Augeas.
|
|
|
|
This function will correctly add a transform to augeas
|
|
The existing augeas.add_transform in python is broken.
|
|
|
|
:param str incl: TODO
|
|
|
|
"""
|
|
last_include = self.aug.match("/augeas/load/Httpd/incl [last()]")
|
|
self.aug.insert(last_include[0], "incl", False)
|
|
self.aug.set("/augeas/load/Httpd/incl[last()]", incl)
|
|
|
|
def config_test(self):
|
|
"""Check the configuration of Apache for errors.
|
|
|
|
:returns: Success
|
|
:rtype: bool
|
|
|
|
"""
|
|
try:
|
|
proc = subprocess.Popen(
|
|
['sudo', '/usr/sbin/apache2ctl', 'configtest'],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
text = proc.communicate()
|
|
except (OSError, ValueError):
|
|
logger.fatal("Unable to run /usr/sbin/apache2ctl configtest")
|
|
sys.exit(1)
|
|
|
|
if proc.returncode != 0:
|
|
# Enter recovery routine...
|
|
logger.error("Configtest failed")
|
|
logger.error(text[0])
|
|
logger.error(text[1])
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_version(self): # pylint: disable=no-self-use
|
|
"""Return version of Apache Server.
|
|
|
|
Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7))
|
|
|
|
:returns: version
|
|
:rtype: tuple
|
|
|
|
:raises errors.LetsEncryptConfiguratorError:
|
|
Unable to find Apache version
|
|
|
|
"""
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[CONFIG.APACHE_CTL, '-v'],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
text = proc.communicate()[0]
|
|
except (OSError, ValueError):
|
|
raise errors.LetsEncryptConfiguratorError(
|
|
"Unable to run %s -v" % CONFIG.APACHE_CTL)
|
|
|
|
regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE)
|
|
matches = regex.findall(text)
|
|
|
|
if len(matches) != 1:
|
|
raise errors.LetsEncryptConfiguratorError(
|
|
"Unable to find Apache version")
|
|
|
|
return tuple([int(i) for i in matches[0].split('.')])
|
|
|
|
def verify_setup(self):
|
|
"""Verify the setup to ensure safe operating environment.
|
|
|
|
Make sure that files/directories are setup with appropriate permissions
|
|
Aim for defensive coding... make sure all input files
|
|
have permissions of root
|
|
|
|
"""
|
|
uid = os.geteuid()
|
|
le_util.make_or_verify_dir(self.direc["config"], 0o755, uid)
|
|
le_util.make_or_verify_dir(self.direc["work"], 0o755, uid)
|
|
le_util.make_or_verify_dir(self.direc["backup"], 0o755, uid)
|
|
|
|
###########################################################################
|
|
# Challenges Section
|
|
###########################################################################
|
|
|
|
# TODO: Change list_sni_tuple to namedtuple. Also include key within tuple.
|
|
# This allows the keys to be different for each SNI challenge
|
|
|
|
def perform(self, chall_dict):
|
|
"""Perform the configuration related challenge.
|
|
|
|
:param dict chall_dict: Dictionary representing a challenge.
|
|
|
|
"""
|
|
|
|
if chall_dict.get("type", "") == 'dvsni':
|
|
return self.dvsni_perform(chall_dict)
|
|
return None
|
|
|
|
def dvsni_perform(self, chall_dict):
|
|
"""Peform a DVSNI challenge.
|
|
|
|
`chall_dict` composed of:
|
|
|
|
list_sni_tuple:
|
|
List of tuples with form `(addr, r, nonce)`, where
|
|
`addr` (`str`), `r` (base64 `str`), `nonce` (hex `str`)
|
|
|
|
dvsni_key:
|
|
DVSNI key (:class:`letsencrypt.client.client.Client.Key`)
|
|
|
|
:param dict chall_dict: dvsni challenge - see documentation
|
|
|
|
"""
|
|
# Save any changes to the configuration as a precaution
|
|
# About to make temporary changes to the config
|
|
self.save()
|
|
|
|
# Do weak validation that challenge is of expected type
|
|
if not ("list_sni_tuple" in chall_dict and "dvsni_key" in chall_dict):
|
|
logger.fatal("Incorrect parameter given to Apache DVSNI challenge")
|
|
logger.fatal("Chall dict: " + str(chall_dict))
|
|
sys.exit(1)
|
|
|
|
addresses = []
|
|
default_addr = "*:443"
|
|
for tup in chall_dict["list_sni_tuple"]:
|
|
vhost = self.choose_virtual_host(tup[0])
|
|
if vhost is None:
|
|
logger.error(("No vhost exists with servername "
|
|
"or alias of: %s" % tup[0]))
|
|
logger.error("No _default_:443 vhost exists")
|
|
logger.error("Please specify servernames in the Apache config")
|
|
return None
|
|
|
|
# TODO - @jdkasten review this code to make sure it makes sense
|
|
self.make_server_sni_ready(vhost, default_addr)
|
|
|
|
for addr in vhost.addrs:
|
|
if "_default_" in addr:
|
|
addresses.append([default_addr])
|
|
break
|
|
else:
|
|
addresses.append(vhost.addrs)
|
|
|
|
# Generate S
|
|
dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE)
|
|
# Create all of the challenge certs
|
|
for tup in chall_dict["list_sni_tuple"]:
|
|
# Need to decode from base64
|
|
dvsni_r = le_util.jose_b64decode(tup[1])
|
|
ext = dvsni_gen_ext(dvsni_r, dvsni_s)
|
|
self.dvsni_create_chall_cert(
|
|
tup[0], ext, tup[2], chall_dict["dvsni_key"])
|
|
|
|
self.dvsni_mod_config(chall_dict["list_sni_tuple"],
|
|
chall_dict["dvsni_key"],
|
|
addresses)
|
|
# Save reversible changes and restart the server
|
|
self.save("SNI Challenge", True)
|
|
self.restart(True)
|
|
|
|
return {"type": "dvsni", "s": le_util.jose_b64encode(dvsni_s)}
|
|
|
|
def cleanup(self):
|
|
"""Revert all challenges."""
|
|
|
|
self.revert_challenge_config()
|
|
self.restart(True)
|
|
|
|
# TODO: Variable names
|
|
def dvsni_mod_config(self, list_sni_tuple, dvsni_key,
|
|
ll_addrs):
|
|
"""Modifies Apache config files to include challenge vhosts.
|
|
|
|
Result: Apache config includes virtual servers for issued challs
|
|
|
|
:param list list_sni_tuple: list of tuples with the form
|
|
`(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`,
|
|
and nonce is hex `str`
|
|
|
|
:param dvsni_key: DVSNI key
|
|
:type dvsni_key: :class:`letsencrypt.client.client.Client.Key`
|
|
|
|
:param list ll_addrs: list of list of addresses to apply
|
|
|
|
"""
|
|
# WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY
|
|
# THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER
|
|
# AND TAKEN OUT BEFORE RELEASE, INSTEAD
|
|
# SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM
|
|
|
|
# Check to make sure options-ssl.conf is installed
|
|
# pylint: disable=no-member
|
|
if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF):
|
|
dist_conf = pkg_resources.resource_filename(
|
|
__name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF))
|
|
shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF)
|
|
|
|
# TODO: Use ip address of existing vhost instead of relying on FQDN
|
|
config_text = "<IfModule mod_ssl.c> \n"
|
|
for idx, lis in enumerate(ll_addrs):
|
|
config_text += self.get_config_text(
|
|
list_sni_tuple[idx][2], lis, dvsni_key.file)
|
|
config_text += "</IfModule> \n"
|
|
|
|
self.dvsni_conf_include_check(self.location["default"])
|
|
self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF)
|
|
|
|
with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf:
|
|
new_conf.write(config_text)
|
|
|
|
def dvsni_conf_include_check(self, main_config):
|
|
"""Adds DVSNI challenge conf file into configuration.
|
|
|
|
Adds DVSNI challenge include file if it does not already exist
|
|
within mainConfig
|
|
|
|
:param str main_config: file path to main user apache config file
|
|
|
|
"""
|
|
if len(self.find_directive(
|
|
case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0:
|
|
# print "Including challenge virtual host(s)"
|
|
self.add_dir(get_aug_path(main_config),
|
|
"Include", CONFIG.APACHE_CHALLENGE_CONF)
|
|
|
|
def dvsni_create_chall_cert(self, name, ext, nonce, dvsni_key):
|
|
"""Creates DVSNI challenge certifiate.
|
|
|
|
Certificate created at self.dvsni_get_cert_file(nonce)
|
|
|
|
:param str nonce: hex form of nonce
|
|
|
|
:param dvsni_key: absolute path to key file
|
|
:type dvsni_key: `client.Client.Key`
|
|
|
|
"""
|
|
self.register_file_creation(True, self.dvsni_get_cert_file(nonce))
|
|
|
|
cert_pem = crypto_util.make_ss_cert(
|
|
dvsni_key.pem, [nonce + CONFIG.INVALID_EXT, name, ext])
|
|
|
|
with open(self.dvsni_get_cert_file(nonce), 'w') as chall_cert_file:
|
|
chall_cert_file.write(cert_pem)
|
|
|
|
def get_config_text(self, nonce, ip_addrs, dvsni_key_file):
|
|
"""Chocolate virtual server configuration text
|
|
|
|
:param str nonce: hex form of nonce
|
|
:param str ip_addrs: addresses of challenged domain
|
|
:param str dvsni_key_file: Path to key file
|
|
|
|
:returns: virtual host configuration text
|
|
:rtype: str
|
|
|
|
"""
|
|
return ("<VirtualHost " + " ".join(ip_addrs) + "> \n"
|
|
"ServerName " + nonce + CONFIG.INVALID_EXT + " \n"
|
|
"UseCanonicalName on \n"
|
|
"SSLStrictSNIVHostCheck on \n"
|
|
"\n"
|
|
"LimitRequestBody 1048576 \n"
|
|
"\n"
|
|
"Include " + self.location["ssl_options"] + " \n"
|
|
"SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + " \n"
|
|
"SSLCertificateKeyFile " + dvsni_key_file + " \n"
|
|
"\n"
|
|
"DocumentRoot " + self.direc["config"] + "challenge_page/ \n"
|
|
"</VirtualHost> \n\n")
|
|
|
|
def dvsni_get_cert_file(self, nonce):
|
|
"""Returns standardized name for challenge certificate.
|
|
|
|
:param str nonce: hex form of nonce
|
|
|
|
:returns: certificate file name
|
|
:rtype: str
|
|
|
|
"""
|
|
return self.direc["work"] + nonce + ".crt"
|
|
|
|
|
|
def enable_mod(mod_name):
|
|
"""Enables module in Apache.
|
|
|
|
Both enables and restarts Apache so module is active.
|
|
|
|
:param str mod_name: Name of the module to enable
|
|
|
|
"""
|
|
try:
|
|
# Use check_output so the command will finish before reloading
|
|
subprocess.check_call(["sudo", "a2enmod", 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'))
|
|
except (OSError, subprocess.CalledProcessError) as err:
|
|
logger.error("Error enabling mod_" + mod_name)
|
|
logger.error("Exception: %s" % str(err))
|
|
sys.exit(1)
|
|
|
|
|
|
def check_ssl_loaded():
|
|
"""Checks to see if mod_ssl is loaded
|
|
|
|
Currently uses apache2ctl to get loaded module list
|
|
|
|
.. todo:: This function is likely fragile to versions/distros
|
|
|
|
:returns: If ssl_module is included and active in Apache
|
|
:rtype: bool
|
|
|
|
"""
|
|
try:
|
|
# p=subprocess.check_output(['sudo', '/usr/sbin/apache2ctl', '-M'],
|
|
# stderr=open("/dev/null", 'w'))
|
|
proc = subprocess.Popen([CONFIG.APACHE_CTL, '-M'],
|
|
stdout=subprocess.PIPE,
|
|
stderr=open(
|
|
"/dev/null", 'w')).communicate()[0]
|
|
except (OSError, ValueError):
|
|
logger.error(
|
|
"Error accessing %s for loaded modules!" % CONFIG.APACHE_CTL)
|
|
logger.error("This may be caused by an Apache Configuration Error")
|
|
return False
|
|
|
|
if "ssl_module" in proc:
|
|
return True
|
|
return False
|
|
|
|
|
|
def apache_restart():
|
|
"""Restarts the Apache Server.
|
|
|
|
.. todo:: Try to use reload instead. (This caused timing problems before)
|
|
.. todo:: This should be written to use the process return code.
|
|
|
|
"""
|
|
try:
|
|
proc = subprocess.Popen([CONFIG.APACHE2, 'restart'],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE)
|
|
text = proc.communicate()
|
|
|
|
if proc.returncode != 0:
|
|
# Enter recovery routine...
|
|
logger.error("Configtest failed")
|
|
logger.error(text[0])
|
|
logger.error(text[1])
|
|
return False
|
|
|
|
except (OSError, ValueError):
|
|
logger.fatal(("Apache Restart Failed - "
|
|
"Please Check the Configuration"))
|
|
sys.exit(1)
|
|
|
|
return True
|
|
|
|
|
|
def case_i(string):
|
|
"""Returns case insensitive regex.
|
|
|
|
Returns a sloppy, but necessary version of a case insensitive regex.
|
|
Any string should be able to be submitted and the string is
|
|
escaped and then made case insensitive.
|
|
May be replaced by a more proper /i once augeas 1.0 is widely
|
|
supported.
|
|
|
|
:param str string: string to make case i regex
|
|
|
|
"""
|
|
return "".join(["["+c.upper()+c.lower()+"]"
|
|
if c.isalpha() else c for c in re.escape(string)])
|
|
|
|
|
|
def get_file_path(vhost_path):
|
|
"""Get file path from augeas_vhost_path.
|
|
|
|
Takes in Augeas path and returns the file name
|
|
|
|
:param str vhost_path: Augeas virtual host path
|
|
|
|
:returns: filename of vhost
|
|
:rtype: str
|
|
|
|
"""
|
|
# Strip off /files
|
|
avail_fp = vhost_path[6:]
|
|
# This can be optimized...
|
|
while True:
|
|
# Cast both to lowercase to be case insensitive
|
|
find_if = avail_fp.lower().find("/ifmodule")
|
|
if find_if != -1:
|
|
avail_fp = avail_fp[:find_if]
|
|
continue
|
|
find_vh = avail_fp.lower().find("/virtualhost")
|
|
if find_vh != -1:
|
|
avail_fp = avail_fp[:find_vh]
|
|
continue
|
|
break
|
|
return avail_fp
|
|
|
|
|
|
def get_aug_path(file_path):
|
|
"""Return augeas path for full filepath.
|
|
|
|
:param str file_path: Full filepath
|
|
|
|
"""
|
|
return "/files%s" % file_path
|
|
|
|
|
|
def strip_dir(path):
|
|
"""Returns directory of file path.
|
|
|
|
.. todo:: Replace this with Python standard function
|
|
|
|
:param str path: path is a file path. not an augeas section or
|
|
directive path
|
|
|
|
:returns: directory
|
|
:rtype: str
|
|
|
|
"""
|
|
index = path.rfind("/")
|
|
if index > 0:
|
|
return path[:index+1]
|
|
# No directory
|
|
return ""
|
|
|
|
|
|
def dvsni_gen_ext(dvsni_r, dvsni_s):
|
|
"""Generates z extension to be placed in certificate extension.
|
|
|
|
:param bytearray dvsni_r: DVSNI r value
|
|
:param bytearray dvsni_s: DVSNI s value
|
|
|
|
:returns: z + CONFIG.INVALID_EXT
|
|
:rtype: str
|
|
|
|
"""
|
|
z_base = hashlib.new('sha256')
|
|
z_base.update(dvsni_r)
|
|
z_base.update(dvsni_s)
|
|
|
|
return z_base.hexdigest() + CONFIG.INVALID_EXT
|
|
|
|
|
|
def main():
|
|
"""Main function used for quick testing purposes"""
|
|
|
|
config = ApacheConfigurator()
|
|
logger.setLogger(logger.FileLogger(sys.stdout))
|
|
logger.setLogLevel(logger.DEBUG)
|
|
|
|
# for v in config.vhosts:
|
|
# print v.filep
|
|
# print v.addrs
|
|
# for name in v.names:
|
|
# print name
|
|
|
|
print config.find_directive(
|
|
case_i("NameVirtualHost"), case_i("holla:443"))
|
|
|
|
# for m in config.find_directive("Listen", "443"):
|
|
# print "Directive Path:", m, "Value:", config.aug.get(m)
|
|
|
|
# for v in config.vhosts:
|
|
# for a in v.addrs:
|
|
# print "Address:",a, "- Is name vhost?", config.is_name_vhost(a)
|
|
|
|
# print config.get_all_names()
|
|
|
|
# test_file = "/home/james/Desktop/ports_test.conf"
|
|
# config._parse_file(test_file)
|
|
|
|
# config.aug.insert("/files"+test_file+"/IfModule[1]/arg","directive",False)
|
|
# config.aug.set("/files"+test_file+"/IfModule[1]/directive[1]", "Listen")
|
|
# config.aug.set(
|
|
# "/files" +test_file+ "/IfModule[1]/directive[1]/arg", "556")
|
|
|
|
# #config.save_notes = "Added listen 431 for test"
|
|
# #config.register_file_creation("/home/james/Desktop/new_file.txt")
|
|
# #config.save("Testing Saves", False)
|
|
# #config.recover_checkpoint(1)
|
|
|
|
# # config.display_checkpoints()
|
|
config.config_test()
|
|
|
|
# # Testing redirection and make_vhost_ssl
|
|
# ssl_vh = None
|
|
# for vh in config.vhosts:
|
|
# if not vh.addrs:
|
|
# print vh.names
|
|
# print vh.filep
|
|
# if vh.addrs[0] == "23.20.47.131:80":
|
|
# print "Here we go"
|
|
# ssl_vh = config.make_vhost_ssl(vh)
|
|
|
|
# config.enable_redirect(ssl_vh)
|
|
|
|
# for vh in config.vhosts:
|
|
# if len(vh.names) > 0:
|
|
# config.deploy_cert(
|
|
# vh,
|
|
# "/home/james/Documents/apache_choc/req.pem",
|
|
# "/home/james/Documents/apache_choc/key.pem",
|
|
# "/home/james/Downloads/sub.class1.server.ca.pem")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|