diff options
Diffstat (limited to 'certbot-apache/certbot_apache/_internal')
19 files changed, 5221 insertions, 0 deletions
diff --git a/certbot-apache/certbot_apache/_internal/__init__.py b/certbot-apache/certbot_apache/_internal/__init__.py new file mode 100644 index 000000000..9c195ccc7 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/__init__.py @@ -0,0 +1 @@ +"""Certbot Apache plugin.""" diff --git a/certbot-apache/certbot_apache/_internal/apache_util.py b/certbot-apache/certbot_apache/_internal/apache_util.py new file mode 100644 index 000000000..7a2ecf49b --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/apache_util.py @@ -0,0 +1,107 @@ +""" Utility functions for certbot-apache plugin """ +import binascii + +from certbot import util +from certbot.compat import os + + +def get_mod_deps(mod_name): + """Get known module dependencies. + + .. note:: This does not need to be accurate in order for the client to + run. This simply keeps things clean if the user decides to revert + changes. + .. warning:: If all deps are not included, it may cause incorrect parsing + behavior, due to enable_mod's shortcut for updating the parser's + currently defined modules (`.ApacheParser.add_mod`) + This would only present a major problem in extremely atypical + configs that use ifmod for the missing deps. + + """ + deps = { + "ssl": ["setenvif", "mime"] + } + return deps.get(mod_name, []) + + +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 + + """ + if not vhost_path or not vhost_path.startswith("/files/"): + return None + + return _split_aug_path(vhost_path)[0] + + +def get_internal_aug_path(vhost_path): + """Get the Augeas path for a vhost with the file path removed. + + :param str vhost_path: Augeas virtual host path + + :returns: Augeas path to vhost relative to the containing file + :rtype: str + + """ + return _split_aug_path(vhost_path)[1] + + +def _split_aug_path(vhost_path): + """Splits an Augeas path into a file path and an internal path. + + After removing "/files", this function splits vhost_path into the + file path and the remaining Augeas path. + + :param str vhost_path: Augeas virtual host path + + :returns: file path and internal Augeas path + :rtype: `tuple` of `str` + + """ + # Strip off /files + file_path = vhost_path[6:] + internal_path = [] + + # Remove components from the end of file_path until it becomes valid + while not os.path.exists(file_path): + file_path, _, internal_path_part = file_path.rpartition("/") + internal_path.append(internal_path_part) + + return file_path, "/".join(reversed(internal_path)) + + +def parse_define_file(filepath, varname): + """ Parses Defines from a variable in configuration file + + :param str filepath: Path of file to parse + :param str varname: Name of the variable + + :returns: Dict of Define:Value pairs + :rtype: `dict` + + """ + return_vars = {} + # Get list of words in the variable + a_opts = util.get_var_from_file(varname, filepath).split() + for i, v in enumerate(a_opts): + # Handle Define statements and make sure it has an argument + if v == "-D" and len(a_opts) >= i+2: + var_parts = a_opts[i+1].partition("=") + return_vars[var_parts[0]] = var_parts[2] + elif len(v) > 2 and v.startswith("-D"): + # Found var with no whitespace separator + var_parts = v[2:].partition("=") + return_vars[var_parts[0]] = var_parts[2] + return return_vars + + +def unique_id(): + """ Returns an unique id to be used as a VirtualHost identifier""" + return binascii.hexlify(os.urandom(16)).decode("utf-8") diff --git a/certbot-apache/certbot_apache/_internal/augeas_lens/README b/certbot-apache/certbot_apache/_internal/augeas_lens/README new file mode 100644 index 000000000..bf9161f93 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/augeas_lens/README @@ -0,0 +1,2 @@ +Certbot includes the very latest Augeas lenses in order to ship bug fixes +to Apache configuration handling bugs as quickly as possible diff --git a/certbot-apache/certbot_apache/_internal/augeas_lens/httpd.aug b/certbot-apache/certbot_apache/_internal/augeas_lens/httpd.aug new file mode 100644 index 000000000..5600088cf --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/augeas_lens/httpd.aug @@ -0,0 +1,206 @@ +(* Apache HTTPD lens for Augeas + +Authors: + David Lutterkort <lutter@redhat.com> + Francis Giraldeau <francis.giraldeau@usherbrooke.ca> + Raphael Pinson <raphink@gmail.com> + +About: Reference + Online Apache configuration manual: http://httpd.apache.org/docs/trunk/ + +About: License + This file is licensed under the LGPL v2+. + +About: Lens Usage + Sample usage of this lens in augtool + + Apache configuration is represented by two main structures, nested sections + and directives. Sections are used as labels, while directives are kept as a + value. Sections and directives can have positional arguments inside values + of "arg" nodes. Arguments of sections must be the firsts child of the + section node. + + This lens doesn't support automatic string quoting. Hence, the string must + be quoted when containing a space. + + Create a new VirtualHost section with one directive: + > clear /files/etc/apache2/sites-available/foo/VirtualHost + > set /files/etc/apache2/sites-available/foo/VirtualHost/arg "172.16.0.1:80" + > set /files/etc/apache2/sites-available/foo/VirtualHost/directive "ServerAdmin" + > set /files/etc/apache2/sites-available/foo/VirtualHost/*[self::directive="ServerAdmin"]/arg "admin@example.com" + +About: Configuration files + This lens applies to files in /etc/httpd and /etc/apache2. See <filter>. + +*) + + +module Httpd = + +autoload xfm + +(****************************************************************** + * Utilities lens + *****************************************************************) +let dels (s:string) = del s s + +(* The continuation sequence that indicates that we should consider the + * next line part of the current line *) +let cont = /\\\\\r?\n/ + +(* Whitespace within a line: space, tab, and the continuation sequence *) +let ws = /[ \t]/ | cont + +(* Any possible character - '.' does not match \n *) +let any = /(.|\n)/ + +(* Any character preceded by a backslash *) +let esc_any = /\\\\(.|\n)/ + +(* Newline sequence - both for Unix and DOS newlines *) +let nl = /\r?\n/ + +(* Whitespace at the end of a line *) +let eol = del (ws* . nl) "\n" + +(* deal with continuation lines *) +let sep_spc = del ws+ " " +let sep_osp = del ws* "" +let sep_eq = del (ws* . "=" . ws*) "=" + +let nmtoken = /[a-zA-Z:_][a-zA-Z0-9:_.-]*/ +let word = /[a-z][a-z0-9._-]*/i + +(* A complete line that is either just whitespace or a comment that only + * contains whitespace *) +let empty = [ del (ws* . /#?/ . ws* . nl) "\n" ] + +let indent = Util.indent + +(* A comment that is not just whitespace. We define it in terms of the + * things that are not allowed as part of such a comment: + * 1) Starts with whitespace + * 2) Ends with whitespace, a backslash or \r + * 3) Unescaped newlines + *) +let comment = + let comment_start = del (ws* . "#" . ws* ) "# " in + let unesc_eol = /[^\]?/ . nl in + let w = /[^\t\n\r \\]/ in + let r = /[\r\\]/ in + let s = /[\t\r ]/ in + (* + * we'd like to write + * let b = /\\\\/ in + * let t = /[\t\n\r ]/ in + * let x = b . (t? . (s|w)* ) in + * but the definition of b depends on commit 244c0edd in 1.9.0 and + * would make the lens unusable with versions before 1.9.0. So we write + * x out which works in older versions, too + *) + let x = /\\\\[\t\n\r ]?[^\n\\]*/ in + let line = ((r . s* . w|w|r) . (s|w)* . x*|(r.s* )?).w.(s*.w)* in + [ label "#comment" . comment_start . store line . eol ] + +(* borrowed from shellvars.aug *) +let char_arg_sec = /([^\\ '"\t\r\n>]|[^ '"\t\r\n>]+[^\\ \t\r\n>])|\\\\"|\\\\'|\\\\ / +let char_arg_wl = /([^\\ '"},\t\r\n]|[^ '"},\t\r\n]+[^\\ '"},\t\r\n])/ + +let dquot = + let no_dquot = /[^"\\\r\n]/ + in /"/ . (no_dquot|esc_any)* . /"/ +let dquot_msg = + let no_dquot = /([^ \t"\\\r\n]|[^"\\\r\n]+[^ \t"\\\r\n])/ + in /"/ . (no_dquot|esc_any)* . no_dquot + +let squot = + let no_squot = /[^'\\\r\n]/ + in /'/ . (no_squot|esc_any)* . /'/ +let comp = /[<>=]?=/ + +(****************************************************************** + * Attributes + *****************************************************************) + +(* The arguments for a directive come in two flavors: quoted with single or + * double quotes, or bare. Bare arguments may not start with a single or + * double quote; since we also treat "word lists" special, i.e. lists + * enclosed in curly braces, bare arguments may not start with those, + * either. + * + * Bare arguments may not contain unescaped spaces, but we allow escaping + * with '\\'. Quoted arguments can contain anything, though the quote must + * be escaped with '\\'. + *) +let bare = /([^{"' \t\n\r]|\\\\.)([^ \t\n\r]|\\\\.)*[^ \t\n\r\\]|[^{"' \t\n\r\\]/ + +let arg_quoted = [ label "arg" . store (dquot|squot) ] +let arg_bare = [ label "arg" . store bare ] + +(* message argument starts with " but ends at EOL *) +let arg_dir_msg = [ label "arg" . store dquot_msg ] +let arg_wl = [ label "arg" . store (char_arg_wl+|dquot|squot) ] + +(* comma-separated wordlist as permitted in the SSLRequire directive *) +let arg_wordlist = + let wl_start = dels "{" in + let wl_end = dels "}" in + let wl_sep = del /[ \t]*,[ \t]*/ ", " + in [ label "wordlist" . wl_start . arg_wl . (wl_sep . arg_wl)* . wl_end ] + +let argv (l:lens) = l . (sep_spc . l)* + +(* the arguments of a directive. We use this once we have parsed the name + * of the directive, and the space right after it. When dir_args is used, + * we also know that we have at least one argument. We need to be careful + * with the spacing between arguments: quoted arguments and word lists do + * not need to have space between them, but bare arguments do. + * + * Apache apparently is also happy if the last argument starts with a double + * quote, but has no corresponding closing duoble quote, which is what + * arg_dir_msg handles + *) +let dir_args = + let arg_nospc = arg_quoted|arg_wordlist in + (arg_bare . sep_spc | arg_nospc . sep_osp)* . (arg_bare|arg_nospc|arg_dir_msg) + +let directive = + [ indent . label "directive" . store word . (sep_spc . dir_args)? . eol ] + +let arg_sec = [ label "arg" . store (char_arg_sec+|comp|dquot|squot) ] + +let section (body:lens) = + (* opt_eol includes empty lines *) + let opt_eol = del /([ \t]*#?[ \t]*\r?\n)*/ "\n" in + let inner = (sep_spc . argv arg_sec)? . sep_osp . + dels ">" . opt_eol . ((body|comment) . (body|empty|comment)*)? . + indent . dels "</" in + let kword = key (word - /perl/i) in + let dword = del (word - /perl/i) "a" in + [ indent . dels "<" . square kword inner dword . del />[ \t\n\r]*/ ">\n" ] + +let perl_section = [ indent . label "Perl" . del /<perl>/i "<Perl>" + . store /[^<]*/ + . del /<\/perl>/i "</Perl>" . eol ] + + +let rec content = section (content|directive) + | perl_section + +let lns = (content|directive|comment|empty)* + +let filter = (incl "/etc/apache2/apache2.conf") . + (incl "/etc/apache2/httpd.conf") . + (incl "/etc/apache2/ports.conf") . + (incl "/etc/apache2/conf.d/*") . + (incl "/etc/apache2/conf-available/*.conf") . + (incl "/etc/apache2/mods-available/*") . + (incl "/etc/apache2/sites-available/*") . + (incl "/etc/apache2/vhosts.d/*.conf") . + (incl "/etc/httpd/conf.d/*.conf") . + (incl "/etc/httpd/httpd.conf") . + (incl "/etc/httpd/conf/httpd.conf") . + (incl "/etc/httpd/conf.modules.d/*.conf") . + Util.stdexcl + +let xfm = transform lns filter diff --git a/certbot-apache/certbot_apache/_internal/configurator.py b/certbot-apache/certbot_apache/_internal/configurator.py new file mode 100644 index 000000000..84b59d2c7 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/configurator.py @@ -0,0 +1,2512 @@ +"""Apache Configurator.""" +# pylint: disable=too-many-lines +from collections import defaultdict +import copy +import fnmatch +import logging +import re +import socket +import time + +import pkg_resources +import six +import zope.component +import zope.interface + +from acme import challenges +from acme.magic_typing import DefaultDict # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Dict # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Union # pylint: disable=unused-import, no-name-in-module +from certbot import errors +from certbot import interfaces +from certbot import util +from certbot.achallenges import KeyAuthorizationAnnotatedChallenge # pylint: disable=unused-import +from certbot.compat import filesystem +from certbot.compat import os +from certbot.plugins import common +from certbot.plugins.enhancements import AutoHSTSEnhancement +from certbot.plugins.util import path_surgery +from certbot_apache._internal import apache_util +from certbot_apache._internal import constants +from certbot_apache._internal import display_ops +from certbot_apache._internal import http_01 +from certbot_apache._internal import obj +from certbot_apache._internal import parser + +logger = logging.getLogger(__name__) + + +# 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 LE's 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() + + +# TODO: Verify permissions on configuration root... it is easier than +# checking permissions on each of the relative directories and less error +# prone. +# TODO: Write a server protocol finder. Listen <port> <protocol> or +# Protocol <protocol>. This can verify partial setups are correct +# TODO: Add directives to sites-enabled... not sites-available. +# sites-available doesn't allow immediate find_dir search even with save() +# and load() + +@zope.interface.implementer(interfaces.IAuthenticator, interfaces.IInstaller) +@zope.interface.provider(interfaces.IPluginFactory) +class ApacheConfigurator(common.Installer): + """Apache configurator. + + :ivar config: Configuration. + :type config: :class:`~certbot.interfaces.IConfig` + + :ivar parser: Handles low level parsing + :type parser: :class:`~certbot_apache._internal.parser` + + :ivar tup version: version of Apache + :ivar list vhosts: All vhosts found in the configuration + (:class:`list` of :class:`~certbot_apache._internal.obj.VirtualHost`) + + :ivar dict assoc: Mapping between domains and vhosts + + """ + + description = "Apache Web Server plugin" + if os.environ.get("CERTBOT_DOCS") == "1": + description += ( # pragma: no cover + " (Please note that the default values of the Apache plugin options" + " change depending on the operating system Certbot is run on.)" + ) + + OS_DEFAULTS = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/sites-available", + vhost_files="*", + logs_root="/var/log/apache2", + ctl="apache2ctl", + version_cmd=['apache2ctl', '-v'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/apache2", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) + + def option(self, key): + """Get a value from options""" + return self.options.get(key) + + def _prepare_options(self): + """ + Set the values possibly changed by command line parameters to + OS_DEFAULTS constant dictionary + """ + opts = ["enmod", "dismod", "le_vhost_ext", "server_root", "vhost_root", + "logs_root", "challenge_location", "handle_modules", "handle_sites", + "ctl"] + for o in opts: + # Config options use dashes instead of underscores + if self.conf(o.replace("_", "-")) is not None: + self.options[o] = self.conf(o.replace("_", "-")) + else: + self.options[o] = self.OS_DEFAULTS[o] + + # Special cases + self.options["version_cmd"][0] = self.option("ctl") + self.options["restart_cmd"][0] = self.option("ctl") + self.options["conftest_cmd"][0] = self.option("ctl") + + @classmethod + def add_parser_arguments(cls, add): + # When adding, modifying or deleting command line arguments, be sure to + # include the changes in the list used in method _prepare_options() to + # ensure consistent behavior. + + # Respect CERTBOT_DOCS environment variable and use default values from + # base class regardless of the underlying distribution (overrides). + if os.environ.get("CERTBOT_DOCS") == "1": + DEFAULTS = ApacheConfigurator.OS_DEFAULTS + else: + # cls.OS_DEFAULTS can be distribution specific, see override classes + DEFAULTS = cls.OS_DEFAULTS + add("enmod", default=DEFAULTS["enmod"], + help="Path to the Apache 'a2enmod' binary") + add("dismod", default=DEFAULTS["dismod"], + help="Path to the Apache 'a2dismod' binary") + add("le-vhost-ext", default=DEFAULTS["le_vhost_ext"], + help="SSL vhost configuration extension") + add("server-root", default=DEFAULTS["server_root"], + help="Apache server root directory") + add("vhost-root", default=None, + help="Apache server VirtualHost configuration root") + add("logs-root", default=DEFAULTS["logs_root"], + help="Apache server logs directory") + add("challenge-location", + default=DEFAULTS["challenge_location"], + help="Directory path for challenge configuration") + add("handle-modules", default=DEFAULTS["handle_modules"], + help="Let installer handle enabling required modules for you " + + "(Only Ubuntu/Debian currently)") + add("handle-sites", default=DEFAULTS["handle_sites"], + help="Let installer handle enabling sites for you " + + "(Only Ubuntu/Debian currently)") + add("ctl", default=DEFAULTS["ctl"], + help="Full path to Apache control script") + + def __init__(self, *args, **kwargs): + """Initialize an Apache Configurator. + + :param tup version: version of Apache as a tuple (2, 4, 7) + (used mostly for unittesting) + + """ + version = kwargs.pop("version", None) + super(ApacheConfigurator, self).__init__(*args, **kwargs) + + # Add name_server association dict + self.assoc = dict() # type: Dict[str, obj.VirtualHost] + # Outstanding challenges + self._chall_out = set() # type: Set[KeyAuthorizationAnnotatedChallenge] + # List of vhosts configured per wildcard domain on this run. + # used by deploy_cert() and enhance() + self._wildcard_vhosts = dict() # type: Dict[str, List[obj.VirtualHost]] + # Maps enhancements to vhosts we've enabled the enhancement for + self._enhanced_vhosts = defaultdict(set) # type: DefaultDict[str, Set[obj.VirtualHost]] + # Temporary state for AutoHSTS enhancement + self._autohsts = {} # type: Dict[str, Dict[str, Union[int, float]]] + # Reverter save notes + self.save_notes = "" + + # These will be set in the prepare function + self._prepared = False + self.parser = None + self.version = version + self.vhosts = None + self.options = copy.deepcopy(self.OS_DEFAULTS) + self._enhance_func = {"redirect": self._enable_redirect, + "ensure-http-header": self._set_http_header, + "staple-ocsp": self._enable_ocsp_stapling} + + @property + def mod_ssl_conf(self): + """Full absolute path to SSL configuration file.""" + return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST) + + @property + def updated_mod_ssl_conf_digest(self): + """Full absolute path to digest of updated SSL configuration file.""" + return os.path.join(self.config.config_dir, constants.UPDATED_MOD_SSL_CONF_DIGEST) + + def prepare(self): + """Prepare the authenticator/installer. + + :raises .errors.NoInstallationError: If Apache configs cannot be found + :raises .errors.MisconfigurationError: If Apache is misconfigured + :raises .errors.NotSupportedError: If Apache version is not supported + :raises .errors.PluginError: If there is any other error + + """ + self._prepare_options() + + # Verify Apache is installed + self._verify_exe_availability(self.option("ctl")) + + # Make sure configuration is valid + self.config_test() + + # Set Version + if self.version is None: + self.version = self.get_version() + logger.debug('Apache version is %s', + '.'.join(str(i) for i in self.version)) + if self.version < (2, 2): + raise errors.NotSupportedError( + "Apache Version {0} not supported.".format(str(self.version))) + + # Recover from previous crash before Augeas initialization to have the + # correct parse tree from the get go. + self.recovery_routine() + # Perform the actual Augeas initialization to be able to react + self.parser = self.get_parser() + + # Check for errors in parsing files with Augeas + self.parser.check_parsing_errors("httpd.aug") + + # Get all of the available vhosts + self.vhosts = self.get_virtual_hosts() + + self.install_ssl_options_conf(self.mod_ssl_conf, + self.updated_mod_ssl_conf_digest) + + # Prevent two Apache plugins from modifying a config at once + try: + util.lock_dir_until_exit(self.option("server_root")) + except (OSError, errors.LockError): + logger.debug("Encountered error:", exc_info=True) + raise errors.PluginError( + "Unable to create a lock file in {0}. Are you running" + " Certbot with sufficient privileges to modify your" + " Apache configuration?".format(self.option("server_root"))) + self._prepared = True + + def save(self, title=None, temporary=False): + """Saves all changes to the configuration files. + + This function first checks for save errors, if none are found, + all configuration changes made will be saved. According to the + function parameters. If an exception is raised, a new checkpoint + was not created. + + :param str title: The title of the save. If a title is given, the + configuration will be saved as a new checkpoint and put in a + timestamped directory. + + :param bool temporary: Indicates whether the changes made will + be quickly reversed in the future (ie. challenges) + + """ + save_files = self.parser.unsaved_files() + if save_files: + self.add_to_checkpoint(save_files, + self.save_notes, temporary=temporary) + # Handle the parser specific tasks + self.parser.save(save_files) + if title and not temporary: + self.finalize_checkpoint(title) + + def recovery_routine(self): + """Revert all previously modified files. + + Reverts all modified files that have not been saved as a checkpoint + + :raises .errors.PluginError: If unable to recover the configuration + + """ + super(ApacheConfigurator, self).recovery_routine() + # Reload configuration after these changes take effect if needed + # ie. ApacheParser has been initialized. + if self.parser: + # TODO: wrap into non-implementation specific parser interface + self.parser.aug.load() + + def revert_challenge_config(self): + """Used to cleanup challenge configurations. + + :raises .errors.PluginError: If unable to revert the challenge config. + + """ + self.revert_temporary_config() + self.parser.aug.load() + + def rollback_checkpoints(self, rollback=1): + """Rollback saved checkpoints. + + :param int rollback: Number of checkpoints to revert + + :raises .errors.PluginError: If there is a problem with the input or + the function is unable to correctly revert the configuration + + """ + super(ApacheConfigurator, self).rollback_checkpoints(rollback) + self.parser.aug.load() + + def _verify_exe_availability(self, exe): + """Checks availability of Apache executable""" + if not util.exe_exists(exe): + if not path_surgery(exe): + raise errors.NoInstallationError( + 'Cannot find Apache executable {0}'.format(exe)) + + def get_parser(self): + """Initializes the ApacheParser""" + # If user provided vhost_root value in command line, use it + return parser.ApacheParser( + self.option("server_root"), self.conf("vhost-root"), + self.version, configurator=self) + + def _wildcard_domain(self, domain): + """ + Checks if domain is a wildcard domain + + :param str domain: Domain to check + + :returns: If the domain is wildcard domain + :rtype: bool + """ + if isinstance(domain, six.text_type): + wildcard_marker = u"*." + else: + wildcard_marker = b"*." + return domain.startswith(wildcard_marker) + + def deploy_cert(self, domain, cert_path, key_path, + chain_path=None, fullchain_path=None): + """Deploys certificate to specified virtual host. + + Currently tries to find the last directives to deploy the certificate + in the VHost associated with the given domain. 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. After the certificate is + installed, the VirtualHost is enabled if it isn't already. + + .. todo:: Might be nice to remove chain directive if none exists + This shouldn't happen within certbot though + + :raises errors.PluginError: When unable to deploy certificate due to + a lack of directives + + """ + vhosts = self.choose_vhosts(domain) + for vhost in vhosts: + self._deploy_cert(vhost, cert_path, key_path, chain_path, fullchain_path) + + def choose_vhosts(self, domain, create_if_no_ssl=True): + """ + Finds VirtualHosts that can be used with the provided domain + + :param str domain: Domain name to match VirtualHosts to + :param bool create_if_no_ssl: If found VirtualHost doesn't have a HTTPS + counterpart, should one get created + + :returns: List of VirtualHosts or None + :rtype: `list` of :class:`~certbot_apache._internal.obj.VirtualHost` + """ + + if self._wildcard_domain(domain): + if domain in self._wildcard_vhosts: + # Vhosts for a wildcard domain were already selected + return self._wildcard_vhosts[domain] + # Ask user which VHosts to support. + # Returned objects are guaranteed to be ssl vhosts + return self._choose_vhosts_wildcard(domain, create_if_no_ssl) + else: + return [self.choose_vhost(domain, create_if_no_ssl)] + + def _vhosts_for_wildcard(self, domain): + """ + Get VHost objects for every VirtualHost that the user wants to handle + with the wildcard certificate. + """ + + # Collect all vhosts that match the name + matched = set() + for vhost in self.vhosts: + for name in vhost.get_names(): + if self._in_wildcard_scope(name, domain): + matched.add(vhost) + + return list(matched) + + def _in_wildcard_scope(self, name, domain): + """ + Helper method for _vhosts_for_wildcard() that makes sure that the domain + is in the scope of wildcard domain. + + eg. in scope: domain = *.wild.card, name = 1.wild.card + not in scope: domain = *.wild.card, name = 1.2.wild.card + """ + if len(name.split(".")) == len(domain.split(".")): + return fnmatch.fnmatch(name, domain) + return None + + def _choose_vhosts_wildcard(self, domain, create_ssl=True): + """Prompts user to choose vhosts to install a wildcard certificate for""" + + # Get all vhosts that are covered by the wildcard domain + vhosts = self._vhosts_for_wildcard(domain) + + # Go through the vhosts, making sure that we cover all the names + # present, but preferring the SSL vhosts + filtered_vhosts = dict() + for vhost in vhosts: + for name in vhost.get_names(): + if vhost.ssl: + # Always prefer SSL vhosts + filtered_vhosts[name] = vhost + elif name not in filtered_vhosts and create_ssl: + # Add if not in list previously + filtered_vhosts[name] = vhost + + # Only unique VHost objects + dialog_input = set(filtered_vhosts.values()) + + # Ask the user which of names to enable, expect list of names back + dialog_output = display_ops.select_vhost_multiple(list(dialog_input)) + + if not dialog_output: + logger.error( + "No vhost exists with servername or alias for domain %s. " + "No vhost was selected. Please specify ServerName or ServerAlias " + "in the Apache config.", + domain) + raise errors.PluginError("No vhost selected") + + # Make sure we create SSL vhosts for the ones that are HTTP only + # if requested. + return_vhosts = list() + for vhost in dialog_output: + if not vhost.ssl: + return_vhosts.append(self.make_vhost_ssl(vhost)) + else: + return_vhosts.append(vhost) + + self._wildcard_vhosts[domain] = return_vhosts + return return_vhosts + + def _deploy_cert(self, vhost, cert_path, key_path, chain_path, fullchain_path): + """ + Helper function for deploy_cert() that handles the actual deployment + this exists because we might want to do multiple deployments per + domain originally passed for deploy_cert(). This is especially true + with wildcard certificates + """ + # This is done first so that ssl module is enabled and cert_path, + # cert_key... can all be parsed appropriately + self.prepare_server_https("443") + + # Add directives and remove duplicates + self._add_dummy_ssl_directives(vhost.path) + self._clean_vhost(vhost) + + path = {"cert_path": self.parser.find_dir("SSLCertificateFile", + None, vhost.path), + "cert_key": self.parser.find_dir("SSLCertificateKeyFile", + None, vhost.path)} + + # Only include if a certificate chain is specified + if chain_path is not None: + path["chain_path"] = self.parser.find_dir( + "SSLCertificateChainFile", None, vhost.path) + + # Handle errors when certificate/key directives cannot be found + if not path["cert_path"]: + logger.warning( + "Cannot find an SSLCertificateFile directive in %s. " + "VirtualHost was not modified", vhost.path) + raise errors.PluginError( + "Unable to find an SSLCertificateFile directive") + elif not path["cert_key"]: + logger.warning( + "Cannot find an SSLCertificateKeyFile directive for " + "certificate in %s. VirtualHost was not modified", vhost.path) + raise errors.PluginError( + "Unable to find an SSLCertificateKeyFile directive for " + "certificate") + + logger.info("Deploying Certificate to VirtualHost %s", vhost.filep) + + if self.version < (2, 4, 8) or (chain_path and not fullchain_path): + # install SSLCertificateFile, SSLCertificateKeyFile, + # and SSLCertificateChainFile directives + set_cert_path = cert_path + self.parser.aug.set(path["cert_path"][-1], cert_path) + self.parser.aug.set(path["cert_key"][-1], key_path) + if chain_path is not None: + self.parser.add_dir(vhost.path, + "SSLCertificateChainFile", chain_path) + else: + raise errors.PluginError("--chain-path is required for your " + "version of Apache") + else: + if not fullchain_path: + raise errors.PluginError("Please provide the --fullchain-path " + "option pointing to your full chain file") + set_cert_path = fullchain_path + self.parser.aug.set(path["cert_path"][-1], fullchain_path) + self.parser.aug.set(path["cert_key"][-1], key_path) + + # Enable the new vhost if needed + if not vhost.enabled: + self.enable_site(vhost) + + # Save notes about the transaction that took place + self.save_notes += ("Changed vhost at %s with addresses of %s\n" + "\tSSLCertificateFile %s\n" + "\tSSLCertificateKeyFile %s\n" % + (vhost.filep, + ", ".join(str(addr) for addr in vhost.addrs), + set_cert_path, key_path)) + if chain_path is not None: + self.save_notes += "\tSSLCertificateChainFile %s\n" % chain_path + + def choose_vhost(self, target_name, create_if_no_ssl=True): + """Chooses a virtual host based on the given domain name. + + If there is no clear virtual host to be selected, the user is prompted + with all available choices. + + The returned vhost is guaranteed to have TLS enabled unless + create_if_no_ssl is set to False, in which case there is no such guarantee + and the result is not cached. + + :param str target_name: domain name + :param bool create_if_no_ssl: If found VirtualHost doesn't have a HTTPS + counterpart, should one get created + + :returns: vhost associated with name + :rtype: :class:`~certbot_apache._internal.obj.VirtualHost` + + :raises .errors.PluginError: If no vhost is available or chosen + + """ + # Allows for domain names to be associated with a virtual host + if target_name in self.assoc: + return self.assoc[target_name] + + # Try to find a reasonable vhost + vhost = self._find_best_vhost(target_name) + if vhost is not None: + if not create_if_no_ssl: + return vhost + if not vhost.ssl: + vhost = self.make_vhost_ssl(vhost) + + self._add_servername_alias(target_name, vhost) + self.assoc[target_name] = vhost + return vhost + + # Negate create_if_no_ssl value to indicate if we want a SSL vhost + # to get created if a non-ssl vhost is selected. + return self._choose_vhost_from_list(target_name, temp=not create_if_no_ssl) + + def _choose_vhost_from_list(self, target_name, temp=False): + # Select a vhost from a list + vhost = display_ops.select_vhost(target_name, self.vhosts) + if vhost is None: + logger.error( + "No vhost exists with servername or alias of %s. " + "No vhost was selected. Please specify ServerName or ServerAlias " + "in the Apache config.", + target_name) + raise errors.PluginError("No vhost selected") + if temp: + return vhost + if not vhost.ssl: + addrs = self._get_proposed_addrs(vhost, "443") + # TODO: Conflicts is too conservative + if not any(vhost.enabled and vhost.conflicts(addrs) for + vhost in self.vhosts): + vhost = self.make_vhost_ssl(vhost) + else: + logger.error( + "The selected vhost would conflict with other HTTPS " + "VirtualHosts within Apache. Please select another " + "vhost or add ServerNames to your configuration.") + raise errors.PluginError( + "VirtualHost not able to be selected.") + + self._add_servername_alias(target_name, vhost) + self.assoc[target_name] = vhost + return vhost + + def domain_in_names(self, names, target_name): + """Checks if target domain is covered by one or more of the provided + names. The target name is matched by wildcard as well as exact match. + + :param names: server aliases + :type names: `collections.Iterable` of `str` + :param str target_name: name to compare with wildcards + + :returns: True if target_name is covered by a wildcard, + otherwise, False + :rtype: bool + + """ + # use lowercase strings because fnmatch can be case sensitive + target_name = target_name.lower() + for name in names: + name = name.lower() + # fnmatch treats "[seq]" specially and [ or ] characters aren't + # valid in Apache but Apache doesn't error out if they are present + if "[" not in name and fnmatch.fnmatch(target_name, name): + return True + return False + + def find_best_http_vhost(self, target, filter_defaults, port="80"): + """Returns non-HTTPS vhost objects found from the Apache config + + :param str target: Domain name of the desired VirtualHost + :param bool filter_defaults: whether _default_ vhosts should be + included if it is the best match + :param str port: port number the vhost should be listening on + + :returns: VirtualHost object that's the best match for target name + :rtype: `obj.VirtualHost` or None + """ + filtered_vhosts = [] + for vhost in self.vhosts: + if any(a.is_wildcard() or a.get_port() == port for a in vhost.addrs) and not vhost.ssl: + filtered_vhosts.append(vhost) + return self._find_best_vhost(target, filtered_vhosts, filter_defaults) + + def _find_best_vhost(self, target_name, vhosts=None, filter_defaults=True): + """Finds the best vhost for a target_name. + + This does not upgrade a vhost to HTTPS... it only finds the most + appropriate vhost for the given target_name. + + :param str target_name: domain handled by the desired vhost + :param vhosts: vhosts to consider + :type vhosts: `collections.Iterable` of :class:`~certbot_apache._internal.obj.VirtualHost` + :param bool filter_defaults: whether a vhost with a _default_ + addr is acceptable + + :returns: VHost or None + + """ + # Points 6 - Servername SSL + # Points 5 - Wildcard SSL + # Points 4 - Address name with SSL + # Points 3 - Servername no SSL + # Points 2 - Wildcard no SSL + # Points 1 - Address name with no SSL + best_candidate = None + best_points = 0 + + if vhosts is None: + vhosts = self.vhosts + + for vhost in vhosts: + if vhost.modmacro is True: + continue + names = vhost.get_names() + if target_name in names: + points = 3 + elif self.domain_in_names(names, target_name): + points = 2 + elif any(addr.get_addr() == target_name for addr in vhost.addrs): + points = 1 + else: + # No points given if names can't be found. + # This gets hit but doesn't register + continue # pragma: no cover + + if vhost.ssl: + points += 3 + + if points > best_points: + best_points = points + best_candidate = vhost + + # No winners here... is there only one reasonable vhost? + if best_candidate is None: + if filter_defaults: + vhosts = self._non_default_vhosts(vhosts) + # remove mod_macro hosts from reasonable vhosts + reasonable_vhosts = [vh for vh + in vhosts if vh.modmacro is False] + if len(reasonable_vhosts) == 1: + best_candidate = reasonable_vhosts[0] + + return best_candidate + + def _non_default_vhosts(self, vhosts): + """Return all non _default_ only vhosts.""" + return [vh for vh in vhosts if not all( + addr.get_addr() == "_default_" for addr in vh.addrs + )] + + 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() # type: Set[str] + + vhost_macro = [] + + for vhost in self.vhosts: + all_names.update(vhost.get_names()) + if vhost.modmacro: + vhost_macro.append(vhost.filep) + + for addr in vhost.addrs: + if common.hostname_regex.match(addr.get_addr()): + all_names.add(addr.get_addr()) + else: + name = self.get_name_from_ip(addr) + if name: + all_names.add(name) + + if vhost_macro: + zope.component.getUtility(interfaces.IDisplay).notification( + "Apache mod_macro seems to be in use in file(s):\n{0}" + "\n\nUnfortunately mod_macro is not yet supported".format( + "\n ".join(vhost_macro)), force_interactive=True) + + return util.get_filtered_names(all_names) + + def get_name_from_ip(self, addr): # pylint: disable=no-self-use + """Returns a reverse dns name if available. + + :param addr: IP Address + :type addr: ~.common.Addr + + :returns: name or empty string if name cannot be determined + :rtype: str + + """ + # If it isn't a private IP, do a reverse DNS lookup + if not common.private_ips_regex.match(addr.get_addr()): + try: + socket.inet_aton(addr.get_addr()) + return socket.gethostbyaddr(addr.get_addr())[0] + except (socket.error, socket.herror, socket.timeout): + pass + + return "" + + def _get_vhost_names(self, path): + """Helper method for getting the ServerName and + ServerAlias values from vhost in path + + :param path: Path to read ServerName and ServerAliases from + + :returns: Tuple including ServerName and `list` of ServerAlias strings + """ + + servername_match = self.parser.find_dir( + "ServerName", None, start=path, exclude=False) + serveralias_match = self.parser.find_dir( + "ServerAlias", None, start=path, exclude=False) + + serveraliases = [] + for alias in serveralias_match: + serveralias = self.parser.get_arg(alias) + serveraliases.append(serveralias) + + servername = None + if servername_match: + # Get last ServerName as each overwrites the previous + servername = self.parser.get_arg(servername_match[-1]) + + return (servername, serveraliases) + + def _add_servernames(self, host): + """Helper function for get_virtual_hosts(). + + :param host: In progress vhost whose names will be added + :type host: :class:`~certbot_apache._internal.obj.VirtualHost` + + """ + + servername, serveraliases = self._get_vhost_names(host.path) + + for alias in serveraliases: + if not host.modmacro: + host.aliases.add(alias) + + if not host.modmacro: + host.name = servername + + 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:`~certbot_apache._internal.obj.VirtualHost` + + """ + addrs = set() + try: + args = self.parser.aug.match(path + "/arg") + except RuntimeError: + logger.warning("Encountered a problem while parsing file: %s, skipping", path) + return None + for arg in args: + addrs.add(obj.Addr.fromstring(self.parser.get_arg(arg))) + is_ssl = False + + if self.parser.find_dir("SSLEngine", "on", start=path, exclude=False): + is_ssl = True + + # "SSLEngine on" might be set outside of <VirtualHost> + # Treat vhosts with port 443 as ssl vhosts + for addr in addrs: + if addr.get_port() == "443": + is_ssl = True + + filename = apache_util.get_file_path( + self.parser.aug.get("/augeas/files%s/path" % apache_util.get_file_path(path))) + if filename is None: + return None + + macro = False + if "/macro/" in path.lower(): + macro = True + + vhost_enabled = self.parser.parsed_in_original(filename) + + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, + vhost_enabled, modmacro=macro) + self._add_servernames(vhost) + return vhost + + def get_virtual_hosts(self): + """Returns list of virtual hosts found in the Apache configuration. + + :returns: List of :class:`~certbot_apache._internal.obj.VirtualHost` + objects found in configuration + :rtype: list + + """ + # Search base config, and all included paths for VirtualHosts + file_paths = {} # type: Dict[str, str] + internal_paths = defaultdict(set) # type: DefaultDict[str, Set[str]] + vhs = [] + # Make a list of parser paths because the parser_paths + # dictionary may be modified during the loop. + for vhost_path in list(self.parser.parser_paths): + paths = self.parser.aug.match( + ("/files%s//*[label()=~regexp('%s')]" % + (vhost_path, parser.case_i("VirtualHost")))) + paths = [path for path in paths if + "virtualhost" in os.path.basename(path).lower()] + for path in paths: + new_vhost = self._create_vhost(path) + if not new_vhost: + continue + internal_path = apache_util.get_internal_aug_path(new_vhost.path) + realpath = filesystem.realpath(new_vhost.filep) + if realpath not in file_paths: + file_paths[realpath] = new_vhost.filep + internal_paths[realpath].add(internal_path) + vhs.append(new_vhost) + elif (realpath == new_vhost.filep and + realpath != file_paths[realpath]): + # Prefer "real" vhost paths instead of symlinked ones + # ex: sites-enabled/vh.conf -> sites-available/vh.conf + + # remove old (most likely) symlinked one + new_vhs = [] + for v in vhs: + if v.filep == file_paths[realpath]: + internal_paths[realpath].remove( + apache_util.get_internal_aug_path(v.path)) + else: + new_vhs.append(v) + vhs = new_vhs + + file_paths[realpath] = realpath + internal_paths[realpath].add(internal_path) + vhs.append(new_vhost) + elif internal_path not in internal_paths[realpath]: + internal_paths[realpath].add(internal_path) + vhs.append(new_vhost) + return vhs + + 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 certbot_apache._internal.obj.Addr target_addr: vhost address + + :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.parser.find_dir("NameVirtualHost", str(target_addr))) + + def add_name_vhost(self, addr): + """Adds NameVirtualHost directive for given address. + + :param addr: Address that will be added as NameVirtualHost directive + :type addr: :class:`~certbot_apache._internal.obj.Addr` + + """ + + loc = parser.get_aug_path(self.parser.loc["name"]) + if addr.get_port() == "443": + self.parser.add_dir_to_ifmodssl( + loc, "NameVirtualHost", [str(addr)]) + else: + self.parser.add_dir(loc, "NameVirtualHost", [str(addr)]) + + msg = "Setting {0} to be NameBasedVirtualHost\n".format(addr) + logger.debug(msg) + self.save_notes += msg + + def prepare_server_https(self, port, temp=False): + """Prepare the server for HTTPS. + + Make sure that the ssl_module is loaded and that the server + is appropriately listening on port. + + :param str port: Port to listen on + + """ + + self.prepare_https_modules(temp) + self.ensure_listen(port, https=True) + + def ensure_listen(self, port, https=False): + """Make sure that Apache is listening on the port. Checks if the + Listen statement for the port already exists, and adds it to the + configuration if necessary. + + :param str port: Port number to check and add Listen for if not in + place already + :param bool https: If the port will be used for HTTPS + + """ + + # If HTTPS requested for nonstandard port, add service definition + if https and port != "443": + port_service = "%s %s" % (port, "https") + else: + port_service = port + + # Check for Listen <port> + # Note: This could be made to also look for ip:443 combo + listens = [self.parser.get_arg(x).split()[0] for + x in self.parser.find_dir("Listen")] + + # Listen already in place + if self._has_port_already(listens, port): + return + + listen_dirs = set(listens) + + if not listens: + listen_dirs.add(port_service) + + for listen in listens: + # For any listen statement, check if the machine also listens on + # the given port. If not, add such a listen statement. + if len(listen.split(":")) == 1: + # Its listening to all interfaces + if port not in listen_dirs and port_service not in listen_dirs: + listen_dirs.add(port_service) + else: + # The Listen statement specifies an ip + _, ip = listen[::-1].split(":", 1) + ip = ip[::-1] + if "%s:%s" % (ip, port_service) not in listen_dirs and ( + "%s:%s" % (ip, port_service) not in listen_dirs): + listen_dirs.add("%s:%s" % (ip, port_service)) + if https: + self._add_listens_https(listen_dirs, listens, port) + else: + self._add_listens_http(listen_dirs, listens, port) + + def _add_listens_http(self, listens, listens_orig, port): + """Helper method for ensure_listen to figure out which new + listen statements need adding for listening HTTP on port + + :param set listens: Set of all needed Listen statements + :param list listens_orig: List of existing listen statements + :param string port: Port number we're adding + """ + + new_listens = listens.difference(listens_orig) + + if port in new_listens: + # We have wildcard, skip the rest + self.parser.add_dir(parser.get_aug_path(self.parser.loc["listen"]), + "Listen", port) + self.save_notes += "Added Listen %s directive to %s\n" % ( + port, self.parser.loc["listen"]) + else: + for listen in new_listens: + self.parser.add_dir(parser.get_aug_path( + self.parser.loc["listen"]), "Listen", listen.split(" ")) + self.save_notes += ("Added Listen %s directive to " + "%s\n") % (listen, + self.parser.loc["listen"]) + + def _add_listens_https(self, listens, listens_orig, port): + """Helper method for ensure_listen to figure out which new + listen statements need adding for listening HTTPS on port + + :param set listens: Set of all needed Listen statements + :param list listens_orig: List of existing listen statements + :param string port: Port number we're adding + """ + + # Add service definition for non-standard ports + if port != "443": + port_service = "%s %s" % (port, "https") + else: + port_service = port + + new_listens = listens.difference(listens_orig) + + if port in new_listens or port_service in new_listens: + # We have wildcard, skip the rest + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["listen"]), + "Listen", port_service.split(" ")) + self.save_notes += "Added Listen %s directive to %s\n" % ( + port_service, self.parser.loc["listen"]) + else: + for listen in new_listens: + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["listen"]), + "Listen", listen.split(" ")) + self.save_notes += ("Added Listen %s directive to " + "%s\n") % (listen, + self.parser.loc["listen"]) + + def _has_port_already(self, listens, port): + """Helper method for prepare_server_https to find out if user + already has an active Listen statement for the port we need + + :param list listens: List of listen variables + :param string port: Port in question + """ + + if port in listens: + return True + # Check if Apache is already listening on a specific IP + for listen in listens: + if len(listen.split(":")) > 1: + # Ugly but takes care of protocol def, eg: 1.1.1.1:443 https + if listen.split(":")[-1].split(" ")[0] == port: + return True + return None + + def prepare_https_modules(self, temp): + """Helper method for prepare_server_https, taking care of enabling + needed modules + + :param boolean temp: If the change is temporary + """ + + if self.option("handle_modules"): + if self.version >= (2, 4) and ("socache_shmcb_module" not in + self.parser.modules): + self.enable_mod("socache_shmcb", temp=temp) + if "ssl_module" not in self.parser.modules: + self.enable_mod("ssl", temp=temp) + + 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) + + ``self.option("le_vhost_ext")`` + + .. note:: This function saves the configuration + + :param nonssl_vhost: Valid VH that doesn't have SSLEngine on + :type nonssl_vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :returns: SSL vhost + :rtype: :class:`~certbot_apache._internal.obj.VirtualHost` + + :raises .errors.PluginError: If more than one virtual host is in + the file or if plugin is unable to write/read vhost files. + + """ + avail_fp = nonssl_vhost.filep + ssl_fp = self._get_ssl_vhost_path(avail_fp) + + orig_matches = self.parser.aug.match("/files%s//* [label()=~regexp('%s')]" % + (self._escape(ssl_fp), + parser.case_i("VirtualHost"))) + + self._copy_create_ssl_vhost_skeleton(nonssl_vhost, ssl_fp) + + # Reload augeas to take into account the new vhost + self.parser.aug.load() + # Get Vhost augeas path for new vhost + new_matches = self.parser.aug.match("/files%s//* [label()=~regexp('%s')]" % + (self._escape(ssl_fp), + parser.case_i("VirtualHost"))) + + vh_p = self._get_new_vh_path(orig_matches, new_matches) + + if not vh_p: + # The vhost was not found on the currently parsed paths + # Make Augeas aware of the new vhost + self.parser.parse_file(ssl_fp) + # Try to search again + new_matches = self.parser.aug.match( + "/files%s//* [label()=~regexp('%s')]" % + (self._escape(ssl_fp), + parser.case_i("VirtualHost"))) + vh_p = self._get_new_vh_path(orig_matches, new_matches) + if not vh_p: + raise errors.PluginError( + "Could not reverse map the HTTPS VirtualHost to the original") + + + # Update Addresses + self._update_ssl_vhosts_addrs(vh_p) + + # 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 + # Create the Vhost object + ssl_vhost = self._create_vhost(vh_p) + ssl_vhost.ancestor = nonssl_vhost + + self.vhosts.append(ssl_vhost) + + # 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 + + # Now check if addresses need to be added as NameBasedVhost addrs + # This is for compliance with versions of Apache < 2.4 + self._add_name_vhost_if_necessary(ssl_vhost) + + return ssl_vhost + + def _get_new_vh_path(self, orig_matches, new_matches): + """ Helper method for make_vhost_ssl for matching augeas paths. Returns + VirtualHost path from new_matches that's not present in orig_matches. + + Paths are normalized, because augeas leaves indices out for paths + with only single directive with a similar key """ + + orig_matches = [i.replace("[1]", "") for i in orig_matches] + for match in new_matches: + if match.replace("[1]", "") not in orig_matches: + # Return the unmodified path + return match + return None + + def _get_ssl_vhost_path(self, non_ssl_vh_fp): + """ Get a file path for SSL vhost, uses user defined path as priority, + but if the value is invalid or not defined, will fall back to non-ssl + vhost filepath. + + :param str non_ssl_vh_fp: Filepath of non-SSL vhost + + :returns: Filepath for SSL vhost + :rtype: str + """ + + if self.conf("vhost-root") and os.path.exists(self.conf("vhost-root")): + fp = os.path.join(filesystem.realpath(self.option("vhost_root")), + os.path.basename(non_ssl_vh_fp)) + else: + # Use non-ssl filepath + fp = filesystem.realpath(non_ssl_vh_fp) + + if fp.endswith(".conf"): + return fp[:-(len(".conf"))] + self.option("le_vhost_ext") + return fp + self.option("le_vhost_ext") + + def _sift_rewrite_rule(self, line): + """Decides whether a line should be copied to a SSL vhost. + + A canonical example of when sifting a line is required: + When the http vhost contains a RewriteRule that unconditionally + redirects any request to the https version of the same site. + e.g: + RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [L,QSA,R=permanent] + Copying the above line to the ssl vhost would cause a + redirection loop. + + :param str line: a line extracted from the http vhost. + + :returns: True - don't copy line from http vhost to SSL vhost. + :rtype: bool + + """ + if not line.lower().lstrip().startswith("rewriterule"): + return False + + # According to: http://httpd.apache.org/docs/2.4/rewrite/flags.html + # The syntax of a RewriteRule is: + # RewriteRule pattern target [Flag1,Flag2,Flag3] + # i.e. target is required, so it must exist. + target = line.split()[2].strip() + + # target may be surrounded with quotes + if target[0] in ("'", '"') and target[0] == target[-1]: + target = target[1:-1] + + # Sift line if it redirects the request to a HTTPS site + return target.startswith("https://") + + def _copy_create_ssl_vhost_skeleton(self, vhost, ssl_fp): + """Copies over existing Vhost with IfModule mod_ssl.c> skeleton. + + :param obj.VirtualHost vhost: Original VirtualHost object + :param str ssl_fp: Full path where the new ssl_vhost will reside. + + A new file is created on the filesystem. + + """ + # First register the creation so that it is properly removed if + # configuration is rolled back + if os.path.exists(ssl_fp): + notes = "Appended new VirtualHost directive to file %s" % ssl_fp + files = set() + files.add(ssl_fp) + self.reverter.add_to_checkpoint(files, notes) + else: + self.reverter.register_file_creation(False, ssl_fp) + sift = False + + try: + orig_contents = self._get_vhost_block(vhost) + ssl_vh_contents, sift = self._sift_rewrite_rules(orig_contents) + + with open(ssl_fp, "a") as new_file: + new_file.write("<IfModule mod_ssl.c>\n") + new_file.write("\n".join(ssl_vh_contents)) + # The content does not include the closing tag, so add it + new_file.write("</VirtualHost>\n") + new_file.write("</IfModule>\n") + # Add new file to augeas paths if we're supposed to handle + # activation (it's not included as default) + if not self.parser.parsed_in_current(ssl_fp): + self.parser.parse_file(ssl_fp) + except IOError: + logger.critical("Error writing/reading to file in make_vhost_ssl", exc_info=True) + raise errors.PluginError("Unable to write/read in make_vhost_ssl") + + if sift: + reporter = zope.component.getUtility(interfaces.IReporter) + reporter.add_message( + "Some rewrite rules copied from {0} were disabled in the " + "vhost for your HTTPS site located at {1} because they have " + "the potential to create redirection loops.".format( + vhost.filep, ssl_fp), reporter.MEDIUM_PRIORITY) + self.parser.aug.set("/augeas/files%s/mtime" % (self._escape(ssl_fp)), "0") + self.parser.aug.set("/augeas/files%s/mtime" % (self._escape(vhost.filep)), "0") + + def _sift_rewrite_rules(self, contents): + """ Helper function for _copy_create_ssl_vhost_skeleton to prepare the + new HTTPS VirtualHost contents. Currently disabling the rewrites """ + + result = [] + sift = False + contents = iter(contents) + + comment = ("# Some rewrite rules in this file were " + "disabled on your HTTPS site,\n" + "# because they have the potential to create " + "redirection loops.\n") + + for line in contents: + A = line.lower().lstrip().startswith("rewritecond") + B = line.lower().lstrip().startswith("rewriterule") + + if not (A or B): + result.append(line) + continue + + # A RewriteRule that doesn't need filtering + if B and not self._sift_rewrite_rule(line): + result.append(line) + continue + + # A RewriteRule that does need filtering + if B and self._sift_rewrite_rule(line): + if not sift: + result.append(comment) + sift = True + result.append("# " + line) + continue + + # We save RewriteCond(s) and their corresponding + # RewriteRule in 'chunk'. + # We then decide whether we comment out the entire + # chunk based on its RewriteRule. + chunk = [] + if A: + chunk.append(line) + line = next(contents) + + # RewriteCond(s) must be followed by one RewriteRule + while not line.lower().lstrip().startswith("rewriterule"): + chunk.append(line) + line = next(contents) + + # Now, current line must start with a RewriteRule + chunk.append(line) + + if self._sift_rewrite_rule(line): + if not sift: + result.append(comment) + sift = True + + result.append('\n'.join(['# ' + l for l in chunk])) + else: + result.append('\n'.join(chunk)) + return result, sift + + def _get_vhost_block(self, vhost): + """ Helper method to get VirtualHost contents from the original file. + This is done with help of augeas span, which returns the span start and + end positions + + :returns: `list` of VirtualHost block content lines without closing tag + """ + + try: + span_val = self.parser.aug.span(vhost.path) + except ValueError: + logger.critical("Error while reading the VirtualHost %s from " + "file %s", vhost.name, vhost.filep, exc_info=True) + raise errors.PluginError("Unable to read VirtualHost from file") + span_filep = span_val[0] + span_start = span_val[5] + span_end = span_val[6] + with open(span_filep, 'r') as fh: + fh.seek(span_start) + vh_contents = fh.read(span_end-span_start).split("\n") + self._remove_closing_vhost_tag(vh_contents) + return vh_contents + + def _remove_closing_vhost_tag(self, vh_contents): + """Removes the closing VirtualHost tag if it exists. + + This method modifies vh_contents directly to remove the closing + tag. If the closing vhost tag is found, everything on the line + after it is also removed. Whether or not this tag is included + in the result of span depends on the Augeas version. + + :param list vh_contents: VirtualHost block contents to check + + """ + for offset, line in enumerate(reversed(vh_contents)): + if line: + line_index = line.lower().find("</virtualhost>") + if line_index != -1: + content_index = len(vh_contents) - offset - 1 + vh_contents[content_index] = line[:line_index] + break + + def _update_ssl_vhosts_addrs(self, vh_path): + ssl_addrs = set() + ssl_addr_p = self.parser.aug.match(vh_path + "/arg") + + for addr in ssl_addr_p: + old_addr = obj.Addr.fromstring( + str(self.parser.get_arg(addr))) + ssl_addr = old_addr.get_addr_obj("443") + self.parser.aug.set(addr, str(ssl_addr)) + ssl_addrs.add(ssl_addr) + + return ssl_addrs + + def _clean_vhost(self, vhost): + # remove duplicated or conflicting ssl directives + self._deduplicate_directives(vhost.path, + ["SSLCertificateFile", + "SSLCertificateKeyFile"]) + # remove all problematic directives + self._remove_directives(vhost.path, ["SSLCertificateChainFile"]) + + def _deduplicate_directives(self, vh_path, directives): + for directive in directives: + while len(self.parser.find_dir(directive, None, + vh_path, False)) > 1: + directive_path = self.parser.find_dir(directive, None, + vh_path, False) + self.parser.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) + + def _remove_directives(self, vh_path, directives): + for directive in directives: + while self.parser.find_dir(directive, None, vh_path, False): + directive_path = self.parser.find_dir(directive, None, + vh_path, False) + self.parser.aug.remove(re.sub(r"/\w*$", "", directive_path[0])) + + def _add_dummy_ssl_directives(self, vh_path): + self.parser.add_dir(vh_path, "SSLCertificateFile", + "insert_cert_file_path") + self.parser.add_dir(vh_path, "SSLCertificateKeyFile", + "insert_key_file_path") + # Only include the TLS configuration if not already included + existing_inc = self.parser.find_dir("Include", self.mod_ssl_conf, vh_path) + if not existing_inc: + self.parser.add_dir(vh_path, "Include", self.mod_ssl_conf) + + def _add_servername_alias(self, target_name, vhost): + vh_path = vhost.path + sname, saliases = self._get_vhost_names(vh_path) + if target_name == sname or target_name in saliases: + return + if self._has_matching_wildcard(vh_path, target_name): + return + if not self.parser.find_dir("ServerName", None, + start=vh_path, exclude=False): + self.parser.add_dir(vh_path, "ServerName", target_name) + else: + self.parser.add_dir(vh_path, "ServerAlias", target_name) + self._add_servernames(vhost) + + def _has_matching_wildcard(self, vh_path, target_name): + """Is target_name already included in a wildcard in the vhost? + + :param str vh_path: Augeas path to the vhost + :param str target_name: name to compare with wildcards + + :returns: True if there is a wildcard covering target_name in + the vhost in vhost_path, otherwise, False + :rtype: bool + + """ + matches = self.parser.find_dir( + "ServerAlias", start=vh_path, exclude=False) + aliases = (self.parser.aug.get(match) for match in matches) + return self.domain_in_names(aliases, target_name) + + def _add_name_vhost_if_necessary(self, vhost): + """Add NameVirtualHost Directives if necessary for new vhost. + + NameVirtualHosts was a directive in Apache < 2.4 + https://httpd.apache.org/docs/2.2/mod/core.html#namevirtualhost + + :param vhost: New virtual host that was recently created. + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + """ + need_to_save = False + + # See if the exact address appears in any other vhost + # Remember 1.1.1.1:* == 1.1.1.1 -> hence any() + for addr in vhost.addrs: + # In Apache 2.2, when a NameVirtualHost directive is not + # set, "*" and "_default_" will conflict when sharing a port + addrs = set((addr,)) + if addr.get_addr() in ("*", "_default_"): + addrs.update(obj.Addr((a, addr.get_port(),)) + for a in ("*", "_default_")) + + for test_vh in self.vhosts: + if (vhost.filep != test_vh.filep and + any(test_addr in addrs for + test_addr in test_vh.addrs) and + not self.is_name_vhost(addr)): + self.add_name_vhost(addr) + logger.info("Enabling NameVirtualHosts on %s", addr) + need_to_save = True + break + + if need_to_save: + self.save() + + def find_vhost_by_id(self, id_str): + """ + Searches through VirtualHosts and tries to match the id in a comment + + :param str id_str: Id string for matching + + :returns: The matched VirtualHost or None + :rtype: :class:`~certbot_apache._internal.obj.VirtualHost` or None + + :raises .errors.PluginError: If no VirtualHost is found + """ + + for vh in self.vhosts: + if self._find_vhost_id(vh) == id_str: + return vh + msg = "No VirtualHost with ID {} was found.".format(id_str) + logger.warning(msg) + raise errors.PluginError(msg) + + def _find_vhost_id(self, vhost): + """Tries to find the unique ID from the VirtualHost comments. This is + used for keeping track of VirtualHost directive over time. + + :param vhost: Virtual host to add the id + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :returns: The unique ID or None + :rtype: str or None + """ + + # Strip the {} off from the format string + search_comment = constants.MANAGED_COMMENT_ID.format("") + + id_comment = self.parser.find_comments(search_comment, vhost.path) + if id_comment: + # Use the first value, multiple ones shouldn't exist + comment = self.parser.get_arg(id_comment[0]) + return comment.split(" ")[-1] + return None + + def add_vhost_id(self, vhost): + """Adds an unique ID to the VirtualHost as a comment for mapping back + to it on later invocations, as the config file order might have changed. + If ID already exists, returns that instead. + + :param vhost: Virtual host to add or find the id + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :returns: The unique ID for vhost + :rtype: str or None + """ + + vh_id = self._find_vhost_id(vhost) + if vh_id: + return vh_id + + id_string = apache_util.unique_id() + comment = constants.MANAGED_COMMENT_ID.format(id_string) + self.parser.add_comment(vhost.path, comment) + return id_string + + def _escape(self, fp): + fp = fp.replace(",", "\\,") + fp = fp.replace("[", "\\[") + fp = fp.replace("]", "\\]") + fp = fp.replace("|", "\\|") + fp = fp.replace("=", "\\=") + fp = fp.replace("(", "\\(") + fp = fp.replace(")", "\\)") + fp = fp.replace("!", "\\!") + return fp + + ###################################################################### + # Enhancements + ###################################################################### + def supported_enhancements(self): # pylint: disable=no-self-use + """Returns currently supported enhancements.""" + return ["redirect", "ensure-http-header", "staple-ocsp"] + + def enhance(self, domain, enhancement, options=None): + """Enhance configuration. + + :param str domain: domain to enhance + :param str enhancement: enhancement type defined in + :const:`~certbot.plugins.enhancements.ENHANCEMENTS` + :param options: options for the enhancement + See :const:`~certbot.plugins.enhancements.ENHANCEMENTS` + documentation for appropriate parameter. + + :raises .errors.PluginError: If Enhancement is not supported, or if + there is any other problem with the enhancement. + + """ + try: + func = self._enhance_func[enhancement] + except KeyError: + raise errors.PluginError( + "Unsupported enhancement: {0}".format(enhancement)) + + matched_vhosts = self.choose_vhosts(domain, create_if_no_ssl=False) + # We should be handling only SSL vhosts for enhancements + vhosts = [vhost for vhost in matched_vhosts if vhost.ssl] + + if not vhosts: + msg_tmpl = ("Certbot was not able to find SSL VirtualHost for a " + "domain {0} for enabling enhancement \"{1}\". The requested " + "enhancement was not configured.") + msg_enhancement = enhancement + if options: + msg_enhancement += ": " + options + msg = msg_tmpl.format(domain, msg_enhancement) + logger.warning(msg) + raise errors.PluginError(msg) + try: + for vhost in vhosts: + func(vhost, options) + except errors.PluginError: + logger.warning("Failed %s for %s", enhancement, domain) + raise + + def _autohsts_increase(self, vhost, id_str, nextstep): + """Increase the AutoHSTS max-age value + + :param vhost: Virtual host object to modify + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :param str id_str: The unique ID string of VirtualHost + + :param int nextstep: Next AutoHSTS max-age value index + + """ + nextstep_value = constants.AUTOHSTS_STEPS[nextstep] + self._autohsts_write(vhost, nextstep_value) + self._autohsts[id_str] = {"laststep": nextstep, "timestamp": time.time()} + + def _autohsts_write(self, vhost, nextstep_value): + """ + Write the new HSTS max-age value to the VirtualHost file + """ + + hsts_dirpath = None + header_path = self.parser.find_dir("Header", None, vhost.path) + if header_path: + pat = '(?:[ "]|^)(strict-transport-security)(?:[ "]|$)' + for match in header_path: + if re.search(pat, self.parser.aug.get(match).lower()): + hsts_dirpath = match + if not hsts_dirpath: + err_msg = ("Certbot was unable to find the existing HSTS header " + "from the VirtualHost at path {0}.").format(vhost.filep) + raise errors.PluginError(err_msg) + + # Prepare the HSTS header value + hsts_maxage = "\"max-age={0}\"".format(nextstep_value) + + # Update the header + # Our match statement was for string strict-transport-security, but + # we need to update the value instead. The next index is for the value + hsts_dirpath = hsts_dirpath.replace("arg[3]", "arg[4]") + self.parser.aug.set(hsts_dirpath, hsts_maxage) + note_msg = ("Increasing HSTS max-age value to {0} for VirtualHost " + "in {1}\n".format(nextstep_value, vhost.filep)) + logger.debug(note_msg) + self.save_notes += note_msg + self.save(note_msg) + + def _autohsts_fetch_state(self): + """ + Populates the AutoHSTS state from the pluginstorage + """ + try: + self._autohsts = self.storage.fetch("autohsts") + except KeyError: + self._autohsts = dict() + + def _autohsts_save_state(self): + """ + Saves the state of AutoHSTS object to pluginstorage + """ + self.storage.put("autohsts", self._autohsts) + self.storage.save() + + def _autohsts_vhost_in_lineage(self, vhost, lineage): + """ + Searches AutoHSTS managed VirtualHosts that belong to the lineage. + Matches the private key path. + """ + + return bool( + self.parser.find_dir("SSLCertificateKeyFile", + lineage.key_path, vhost.path)) + + def _enable_ocsp_stapling(self, ssl_vhost, unused_options): + """Enables OCSP Stapling + + In OCSP, each client (e.g. browser) would have to query the + OCSP Responder to validate that the site certificate was not revoked. + + Enabling OCSP Stapling, would allow the web-server to query the OCSP + Responder, and staple its response to the offered certificate during + TLS. i.e. clients would not have to query the OCSP responder. + + OCSP Stapling enablement on Apache implicitly depends on + SSLCertificateChainFile being set by other code. + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :param unused_options: Not currently used + :type unused_options: Not Available + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, :class:`~certbot_apache._internal.obj.VirtualHost`) + + """ + min_apache_ver = (2, 3, 3) + if self.get_version() < min_apache_ver: + raise errors.PluginError( + "Unable to set OCSP directives.\n" + "Apache version is below 2.3.3.") + + if "socache_shmcb_module" not in self.parser.modules: + self.enable_mod("socache_shmcb") + + # Check if there's an existing SSLUseStapling directive on. + use_stapling_aug_path = self.parser.find_dir("SSLUseStapling", + "on", start=ssl_vhost.path) + if not use_stapling_aug_path: + self.parser.add_dir(ssl_vhost.path, "SSLUseStapling", "on") + + ssl_vhost_aug_path = self._escape(parser.get_aug_path(ssl_vhost.filep)) + + # Check if there's an existing SSLStaplingCache directive. + stapling_cache_aug_path = self.parser.find_dir('SSLStaplingCache', + None, ssl_vhost_aug_path) + + # We'll simply delete the directive, so that we'll have a + # consistent OCSP cache path. + if stapling_cache_aug_path: + self.parser.aug.remove( + re.sub(r"/\w*$", "", stapling_cache_aug_path[0])) + + self.parser.add_dir_to_ifmodssl(ssl_vhost_aug_path, + "SSLStaplingCache", + ["shmcb:/var/run/apache2/stapling_cache(128000)"]) + + msg = "OCSP Stapling was enabled on SSL Vhost: %s.\n"%( + ssl_vhost.filep) + self.save_notes += msg + self.save() + logger.info(msg) + + def _set_http_header(self, ssl_vhost, header_substring): + """Enables header that is identified by header_substring on ssl_vhost. + + If the header identified by header_substring is not already set, + a new Header directive is placed in ssl_vhost's configuration with + arguments from: constants.HTTP_HEADER[header_substring] + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. + :type str + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, :class:`~certbot_apache._internal.obj.VirtualHost`) + + :raises .errors.PluginError: If no viable HTTP host can be created or + set with header header_substring. + + """ + if "headers_module" not in self.parser.modules: + self.enable_mod("headers") + + # Check if selected header is already set + self._verify_no_matching_http_header(ssl_vhost, header_substring) + + # Add directives to server + self.parser.add_dir(ssl_vhost.path, "Header", + constants.HEADER_ARGS[header_substring]) + + self.save_notes += ("Adding %s header to ssl vhost in %s\n" % + (header_substring, ssl_vhost.filep)) + + self.save() + logger.info("Adding %s header to ssl vhost in %s", header_substring, + ssl_vhost.filep) + + def _verify_no_matching_http_header(self, ssl_vhost, header_substring): + """Checks to see if there is an existing Header directive that + contains the string header_substring. + + :param ssl_vhost: vhost to check + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :param header_substring: string that uniquely identifies a header. + e.g: Strict-Transport-Security, Upgrade-Insecure-Requests. + :type str + + :returns: boolean + :rtype: (bool) + + :raises errors.PluginEnhancementAlreadyPresent When header + header_substring exists + + """ + header_path = self.parser.find_dir("Header", None, + start=ssl_vhost.path) + if header_path: + # "Existing Header directive for virtualhost" + pat = '(?:[ "]|^)(%s)(?:[ "]|$)' % (header_substring.lower()) + for match in header_path: + if re.search(pat, self.parser.aug.get(match).lower()): + raise errors.PluginEnhancementAlreadyPresent( + "Existing %s header" % (header_substring)) + + def _enable_redirect(self, ssl_vhost, unused_options): + """Redirect all equivalent HTTP traffic to ssl_vhost. + + .. todo:: This enhancement should be rewritten and will + unfortunately require lots of debugging by hand. + + 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 + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :param unused_options: Not currently used + :type unused_options: Not Available + + :raises .errors.PluginError: If no viable HTTP host can be created or + used for the redirect. + + """ + if "rewrite_module" not in self.parser.modules: + self.enable_mod("rewrite") + general_vh = self._get_http_vhost(ssl_vhost) + + if general_vh is None: + # Add virtual_server with redirect + logger.debug("Did not find http version of ssl virtual host " + "attempting to create") + redirect_addrs = self._get_proposed_addrs(ssl_vhost) + for vhost in self.vhosts: + if vhost.enabled and vhost.conflicts(redirect_addrs): + raise errors.PluginError( + "Unable to find corresponding HTTP vhost; " + "Unable to create one as intended addresses conflict; " + "Current configuration does not support automated " + "redirection") + self._create_redirect_vhost(ssl_vhost) + else: + if general_vh in self._enhanced_vhosts["redirect"]: + logger.debug("Already enabled redirect for this vhost") + return + + # Check if Certbot redirection already exists + self._verify_no_certbot_redirect(general_vh) + + # Note: if code flow gets here it means we didn't find the exact + # certbot RewriteRule config for redirection. Finding + # another RewriteRule is likely to be fine in most or all cases, + # but redirect loops are possible in very obscure cases; see #1620 + # for reasoning. + if self._is_rewrite_exists(general_vh): + logger.warning("Added an HTTP->HTTPS rewrite in addition to " + "other RewriteRules; you may wish to check for " + "overall consistency.") + + # Add directives to server + # Note: These are not immediately searchable in sites-enabled + # even with save() and load() + if not self._is_rewrite_engine_on(general_vh): + self.parser.add_dir(general_vh.path, "RewriteEngine", "on") + + names = ssl_vhost.get_names() + for idx, name in enumerate(names): + args = ["%{SERVER_NAME}", "={0}".format(name), "[OR]"] + if idx == len(names) - 1: + args.pop() + self.parser.add_dir(general_vh.path, "RewriteCond", args) + + self._set_https_redirection_rewrite_rule(general_vh) + + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % + (general_vh.filep, ssl_vhost.filep)) + self.save() + + self._enhanced_vhosts["redirect"].add(general_vh) + logger.info("Redirecting vhost in %s to ssl vhost in %s", + general_vh.filep, ssl_vhost.filep) + + def _set_https_redirection_rewrite_rule(self, vhost): + if self.get_version() >= (2, 3, 9): + self.parser.add_dir(vhost.path, "RewriteRule", + constants.REWRITE_HTTPS_ARGS_WITH_END) + else: + self.parser.add_dir(vhost.path, "RewriteRule", + constants.REWRITE_HTTPS_ARGS) + + def _verify_no_certbot_redirect(self, vhost): + """Checks to see if a redirect was already installed by certbot. + + Checks to see if virtualhost already contains a rewrite rule that is + identical to Certbot's redirection rewrite rule. + + For graceful transition to new rewrite rules for HTTPS redireciton we + delete certbot's old rewrite rules and set the new one instead. + + :param vhost: vhost to check + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :raises errors.PluginEnhancementAlreadyPresent: When the exact + certbot redirection WriteRule exists in virtual host. + """ + rewrite_path = self.parser.find_dir( + "RewriteRule", None, start=vhost.path) + + # There can be other RewriteRule directive lines in vhost config. + # rewrite_args_dict keys are directive ids and the corresponding value + # for each is a list of arguments to that directive. + rewrite_args_dict = defaultdict(list) # type: DefaultDict[str, List[str]] + pat = r'(.*directive\[\d+\]).*' + for match in rewrite_path: + m = re.match(pat, match) + if m: + dir_path = m.group(1) + rewrite_args_dict[dir_path].append(match) + + if rewrite_args_dict: + redirect_args = [constants.REWRITE_HTTPS_ARGS, + constants.REWRITE_HTTPS_ARGS_WITH_END] + + for dir_path, args_paths in rewrite_args_dict.items(): + arg_vals = [self.parser.aug.get(x) for x in args_paths] + + # Search for past redirection rule, delete it, set the new one + if arg_vals in constants.OLD_REWRITE_HTTPS_ARGS: + self.parser.aug.remove(dir_path) + self._set_https_redirection_rewrite_rule(vhost) + self.save() + raise errors.PluginEnhancementAlreadyPresent( + "Certbot has already enabled redirection") + + if arg_vals in redirect_args: + raise errors.PluginEnhancementAlreadyPresent( + "Certbot has already enabled redirection") + + def _is_rewrite_exists(self, vhost): + """Checks if there exists a RewriteRule directive in vhost + + :param vhost: vhost to check + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :returns: True if a RewriteRule directive exists. + :rtype: bool + + """ + rewrite_path = self.parser.find_dir( + "RewriteRule", None, start=vhost.path) + return bool(rewrite_path) + + def _is_rewrite_engine_on(self, vhost): + """Checks if a RewriteEngine directive is on + + :param vhost: vhost to check + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + """ + rewrite_engine_path_list = self.parser.find_dir("RewriteEngine", "on", + start=vhost.path) + if rewrite_engine_path_list: + for re_path in rewrite_engine_path_list: + # A RewriteEngine directive may also be included in per + # directory .htaccess files. We only care about the VirtualHost. + if 'virtualhost' in re_path.lower(): + return self.parser.get_arg(re_path) + return False + + 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:`~certbot_apache._internal.obj.VirtualHost` + + :returns: tuple of the form + (`success`, :class:`~certbot_apache._internal.obj.VirtualHost`) + :rtype: tuple + + """ + text = self._get_redirect_config_str(ssl_vhost) + + redirect_filepath = self._write_out_redirect(ssl_vhost, text) + + self.parser.aug.load() + # Make a new vhost data structure and add it to the lists + new_vhost = self._create_vhost(parser.get_aug_path(self._escape(redirect_filepath))) + self.vhosts.append(new_vhost) + self._enhanced_vhosts["redirect"].add(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)) + + def _get_redirect_config_str(self, ssl_vhost): + # get servernames and serveraliases + serveralias = "" + servername = "" + + if ssl_vhost.name is not None: + servername = "ServerName " + ssl_vhost.name + if ssl_vhost.aliases: + serveralias = "ServerAlias " + " ".join(ssl_vhost.aliases) + + rewrite_rule_args = [] # type: List[str] + if self.get_version() >= (2, 3, 9): + rewrite_rule_args = constants.REWRITE_HTTPS_ARGS_WITH_END + else: + rewrite_rule_args = constants.REWRITE_HTTPS_ARGS + + return ("<VirtualHost %s>\n" + "%s \n" + "%s \n" + "ServerSignature Off\n" + "\n" + "RewriteEngine On\n" + "RewriteRule %s\n" + "\n" + "ErrorLog %s/redirect.error.log\n" + "LogLevel warn\n" + "</VirtualHost>\n" + % (" ".join(str(addr) for + addr in self._get_proposed_addrs(ssl_vhost)), + servername, serveralias, + " ".join(rewrite_rule_args), + self.option("logs_root"))) + + def _write_out_redirect(self, ssl_vhost, text): + # This is the default name + redirect_filename = "le-redirect.conf" + + # See if a more appropriate name can be applied + if ssl_vhost.name is not None: + # make sure servername doesn't exceed filename length restriction + if len(ssl_vhost.name) < (255 - (len(redirect_filename) + 1)): + redirect_filename = "le-redirect-%s.conf" % ssl_vhost.name + + redirect_filepath = os.path.join(self.option("vhost_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.reverter.register_file_creation(False, redirect_filepath) + + # Write out file + with open(redirect_filepath, "w") as redirect_file: + redirect_file.write(text) + + # Add new include to configuration if it doesn't exist yet + if not self.parser.parsed_in_current(redirect_filepath): + self.parser.parse_file(redirect_filepath) + + logger.info("Created redirect file: %s", redirect_filename) + + return redirect_filepath + + def _get_http_vhost(self, ssl_vhost): + """Find appropriate HTTP vhost for ssl_vhost.""" + # First candidate vhosts filter + if ssl_vhost.ancestor: + return ssl_vhost.ancestor + candidate_http_vhs = [ + vhost for vhost in self.vhosts if not vhost.ssl + ] + + # Second filter - check addresses + for http_vh in candidate_http_vhs: + if http_vh.same_server(ssl_vhost): + return http_vh + # Third filter - if none with same names, return generic + for http_vh in candidate_http_vhs: + if http_vh.same_server(ssl_vhost, generic=True): + return http_vh + + return None + + def _get_proposed_addrs(self, vhost, port="80"): + """Return all addrs of vhost with the port replaced with the specified. + + :param obj.VirtualHost ssl_vhost: Original Vhost + :param str port: Desired port for new addresses + + :returns: `set` of :class:`~obj.Addr` + + """ + redirects = set() + for addr in vhost.addrs: + redirects.add(addr.get_addr_obj(port)) + + return redirects + + def enable_site(self, vhost): + """Enables an available site, Apache reload required. + + .. note:: Does not make sure that the site correctly works or that all + modules are enabled appropriately. + .. note:: The distribution specific override replaces functionality + of this method where available. + + :param vhost: vhost to enable + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :raises .errors.NotSupportedError: If filesystem layout is not + supported. + + """ + if vhost.enabled: + return + + if not self.parser.parsed_in_original(vhost.filep): + # Add direct include to root conf + logger.info("Enabling site %s by adding Include to root configuration", + vhost.filep) + self.save_notes += "Enabled site %s\n" % vhost.filep + self.parser.add_include(self.parser.loc["default"], vhost.filep) + vhost.enabled = True + return + + def enable_mod(self, mod_name, temp=False): # pylint: disable=unused-argument + """Enables module in Apache. + + Both enables and reloads Apache so module is active. + + :param str mod_name: Name of the module to enable. (e.g. 'ssl') + :param bool temp: Whether or not this is a temporary action. + + .. note:: The distribution specific override replaces functionality + of this method where available. + + :raises .errors.MisconfigurationError: We cannot enable modules in + generic fashion. + + """ + mod_message = ("Apache needs to have module \"{0}\" active for the " + + "requested installation options. Unfortunately Certbot is unable " + + "to install or enable it for you. Please install the module, and " + + "run Certbot again.") + raise errors.MisconfigurationError(mod_message.format(mod_name)) + + def restart(self): + """Runs a config test and reloads the Apache server. + + :raises .errors.MisconfigurationError: If either the config test + or reload fails. + + """ + self.config_test() + self._reload() + + def _reload(self): + """Reloads the Apache server. + + :raises .errors.MisconfigurationError: If reload fails + + """ + try: + util.run_script(self.option("restart_cmd")) + except errors.SubprocessError as err: + logger.info("Unable to restart apache using %s", + self.option("restart_cmd")) + alt_restart = self.option("restart_cmd_alt") + if alt_restart: + logger.debug("Trying alternative restart command: %s", + alt_restart) + # There is an alternative restart command available + # This usually is "restart" verb while original is "graceful" + try: + util.run_script(self.option( + "restart_cmd_alt")) + return + except errors.SubprocessError as secerr: + error = str(secerr) + else: + error = str(err) + raise errors.MisconfigurationError(error) + + def config_test(self): # pylint: disable=no-self-use + """Check the configuration of Apache for errors. + + :raises .errors.MisconfigurationError: If config_test fails + + """ + try: + util.run_script(self.option("conftest_cmd")) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) + + def get_version(self): + """Return version of Apache Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) + + :returns: version + :rtype: tuple + + :raises .PluginError: if unable to find Apache version + + """ + try: + stdout, _ = util.run_script(self.option("version_cmd")) + except errors.SubprocessError: + raise errors.PluginError( + "Unable to run %s -v" % + self.option("version_cmd")) + + regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) + matches = regex.findall(stdout) + + if len(matches) != 1: + raise errors.PluginError("Unable to find Apache version") + + return tuple([int(i) for i in matches[0].split(".")]) + + def more_info(self): + """Human-readable string to help understand the module""" + return ( + "Configures Apache to authenticate and install HTTPS.{0}" + "Server root: {root}{0}" + "Version: {version}".format( + os.linesep, root=self.parser.loc["root"], + version=".".join(str(i) for i in self.version)) + ) + + ########################################################################### + # Challenges Section + ########################################################################### + def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + return [challenges.HTTP01] + + def perform(self, achalls): + """Perform the configuration related challenge. + + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + """ + self._chall_out.update(achalls) + responses = [None] * len(achalls) + http_doer = http_01.ApacheHttp01(self) + + for i, achall in enumerate(achalls): + # Currently also have chall_doer hold associated index of the + # challenge. This helps to put all of the responses back together + # when they are all complete. + http_doer.add_chall(achall, i) + + http_response = http_doer.perform() + if http_response: + # Must reload in order to activate the challenges. + # Handled here because we may be able to load up other challenge + # types + self.restart() + + # TODO: Remove this dirty hack. We need to determine a reliable way + # of identifying when the new configuration is being used. + time.sleep(3) + + self._update_responses(responses, http_response, http_doer) + + return responses + + def _update_responses(self, responses, chall_response, chall_doer): + # Go through all of the challenges and assign them to the proper + # place in the responses return value. All responses must be in the + # same order as the original challenges. + for i, resp in enumerate(chall_response): + responses[chall_doer.indices[i]] = resp + + def cleanup(self, achalls): + """Revert all challenges.""" + self._chall_out.difference_update(achalls) + + # If all of the challenges have been finished, clean up everything + if not self._chall_out: + self.revert_challenge_config() + self.restart() + self.parser.reset_modules() + + def install_ssl_options_conf(self, options_ssl, options_ssl_digest): + """Copy Certbot's SSL options file into the system's config dir if required.""" + + # XXX if we ever try to enforce a local privilege boundary (eg, running + # certbot for unprivileged users via setuid), this function will need + # to be modified. + return common.install_version_controlled_file(options_ssl, options_ssl_digest, + self.option("MOD_SSL_CONF_SRC"), constants.ALL_SSL_OPTIONS_HASHES) + + def enable_autohsts(self, _unused_lineage, domains): + """ + Enable the AutoHSTS enhancement for defined domains + + :param _unused_lineage: Certificate lineage object, unused + :type _unused_lineage: certbot._internal.storage.RenewableCert + + :param domains: List of domains in certificate to enhance + :type domains: str + """ + + self._autohsts_fetch_state() + _enhanced_vhosts = [] + for d in domains: + matched_vhosts = self.choose_vhosts(d, create_if_no_ssl=False) + # We should be handling only SSL vhosts for AutoHSTS + vhosts = [vhost for vhost in matched_vhosts if vhost.ssl] + + if not vhosts: + msg_tmpl = ("Certbot was not able to find SSL VirtualHost for a " + "domain {0} for enabling AutoHSTS enhancement.") + msg = msg_tmpl.format(d) + logger.warning(msg) + raise errors.PluginError(msg) + for vh in vhosts: + try: + self._enable_autohsts_domain(vh) + _enhanced_vhosts.append(vh) + except errors.PluginEnhancementAlreadyPresent: + if vh in _enhanced_vhosts: + continue + msg = ("VirtualHost for domain {0} in file {1} has a " + + "String-Transport-Security header present, exiting.") + raise errors.PluginEnhancementAlreadyPresent( + msg.format(d, vh.filep)) + if _enhanced_vhosts: + note_msg = "Enabling AutoHSTS" + self.save(note_msg) + logger.info(note_msg) + self.restart() + + # Save the current state to pluginstorage + self._autohsts_save_state() + + def _enable_autohsts_domain(self, ssl_vhost): + """Do the initial AutoHSTS deployment to a vhost + + :param ssl_vhost: The VirtualHost object to deploy the AutoHSTS + :type ssl_vhost: :class:`~certbot_apache._internal.obj.VirtualHost` or None + + :raises errors.PluginEnhancementAlreadyPresent: When already enhanced + + """ + # This raises the exception + self._verify_no_matching_http_header(ssl_vhost, + "Strict-Transport-Security") + + if "headers_module" not in self.parser.modules: + self.enable_mod("headers") + # Prepare the HSTS header value + hsts_header = constants.HEADER_ARGS["Strict-Transport-Security"][:-1] + initial_maxage = constants.AUTOHSTS_STEPS[0] + hsts_header.append("\"max-age={0}\"".format(initial_maxage)) + + # Add ID to the VirtualHost for mapping back to it later + uniq_id = self.add_vhost_id(ssl_vhost) + self.save_notes += "Adding unique ID {0} to VirtualHost in {1}\n".format( + uniq_id, ssl_vhost.filep) + # Add the actual HSTS header + self.parser.add_dir(ssl_vhost.path, "Header", hsts_header) + note_msg = ("Adding gradually increasing HSTS header with initial value " + "of {0} to VirtualHost in {1}\n".format( + initial_maxage, ssl_vhost.filep)) + self.save_notes += note_msg + + # Save the current state to pluginstorage + self._autohsts[uniq_id] = {"laststep": 0, "timestamp": time.time()} + + def update_autohsts(self, _unused_domain): + """ + Increase the AutoHSTS values of VirtualHosts that the user has enabled + this enhancement for. + + :param _unused_domain: Not currently used + :type _unused_domain: Not Available + + """ + self._autohsts_fetch_state() + if not self._autohsts: + # No AutoHSTS enabled for any domain + return + curtime = time.time() + save_and_restart = False + for id_str, config in list(self._autohsts.items()): + if config["timestamp"] + constants.AUTOHSTS_FREQ > curtime: + # Skip if last increase was < AUTOHSTS_FREQ ago + continue + nextstep = config["laststep"] + 1 + if nextstep < len(constants.AUTOHSTS_STEPS): + # If installer hasn't been prepared yet, do it now + if not self._prepared: + self.prepare() + # Have not reached the max value yet + try: + vhost = self.find_vhost_by_id(id_str) + except errors.PluginError: + msg = ("Could not find VirtualHost with ID {0}, disabling " + "AutoHSTS for this VirtualHost").format(id_str) + logger.warning(msg) + # Remove the orphaned AutoHSTS entry from pluginstorage + self._autohsts.pop(id_str) + continue + self._autohsts_increase(vhost, id_str, nextstep) + msg = ("Increasing HSTS max-age value for VirtualHost with id " + "{0}").format(id_str) + self.save_notes += msg + save_and_restart = True + + if save_and_restart: + self.save("Increased HSTS max-age values") + self.restart() + + self._autohsts_save_state() + + def deploy_autohsts(self, lineage): + """ + Checks if autohsts vhost has reached maximum auto-increased value + and changes the HSTS max-age to a high value. + + :param lineage: Certificate lineage object + :type lineage: certbot._internal.storage.RenewableCert + """ + self._autohsts_fetch_state() + if not self._autohsts: + # No autohsts enabled for any vhost + return + + vhosts = [] + affected_ids = [] + # Copy, as we are removing from the dict inside the loop + for id_str, config in list(self._autohsts.items()): + if config["laststep"]+1 >= len(constants.AUTOHSTS_STEPS): + # max value reached, try to make permanent + try: + vhost = self.find_vhost_by_id(id_str) + except errors.PluginError: + msg = ("VirtualHost with id {} was not found, unable to " + "make HSTS max-age permanent.").format(id_str) + logger.warning(msg) + self._autohsts.pop(id_str) + continue + if self._autohsts_vhost_in_lineage(vhost, lineage): + vhosts.append(vhost) + affected_ids.append(id_str) + + save_and_restart = False + for vhost in vhosts: + self._autohsts_write(vhost, constants.AUTOHSTS_PERMANENT) + msg = ("Strict-Transport-Security max-age value for " + "VirtualHost in {0} was made permanent.").format(vhost.filep) + logger.debug(msg) + self.save_notes += msg+"\n" + save_and_restart = True + + if save_and_restart: + self.save("Made HSTS max-age permanent") + self.restart() + + for id_str in affected_ids: + self._autohsts.pop(id_str) + + # Update AutoHSTS storage (We potentially removed vhosts from managed) + self._autohsts_save_state() + + +AutoHSTSEnhancement.register(ApacheConfigurator) diff --git a/certbot-apache/certbot_apache/_internal/constants.py b/certbot-apache/certbot_apache/_internal/constants.py new file mode 100644 index 000000000..a37bebac5 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/constants.py @@ -0,0 +1,71 @@ +"""Apache plugin constants.""" +import pkg_resources + +from certbot.compat import os + +MOD_SSL_CONF_DEST = "options-ssl-apache.conf" +"""Name of the mod_ssl config file as saved in `IConfig.config_dir`.""" + + +UPDATED_MOD_SSL_CONF_DIGEST = ".updated-options-ssl-apache-conf-digest.txt" +"""Name of the hash of the updated or informed mod_ssl_conf as saved in `IConfig.config_dir`.""" + +# NEVER REMOVE A SINGLE HASH FROM THIS LIST UNLESS YOU KNOW EXACTLY WHAT YOU ARE DOING! +ALL_SSL_OPTIONS_HASHES = [ + '2086bca02db48daf93468332543c60ac6acdb6f0b58c7bfdf578a5d47092f82a', + '4844d36c9a0f587172d9fa10f4f1c9518e3bcfa1947379f155e16a70a728c21a', + '5a922826719981c0a234b1fbcd495f3213e49d2519e845ea0748ba513044b65b', + '4066b90268c03c9ba0201068eaa39abbc02acf9558bb45a788b630eb85dadf27', + 'f175e2e7c673bd88d0aff8220735f385f916142c44aa83b09f1df88dd4767a88', + 'cfdd7c18d2025836ea3307399f509cfb1ebf2612c87dd600a65da2a8e2f2797b', + '80720bd171ccdc2e6b917ded340defae66919e4624962396b992b7218a561791', + 'c0c022ea6b8a51ecc8f1003d0a04af6c3f2bc1c3ce506b3c2dfc1f11ef931082', + '717b0a89f5e4c39b09a42813ac6e747cfbdeb93439499e73f4f70a1fe1473f20', + '0fcdc81280cd179a07ec4d29d3595068b9326b455c488de4b09f585d5dafc137', + '86cc09ad5415cd6d5f09a947fe2501a9344328b1e8a8b458107ea903e80baa6c', + '06675349e457eae856120cdebb564efe546f0b87399f2264baeb41e442c724c7', + '5cc003edd93fb9cd03d40c7686495f8f058f485f75b5e764b789245a386e6daf', + '007cd497a56a3bb8b6a2c1aeb4997789e7e38992f74e44cc5d13a625a738ac73', +] +"""SHA256 hashes of the contents of previous versions of all versions of MOD_SSL_CONF_SRC""" + +AUGEAS_LENS_DIR = pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "augeas_lens")) +"""Path to the Augeas lens directory""" + +REWRITE_HTTPS_ARGS = [ + "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,NE,R=permanent]"] +"""Apache version<2.3.9 rewrite rule arguments used for redirections to +https vhost""" + +REWRITE_HTTPS_ARGS_WITH_END = [ + "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[END,NE,R=permanent]"] +"""Apache version >= 2.3.9 rewrite rule arguments used for redirections to + https vhost""" + +OLD_REWRITE_HTTPS_ARGS = [ + ["^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"], + ["^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[END,QSA,R=permanent]"]] + +HSTS_ARGS = ["always", "set", "Strict-Transport-Security", + "\"max-age=31536000\""] +"""Apache header arguments for HSTS""" + +UIR_ARGS = ["always", "set", "Content-Security-Policy", + "upgrade-insecure-requests"] + +HEADER_ARGS = {"Strict-Transport-Security": HSTS_ARGS, + "Upgrade-Insecure-Requests": UIR_ARGS} + +AUTOHSTS_STEPS = [60, 300, 900, 3600, 21600, 43200, 86400] +"""AutoHSTS increase steps: 1min, 5min, 15min, 1h, 6h, 12h, 24h""" + +AUTOHSTS_PERMANENT = 31536000 +"""Value for the last max-age of HSTS""" + +AUTOHSTS_FREQ = 172800 +"""Minimum time since last increase to perform a new one: 48h""" + +MANAGED_COMMENT = "DO NOT REMOVE - Managed by Certbot" +MANAGED_COMMENT_ID = MANAGED_COMMENT+", VirtualHost id: {0}" +"""Managed by Certbot comments and the VirtualHost identification template""" diff --git a/certbot-apache/certbot_apache/_internal/display_ops.py b/certbot-apache/certbot_apache/_internal/display_ops.py new file mode 100644 index 000000000..1ae32bb47 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/display_ops.py @@ -0,0 +1,125 @@ +"""Contains UI methods for Apache operations.""" +import logging + +import zope.component + +from certbot import errors +from certbot import interfaces +from certbot.compat import os +import certbot.display.util as display_util + +logger = logging.getLogger(__name__) + + +def select_vhost_multiple(vhosts): + """Select multiple Vhosts to install the certificate for + + :param vhosts: Available Apache VirtualHosts + :type vhosts: :class:`list` of type `~obj.Vhost` + + :returns: List of VirtualHosts + :rtype: :class:`list`of type `~obj.Vhost` + """ + if not vhosts: + return list() + tags_list = [vhost.display_repr()+"\n" for vhost in vhosts] + # Remove the extra newline from the last entry + if tags_list: + tags_list[-1] = tags_list[-1][:-1] + code, names = zope.component.getUtility(interfaces.IDisplay).checklist( + "Which VirtualHosts would you like to install the wildcard certificate for?", + tags=tags_list, force_interactive=True) + if code == display_util.OK: + return_vhosts = _reversemap_vhosts(names, vhosts) + return return_vhosts + return [] + +def _reversemap_vhosts(names, vhosts): + """Helper function for select_vhost_multiple for mapping string + representations back to actual vhost objects""" + return_vhosts = list() + + for selection in names: + for vhost in vhosts: + if vhost.display_repr().strip() == selection.strip(): + return_vhosts.append(vhost) + return return_vhosts + +def select_vhost(domain, vhosts): + """Select an appropriate Apache Vhost. + + :param vhosts: Available Apache VirtualHosts + :type vhosts: :class:`list` of type `~obj.Vhost` + + :returns: VirtualHost or `None` + :rtype: `~obj.Vhost` or `None` + + """ + if not vhosts: + return None + code, tag = _vhost_menu(domain, vhosts) + if code == display_util.OK: + return vhosts[tag] + return None + +def _vhost_menu(domain, vhosts): + """Select an appropriate Apache Vhost. + + :param vhosts: Available Apache Virtual Hosts + :type vhosts: :class:`list` of type `~obj.Vhost` + + :returns: Display tuple - ('code', tag') + :rtype: `tuple` + + """ + # Free characters in the line of display text (9 is for ' | ' formatting) + free_chars = display_util.WIDTH - len("HTTPS") - len("Enabled") - 9 + + if free_chars < 2: + logger.debug("Display size is too small for " + "certbot_apache._internal.display_ops._vhost_menu()") + # This runs the edge off the screen, but it doesn't cause an "error" + filename_size = 1 + disp_name_size = 1 + else: + # Filename is a bit more important and probably longer with 000-* + filename_size = int(free_chars * .6) + disp_name_size = free_chars - filename_size + + choices = [] + for vhost in vhosts: + if len(vhost.get_names()) == 1: + disp_name = next(iter(vhost.get_names())) + elif not vhost.get_names(): + disp_name = "" + else: + disp_name = "Multiple Names" + + choices.append( + "{fn:{fn_size}s} | {name:{name_size}s} | {https:5s} | " + "{active:7s}".format( + fn=os.path.basename(vhost.filep)[:filename_size], + name=disp_name[:disp_name_size], + https="HTTPS" if vhost.ssl else "", + active="Enabled" if vhost.enabled else "", + fn_size=filename_size, + name_size=disp_name_size) + ) + + try: + code, tag = zope.component.getUtility(interfaces.IDisplay).menu( + "We were unable to find a vhost with a ServerName " + "or Address of {0}.{1}Which virtual host would you " + "like to choose?".format(domain, os.linesep), + choices, force_interactive=True) + except errors.MissingCommandlineFlag: + msg = ( + "Encountered vhost ambiguity when trying to find a vhost for " + "{0} but was unable to ask for user " + "guidance in non-interactive mode. Certbot may need " + "vhosts to be explicitly labelled with ServerName or " + "ServerAlias directives.".format(domain)) + logger.warning(msg) + raise errors.MissingCommandlineFlag(msg) + + return code, tag diff --git a/certbot-apache/certbot_apache/_internal/entrypoint.py b/certbot-apache/certbot_apache/_internal/entrypoint.py new file mode 100644 index 000000000..d43094976 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/entrypoint.py @@ -0,0 +1,68 @@ +""" Entry point for Apache Plugin """ +# Pylint does not like disutils.version when running inside a venv. +# See: https://github.com/PyCQA/pylint/issues/73 +from distutils.version import LooseVersion # pylint: disable=no-name-in-module,import-error + +from certbot import util +from certbot_apache._internal import configurator +from certbot_apache._internal import override_arch +from certbot_apache._internal import override_centos +from certbot_apache._internal import override_darwin +from certbot_apache._internal import override_debian +from certbot_apache._internal import override_fedora +from certbot_apache._internal import override_gentoo +from certbot_apache._internal import override_suse + +OVERRIDE_CLASSES = { + "arch": override_arch.ArchConfigurator, + "cloudlinux": override_centos.CentOSConfigurator, + "darwin": override_darwin.DarwinConfigurator, + "debian": override_debian.DebianConfigurator, + "ubuntu": override_debian.DebianConfigurator, + "centos": override_centos.CentOSConfigurator, + "centos linux": override_centos.CentOSConfigurator, + "fedora_old": override_centos.CentOSConfigurator, + "fedora": override_fedora.FedoraConfigurator, + "linuxmint": override_debian.DebianConfigurator, + "ol": override_centos.CentOSConfigurator, + "oracle": override_centos.CentOSConfigurator, + "redhatenterpriseserver": override_centos.CentOSConfigurator, + "red hat enterprise linux server": override_centos.CentOSConfigurator, + "rhel": override_centos.CentOSConfigurator, + "amazon": override_centos.CentOSConfigurator, + "gentoo": override_gentoo.GentooConfigurator, + "gentoo base system": override_gentoo.GentooConfigurator, + "opensuse": override_suse.OpenSUSEConfigurator, + "suse": override_suse.OpenSUSEConfigurator, + "sles": override_suse.OpenSUSEConfigurator, + "scientific": override_centos.CentOSConfigurator, + "scientific linux": override_centos.CentOSConfigurator, +} + + +def get_configurator(): + """ Get correct configurator class based on the OS fingerprint """ + os_name, os_version = util.get_os_info() + os_name = os_name.lower() + override_class = None + + # Special case for older Fedora versions + if os_name == 'fedora' and LooseVersion(os_version) < LooseVersion('29'): + os_name = 'fedora_old' + + try: + override_class = OVERRIDE_CLASSES[os_name] + except KeyError: + # OS not found in the list + os_like = util.get_systemd_os_like() + if os_like: + for os_name in os_like: + if os_name in OVERRIDE_CLASSES.keys(): + override_class = OVERRIDE_CLASSES[os_name] + if not override_class: + # No override class found, return the generic configurator + override_class = configurator.ApacheConfigurator + return override_class + + +ENTRYPOINT = get_configurator() diff --git a/certbot-apache/certbot_apache/_internal/http_01.py b/certbot-apache/certbot_apache/_internal/http_01.py new file mode 100644 index 000000000..c34abc2b4 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/http_01.py @@ -0,0 +1,209 @@ +"""A class that performs HTTP-01 challenges for Apache""" +import logging + +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module +from certbot import errors +from certbot.compat import filesystem +from certbot.compat import os +from certbot.plugins import common +from certbot_apache._internal.obj import VirtualHost # pylint: disable=unused-import +from certbot_apache._internal.parser import get_aug_path + +logger = logging.getLogger(__name__) + + +class ApacheHttp01(common.ChallengePerformer): + """Class that performs HTTP-01 challenges within the Apache configurator.""" + + CONFIG_TEMPLATE22_PRE = """\ + RewriteEngine on + RewriteRule ^/\\.well-known/acme-challenge/([A-Za-z0-9-_=]+)$ {0}/$1 [L] + + """ + CONFIG_TEMPLATE22_POST = """\ + <Directory {0}> + Order Allow,Deny + Allow from all + </Directory> + <Location /.well-known/acme-challenge> + Order Allow,Deny + Allow from all + </Location> + """ + + CONFIG_TEMPLATE24_PRE = """\ + RewriteEngine on + RewriteRule ^/\\.well-known/acme-challenge/([A-Za-z0-9-_=]+)$ {0}/$1 [END] + """ + CONFIG_TEMPLATE24_POST = """\ + <Directory {0}> + Require all granted + </Directory> + <Location /.well-known/acme-challenge> + Require all granted + </Location> + """ + + def __init__(self, *args, **kwargs): + super(ApacheHttp01, self).__init__(*args, **kwargs) + self.challenge_conf_pre = os.path.join( + self.configurator.conf("challenge-location"), + "le_http_01_challenge_pre.conf") + self.challenge_conf_post = os.path.join( + self.configurator.conf("challenge-location"), + "le_http_01_challenge_post.conf") + self.challenge_dir = os.path.join( + self.configurator.config.work_dir, + "http_challenges") + self.moded_vhosts = set() # type: Set[VirtualHost] + + def perform(self): + """Perform all HTTP-01 challenges.""" + if not self.achalls: + return [] + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.configurator.save("Changes before challenge setup", True) + + self.configurator.ensure_listen(str( + self.configurator.config.http01_port)) + self.prepare_http01_modules() + + responses = self._set_up_challenges() + + self._mod_config() + # Save reversible changes + self.configurator.save("HTTP Challenge", True) + + return responses + + def prepare_http01_modules(self): + """Make sure that we have the needed modules available for http01""" + + if self.configurator.conf("handle-modules"): + needed_modules = ["rewrite"] + if self.configurator.version < (2, 4): + needed_modules.append("authz_host") + else: + needed_modules.append("authz_core") + for mod in needed_modules: + if mod + "_module" not in self.configurator.parser.modules: + self.configurator.enable_mod(mod, temp=True) + + def _mod_config(self): + selected_vhosts = [] # type: List[VirtualHost] + http_port = str(self.configurator.config.http01_port) + for chall in self.achalls: + # Search for matching VirtualHosts + for vh in self._matching_vhosts(chall.domain): + selected_vhosts.append(vh) + + # Ensure that we have one or more VirtualHosts that we can continue + # with. (one that listens to port configured with --http-01-port) + found = False + for vhost in selected_vhosts: + if any(a.is_wildcard() or a.get_port() == http_port for a in vhost.addrs): + found = True + + if not found: + for vh in self._relevant_vhosts(): + selected_vhosts.append(vh) + + # Add the challenge configuration + for vh in selected_vhosts: + self._set_up_include_directives(vh) + + self.configurator.reverter.register_file_creation( + True, self.challenge_conf_pre) + self.configurator.reverter.register_file_creation( + True, self.challenge_conf_post) + + if self.configurator.version < (2, 4): + config_template_pre = self.CONFIG_TEMPLATE22_PRE + config_template_post = self.CONFIG_TEMPLATE22_POST + else: + config_template_pre = self.CONFIG_TEMPLATE24_PRE + config_template_post = self.CONFIG_TEMPLATE24_POST + + config_text_pre = config_template_pre.format(self.challenge_dir) + config_text_post = config_template_post.format(self.challenge_dir) + + logger.debug("writing a pre config file with text:\n %s", config_text_pre) + with open(self.challenge_conf_pre, "w") as new_conf: + new_conf.write(config_text_pre) + logger.debug("writing a post config file with text:\n %s", config_text_post) + with open(self.challenge_conf_post, "w") as new_conf: + new_conf.write(config_text_post) + + def _matching_vhosts(self, domain): + """Return all VirtualHost objects that have the requested domain name or + a wildcard name that would match the domain in ServerName or ServerAlias + directive. + """ + matching_vhosts = [] + for vhost in self.configurator.vhosts: + if self.configurator.domain_in_names(vhost.get_names(), domain): + # domain_in_names also matches the exact names, so no need + # to check "domain in vhost.get_names()" explicitly here + matching_vhosts.append(vhost) + + return matching_vhosts + + def _relevant_vhosts(self): + http01_port = str(self.configurator.config.http01_port) + relevant_vhosts = [] + for vhost in self.configurator.vhosts: + if any(a.is_wildcard() or a.get_port() == http01_port for a in vhost.addrs): + if not vhost.ssl: + relevant_vhosts.append(vhost) + if not relevant_vhosts: + raise errors.PluginError( + "Unable to find a virtual host listening on port {0} which is" + " currently needed for Certbot to prove to the CA that you" + " control your domain. Please add a virtual host for port" + " {0}.".format(http01_port)) + + return relevant_vhosts + + def _set_up_challenges(self): + if not os.path.isdir(self.challenge_dir): + filesystem.makedirs(self.challenge_dir, 0o755) + + responses = [] + for achall in self.achalls: + responses.append(self._set_up_challenge(achall)) + + return responses + + def _set_up_challenge(self, achall): + response, validation = achall.response_and_validation() + + name = os.path.join(self.challenge_dir, achall.chall.encode("token")) + + self.configurator.reverter.register_file_creation(True, name) + with open(name, 'wb') as f: + f.write(validation.encode()) + filesystem.chmod(name, 0o644) + + return response + + def _set_up_include_directives(self, vhost): + """Includes override configuration to the beginning and to the end of + VirtualHost. Note that this include isn't added to Augeas search tree""" + + if vhost not in self.moded_vhosts: + logger.debug( + "Adding a temporary challenge validation Include for name: %s in: %s", + vhost.name, vhost.filep) + self.configurator.parser.add_dir_beginning( + vhost.path, "Include", self.challenge_conf_pre) + self.configurator.parser.add_dir( + vhost.path, "Include", self.challenge_conf_post) + + if not vhost.enabled: + self.configurator.parser.add_dir( + get_aug_path(self.configurator.parser.loc["default"]), + "Include", vhost.filep) + + self.moded_vhosts.add(vhost) diff --git a/certbot-apache/certbot_apache/_internal/obj.py b/certbot-apache/certbot_apache/_internal/obj.py new file mode 100644 index 000000000..8b3aeb376 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/obj.py @@ -0,0 +1,269 @@ +"""Module contains classes used by the Apache Configurator.""" +import re + +from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module +from certbot.plugins import common + + +class Addr(common.Addr): + """Represents an Apache address.""" + + def __eq__(self, other): + """This is defined as equivalent within Apache. + + ip_addr:* == ip_addr + + """ + if isinstance(other, self.__class__): + return ((self.tup == other.tup) or + (self.tup[0] == other.tup[0] and + self.is_wildcard() and other.is_wildcard())) + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "certbot_apache._internal.obj.Addr(" + repr(self.tup) + ")" + + def __hash__(self): # pylint: disable=useless-super-delegation + # Python 3 requires explicit overridden for __hash__ if __eq__ or + # __cmp__ is overridden. See https://bugs.python.org/issue2235 + return super(Addr, self).__hash__() + + def _addr_less_specific(self, addr): + """Returns if addr.get_addr() is more specific than self.get_addr().""" + # pylint: disable=protected-access + return addr._rank_specific_addr() > self._rank_specific_addr() + + def _rank_specific_addr(self): + """Returns numerical rank for get_addr() + + :returns: 2 - FQ, 1 - wildcard, 0 - _default_ + :rtype: int + + """ + if self.get_addr() == "_default_": + return 0 + elif self.get_addr() == "*": + return 1 + return 2 + + def conflicts(self, addr): + r"""Returns if address could conflict with correct function of self. + + Could addr take away service provided by self within Apache? + + .. note::IP Address is more important than wildcard. + Connection from 127.0.0.1:80 with choices of *:80 and 127.0.0.1:* + chooses 127.0.0.1:\* + + .. todo:: Handle domain name addrs... + + Examples: + + ========================================= ===== + ``127.0.0.1:\*.conflicts(127.0.0.1:443)`` True + ``127.0.0.1:443.conflicts(127.0.0.1:\*)`` False + ``\*:443.conflicts(\*:80)`` False + ``_default_:443.conflicts(\*:443)`` True + ========================================= ===== + + """ + if self._addr_less_specific(addr): + return True + elif self.get_addr() == addr.get_addr(): + if self.is_wildcard() or self.get_port() == addr.get_port(): + return True + return False + + def is_wildcard(self): + """Returns if address has a wildcard port.""" + return self.tup[1] == "*" or not self.tup[1] + + def get_sni_addr(self, port): + """Returns the least specific address that resolves on the port. + + Examples: + + - ``1.2.3.4:443`` -> ``1.2.3.4:<port>`` + - ``1.2.3.4:*`` -> ``1.2.3.4:*`` + + :param str port: Desired port + + """ + if self.is_wildcard(): + return self + + return self.get_addr_obj(port) + + +class VirtualHost(object): + """Represents an Apache Virtualhost. + + :ivar str filep: file path of VH + :ivar str path: Augeas path to virtual host + :ivar set addrs: Virtual Host addresses (:class:`set` of + :class:`common.Addr`) + :ivar str name: ServerName of VHost + :ivar list aliases: Server aliases of vhost + (:class:`list` of :class:`str`) + + :ivar bool ssl: SSLEngine on in vhost + :ivar bool enabled: Virtual host is enabled + :ivar bool modmacro: VirtualHost is using mod_macro + :ivar VirtualHost ancestor: A non-SSL VirtualHost this is based on + + https://httpd.apache.org/docs/2.4/vhosts/details.html + + .. todo:: Any vhost that includes the magic _default_ wildcard is given the + same ServerName as the main server. + + """ + # ?: is used for not returning enclosed characters + strip_name = re.compile(r"^(?:.+://)?([^ :$]*)") + + def __init__(self, filep, path, addrs, ssl, enabled, name=None, + aliases=None, modmacro=False, ancestor=None): + + """Initialize a VH.""" + self.filep = filep + self.path = path + self.addrs = addrs + self.name = name + self.aliases = aliases if aliases is not None else set() + self.ssl = ssl + self.enabled = enabled + self.modmacro = modmacro + self.ancestor = ancestor + + def get_names(self): + """Return a set of all names.""" + all_names = set() # type: Set[str] + all_names.update(self.aliases) + # Strip out any scheme:// and <port> field from servername + if self.name is not None: + all_names.add(VirtualHost.strip_name.findall(self.name)[0]) + + return all_names + + def __str__(self): + return ( + "File: {filename}\n" + "Vhost path: {vhpath}\n" + "Addresses: {addrs}\n" + "Name: {name}\n" + "Aliases: {aliases}\n" + "TLS Enabled: {tls}\n" + "Site Enabled: {active}\n" + "mod_macro Vhost: {modmacro}".format( + filename=self.filep, + vhpath=self.path, + addrs=", ".join(str(addr) for addr in self.addrs), + name=self.name if self.name is not None else "", + aliases=", ".join(name for name in self.aliases), + tls="Yes" if self.ssl else "No", + active="Yes" if self.enabled else "No", + modmacro="Yes" if self.modmacro else "No")) + + def display_repr(self): + """Return a representation of VHost to be used in dialog""" + return ( + "File: {filename}\n" + "Addresses: {addrs}\n" + "Names: {names}\n" + "HTTPS: {https}\n".format( + filename=self.filep, + addrs=", ".join(str(addr) for addr in self.addrs), + names=", ".join(self.get_names()), + https="Yes" if self.ssl else "No")) + + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (self.filep == other.filep and self.path == other.path and + self.addrs == other.addrs and + self.get_names() == other.get_names() and + self.ssl == other.ssl and + self.enabled == other.enabled and + self.modmacro == other.modmacro) + + return False + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash((self.filep, self.path, + tuple(self.addrs), tuple(self.get_names()), + self.ssl, self.enabled, self.modmacro)) + + def conflicts(self, addrs): + """See if vhost conflicts with any of the addrs. + + This determines whether or not these addresses would/could overwrite + the vhost addresses. + + :param addrs: Iterable Addresses + :type addrs: Iterable :class:~obj.Addr + + :returns: If addresses conflicts with vhost + :rtype: bool + + """ + for pot_addr in addrs: + for addr in self.addrs: + if addr.conflicts(pot_addr): + return True + return False + + def same_server(self, vhost, generic=False): + """Determines if the vhost is the same 'server'. + + Used in redirection - indicates whether or not the two virtual hosts + serve on the exact same IP combinations, but different ports. + The generic flag indicates that that we're trying to match to a + default or generic vhost + + .. todo:: Handle _default_ + + """ + + if not generic: + if vhost.get_names() != self.get_names(): + return False + + # If equal and set is not empty... assume same server + if self.name is not None or self.aliases: + return True + # If we're looking for a generic vhost, + # don't return one with a ServerName + elif self.name: + return False + + # Both sets of names are empty. + + # Make conservative educated guess... this is very restrictive + # Consider adding more safety checks. + if len(vhost.addrs) != len(self.addrs): + return False + + # already_found acts to keep everything very conservative. + # Don't allow multiple ip:ports in same set. + already_found = set() # type: Set[str] + + for addr in vhost.addrs: + for local_addr in self.addrs: + if (local_addr.get_addr() == addr.get_addr() and + local_addr != addr and + local_addr.get_addr() not in already_found): + + # This intends to make sure we aren't double counting... + # e.g. 127.0.0.1:* - We require same number of addrs + # currently + already_found.add(local_addr.get_addr()) + break + else: + return False + + return True diff --git a/certbot-apache/certbot_apache/_internal/options-ssl-apache.conf b/certbot-apache/certbot_apache/_internal/options-ssl-apache.conf new file mode 100644 index 000000000..1a3799628 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/options-ssl-apache.conf @@ -0,0 +1,18 @@ +# This file contains important security parameters. If you modify this file +# manually, Certbot will be unable to automatically provide future security +# updates. Instead, Certbot will print and log an error message with a path to +# the up-to-date file that you will need to refer to when manually updating +# this file. + +SSLEngine on + +# Intermediate configuration, tweak to your needs +SSLProtocol all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1 +SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 +SSLHonorCipherOrder off + +SSLOptions +StrictRequire + +# Add vhost name to log entries: +LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" vhost_combined +LogFormat "%v %h %l %u %t \"%r\" %>s %b" vhost_common diff --git a/certbot-apache/certbot_apache/_internal/override_arch.py b/certbot-apache/certbot_apache/_internal/override_arch.py new file mode 100644 index 000000000..2765bd238 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_arch.py @@ -0,0 +1,31 @@ +""" Distribution specific override class for Arch Linux """ +import pkg_resources +import zope.interface + +from certbot import interfaces +from certbot.compat import os +from certbot_apache._internal import configurator + + +@zope.interface.provider(interfaces.IPluginFactory) +class ArchConfigurator(configurator.ApacheConfigurator): + """Arch Linux specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/httpd", + vhost_root="/etc/httpd/conf", + vhost_files="*.conf", + logs_root="/var/log/httpd", + ctl="apachectl", + version_cmd=['apachectl', '-v'], + restart_cmd=['apachectl', 'graceful'], + conftest_cmd=['apachectl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/httpd/conf", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) diff --git a/certbot-apache/certbot_apache/_internal/override_centos.py b/certbot-apache/certbot_apache/_internal/override_centos.py new file mode 100644 index 000000000..a3ef2d760 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_centos.py @@ -0,0 +1,215 @@ +""" Distribution specific override class for CentOS family (RHEL, Fedora) """ +import logging + +import pkg_resources +import zope.interface + +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module +from certbot import errors +from certbot import interfaces +from certbot import util +from certbot.compat import os +from certbot.errors import MisconfigurationError +from certbot_apache._internal import apache_util +from certbot_apache._internal import configurator +from certbot_apache._internal import parser + +logger = logging.getLogger(__name__) + + +@zope.interface.provider(interfaces.IPluginFactory) +class CentOSConfigurator(configurator.ApacheConfigurator): + """CentOS specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/httpd", + vhost_root="/etc/httpd/conf.d", + vhost_files="*.conf", + logs_root="/var/log/httpd", + ctl="apachectl", + version_cmd=['apachectl', '-v'], + restart_cmd=['apachectl', 'graceful'], + restart_cmd_alt=['apachectl', 'restart'], + conftest_cmd=['apachectl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/httpd/conf.d", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) + + def config_test(self): + """ + Override config_test to mitigate configtest error in vanilla installation + of mod_ssl in Fedora. The error is caused by non-existent self-signed + certificates referenced by the configuration, that would be autogenerated + during the first (re)start of httpd. + """ + + os_info = util.get_os_info() + fedora = os_info[0].lower() == "fedora" + + try: + super(CentOSConfigurator, self).config_test() + except errors.MisconfigurationError: + if fedora: + self._try_restart_fedora() + else: + raise + + def _try_restart_fedora(self): + """ + Tries to restart httpd using systemctl to generate the self signed keypair. + """ + + try: + util.run_script(['systemctl', 'restart', 'httpd']) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) + + # Finish with actual config check to see if systemctl restart helped + super(CentOSConfigurator, self).config_test() + + def _prepare_options(self): + """ + Override the options dictionary initialization in order to support + alternative restart cmd used in CentOS. + """ + super(CentOSConfigurator, self)._prepare_options() + self.options["restart_cmd_alt"][0] = self.option("ctl") + + def get_parser(self): + """Initializes the ApacheParser""" + return CentOSParser( + self.option("server_root"), self.option("vhost_root"), + self.version, configurator=self) + + def _deploy_cert(self, *args, **kwargs): # pylint: disable=arguments-differ + """ + Override _deploy_cert in order to ensure that the Apache configuration + has "LoadModule ssl_module..." before parsing the VirtualHost configuration + that was created by Certbot + """ + super(CentOSConfigurator, self)._deploy_cert(*args, **kwargs) + if self.version < (2, 4, 0): + self._deploy_loadmodule_ssl_if_needed() + + def _deploy_loadmodule_ssl_if_needed(self): + """ + Add "LoadModule ssl_module <pre-existing path>" to main httpd.conf if + it doesn't exist there already. + """ + + loadmods = self.parser.find_dir("LoadModule", "ssl_module", exclude=False) + + correct_ifmods = [] # type: List[str] + loadmod_args = [] # type: List[str] + loadmod_paths = [] # type: List[str] + for m in loadmods: + noarg_path = m.rpartition("/")[0] + path_args = self.parser.get_all_args(noarg_path) + if loadmod_args: + if loadmod_args != path_args: + msg = ("Certbot encountered multiple LoadModule directives " + "for LoadModule ssl_module with differing library paths. " + "Please remove or comment out the one(s) that are not in " + "use, and run Certbot again.") + raise MisconfigurationError(msg) + else: + loadmod_args = path_args + + if self.parser.not_modssl_ifmodule(noarg_path): # pylint: disable=no-member + if self.parser.loc["default"] in noarg_path: + # LoadModule already in the main configuration file + if ("ifmodule/" in noarg_path.lower() or + "ifmodule[1]" in noarg_path.lower()): + # It's the first or only IfModule in the file + return + # Populate the list of known !mod_ssl.c IfModules + nodir_path = noarg_path.rpartition("/directive")[0] + correct_ifmods.append(nodir_path) + else: + loadmod_paths.append(noarg_path) + + if not loadmod_args: + # Do not try to enable mod_ssl + return + + # Force creation as the directive wasn't found from the beginning of + # httpd.conf + rootconf_ifmod = self.parser.create_ifmod( + parser.get_aug_path(self.parser.loc["default"]), + "!mod_ssl.c", beginning=True) + # parser.get_ifmod returns a path postfixed with "/", remove that + self.parser.add_dir(rootconf_ifmod[:-1], "LoadModule", loadmod_args) + correct_ifmods.append(rootconf_ifmod[:-1]) + self.save_notes += "Added LoadModule ssl_module to main configuration.\n" + + # Wrap LoadModule mod_ssl inside of <IfModule !mod_ssl.c> if it's not + # configured like this already. + for loadmod_path in loadmod_paths: + nodir_path = loadmod_path.split("/directive")[0] + # Remove the old LoadModule directive + self.parser.aug.remove(loadmod_path) + + # Create a new IfModule !mod_ssl.c if not already found on path + ssl_ifmod = self.parser.get_ifmod(nodir_path, "!mod_ssl.c", + beginning=True)[:-1] + if ssl_ifmod not in correct_ifmods: + self.parser.add_dir(ssl_ifmod, "LoadModule", loadmod_args) + correct_ifmods.append(ssl_ifmod) + self.save_notes += ("Wrapped pre-existing LoadModule ssl_module " + "inside of <IfModule !mod_ssl> block.\n") + + +class CentOSParser(parser.ApacheParser): + """CentOS specific ApacheParser override class""" + def __init__(self, *args, **kwargs): + # CentOS specific configuration file for Apache + self.sysconfig_filep = "/etc/sysconfig/httpd" + super(CentOSParser, self).__init__(*args, **kwargs) + + def update_runtime_variables(self): + """ Override for update_runtime_variables for custom parsing """ + # Opportunistic, works if SELinux not enforced + super(CentOSParser, self).update_runtime_variables() + self.parse_sysconfig_var() + + def parse_sysconfig_var(self): + """ Parses Apache CLI options from CentOS configuration file """ + defines = apache_util.parse_define_file(self.sysconfig_filep, "OPTIONS") + for k in defines: + self.variables[k] = defines[k] + + def not_modssl_ifmodule(self, path): + """Checks if the provided Augeas path has argument !mod_ssl""" + + if "ifmodule" not in path.lower(): + return False + + # Trim the path to the last ifmodule + workpath = path.lower() + while workpath: + # Get path to the last IfModule (ignore the tail) + parts = workpath.rpartition("ifmodule") + + if not parts[0]: + # IfModule not found + break + ifmod_path = parts[0] + parts[1] + # Check if ifmodule had an index + if parts[2].startswith("["): + # Append the index from tail + ifmod_path += parts[2].partition("/")[0] + # Get the original path trimmed to correct length + # This is required to preserve cases + ifmod_real_path = path[0:len(ifmod_path)] + if "!mod_ssl.c" in self.get_all_args(ifmod_real_path): + return True + # Set the workpath to the heading part + workpath = parts[0] + + return False diff --git a/certbot-apache/certbot_apache/_internal/override_darwin.py b/certbot-apache/certbot_apache/_internal/override_darwin.py new file mode 100644 index 000000000..00faff623 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_darwin.py @@ -0,0 +1,31 @@ +""" Distribution specific override class for macOS """ +import pkg_resources +import zope.interface + +from certbot import interfaces +from certbot.compat import os +from certbot_apache._internal import configurator + + +@zope.interface.provider(interfaces.IPluginFactory) +class DarwinConfigurator(configurator.ApacheConfigurator): + """macOS specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/other", + vhost_files="*.conf", + logs_root="/var/log/apache2", + ctl="apachectl", + version_cmd=['apachectl', '-v'], + restart_cmd=['apachectl', 'graceful'], + conftest_cmd=['apachectl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/apache2/other", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) diff --git a/certbot-apache/certbot_apache/_internal/override_debian.py b/certbot-apache/certbot_apache/_internal/override_debian.py new file mode 100644 index 000000000..77ced6a3f --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_debian.py @@ -0,0 +1,144 @@ +""" Distribution specific override class for Debian family (Ubuntu/Debian) """ +import logging + +import pkg_resources +import zope.interface + +from certbot import errors +from certbot import interfaces +from certbot import util +from certbot.compat import filesystem +from certbot.compat import os +from certbot_apache._internal import apache_util +from certbot_apache._internal import configurator + +logger = logging.getLogger(__name__) + + +@zope.interface.provider(interfaces.IPluginFactory) +class DebianConfigurator(configurator.ApacheConfigurator): + """Debian specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/sites-available", + vhost_files="*", + logs_root="/var/log/apache2", + ctl="apache2ctl", + version_cmd=['apache2ctl', '-v'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod="a2enmod", + dismod="a2dismod", + le_vhost_ext="-le-ssl.conf", + handle_modules=True, + handle_sites=True, + challenge_location="/etc/apache2", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) + + def enable_site(self, vhost): + """Enables an available site, Apache reload required. + + .. note:: Does not make sure that the site correctly works or that all + modules are enabled appropriately. + + :param vhost: vhost to enable + :type vhost: :class:`~certbot_apache._internal.obj.VirtualHost` + + :raises .errors.NotSupportedError: If filesystem layout is not + supported. + + """ + if vhost.enabled: + return None + + enabled_path = ("%s/sites-enabled/%s" % + (self.parser.root, + os.path.basename(vhost.filep))) + if not os.path.isdir(os.path.dirname(enabled_path)): + # For some reason, sites-enabled / sites-available do not exist + # Call the parent method + return super(DebianConfigurator, self).enable_site(vhost) + self.reverter.register_file_creation(False, enabled_path) + try: + os.symlink(vhost.filep, enabled_path) + except OSError as err: + if os.path.islink(enabled_path) and filesystem.realpath( + enabled_path) == vhost.filep: + # Already in shape + vhost.enabled = True + return None + logger.warning( + "Could not symlink %s to %s, got error: %s", enabled_path, + vhost.filep, err.strerror) + errstring = ("Encountered error while trying to enable a " + + "newly created VirtualHost located at {0} by " + + "linking to it from {1}") + raise errors.NotSupportedError(errstring.format(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 None + + def enable_mod(self, mod_name, temp=False): + """Enables module in Apache. + + Both enables and reloads Apache so module is active. + + :param str mod_name: Name of the module to enable. (e.g. 'ssl') + :param bool temp: Whether or not this is a temporary action. + + :raises .errors.NotSupportedError: If the filesystem layout is not + supported. + :raises .errors.MisconfigurationError: If a2enmod or a2dismod cannot be + run. + + """ + avail_path = os.path.join(self.parser.root, "mods-available") + enabled_path = os.path.join(self.parser.root, "mods-enabled") + if not os.path.isdir(avail_path) or not os.path.isdir(enabled_path): + raise errors.NotSupportedError( + "Unsupported directory layout. You may try to enable mod %s " + "and try again." % mod_name) + + deps = apache_util.get_mod_deps(mod_name) + + # Enable all dependencies + for dep in deps: + if (dep + "_module") not in self.parser.modules: + self._enable_mod_debian(dep, temp) + self.parser.add_mod(dep) + note = "Enabled dependency of %s module - %s" % (mod_name, dep) + if not temp: + self.save_notes += note + os.linesep + logger.debug(note) + + # Enable actual module + self._enable_mod_debian(mod_name, temp) + self.parser.add_mod(mod_name) + + if not temp: + self.save_notes += "Enabled %s module in Apache\n" % mod_name + logger.info("Enabled Apache %s module", mod_name) + + # Modules can enable additional config files. Variables may be defined + # within these new configuration sections. + # Reload is not necessary as DUMP_RUN_CFG uses latest config. + self.parser.update_runtime_variables() + + def _enable_mod_debian(self, mod_name, temp): + """Assumes mods-available, mods-enabled layout.""" + # Generate reversal command. + # Try to be safe here... check that we can probably reverse before + # applying enmod command + if not util.exe_exists(self.option("dismod")): + raise errors.MisconfigurationError( + "Unable to find a2dismod, please make sure a2enmod and " + "a2dismod are configured correctly for certbot.") + + self.reverter.register_undo_command( + temp, [self.option("dismod"), "-f", mod_name]) + util.run_script([self.option("enmod"), mod_name]) diff --git a/certbot-apache/certbot_apache/_internal/override_fedora.py b/certbot-apache/certbot_apache/_internal/override_fedora.py new file mode 100644 index 000000000..8197b0dcd --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_fedora.py @@ -0,0 +1,98 @@ +""" Distribution specific override class for Fedora 29+ """ +import pkg_resources +import zope.interface + +from certbot import errors +from certbot import interfaces +from certbot import util +from certbot.compat import os +from certbot_apache._internal import apache_util +from certbot_apache._internal import configurator +from certbot_apache._internal import parser + + +@zope.interface.provider(interfaces.IPluginFactory) +class FedoraConfigurator(configurator.ApacheConfigurator): + """Fedora 29+ specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/httpd", + vhost_root="/etc/httpd/conf.d", + vhost_files="*.conf", + logs_root="/var/log/httpd", + ctl="httpd", + version_cmd=['httpd', '-v'], + restart_cmd=['apachectl', 'graceful'], + restart_cmd_alt=['apachectl', 'restart'], + conftest_cmd=['apachectl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/httpd/conf.d", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + # TODO: eventually newest version of Fedora will need their own config + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) + + def config_test(self): + """ + Override config_test to mitigate configtest error in vanilla installation + of mod_ssl in Fedora. The error is caused by non-existent self-signed + certificates referenced by the configuration, that would be autogenerated + during the first (re)start of httpd. + """ + try: + super(FedoraConfigurator, self).config_test() + except errors.MisconfigurationError: + self._try_restart_fedora() + + def get_parser(self): + """Initializes the ApacheParser""" + return FedoraParser( + self.option("server_root"), self.option("vhost_root"), + self.version, configurator=self) + + def _try_restart_fedora(self): + """ + Tries to restart httpd using systemctl to generate the self signed keypair. + """ + try: + util.run_script(['systemctl', 'restart', 'httpd']) + except errors.SubprocessError as err: + raise errors.MisconfigurationError(str(err)) + + # Finish with actual config check to see if systemctl restart helped + super(FedoraConfigurator, self).config_test() + + def _prepare_options(self): + """ + Override the options dictionary initialization to keep using apachectl + instead of httpd and so take advantages of this new bash script in newer versions + of Fedora to restart httpd. + """ + super(FedoraConfigurator, self)._prepare_options() + self.options["restart_cmd"][0] = 'apachectl' + self.options["restart_cmd_alt"][0] = 'apachectl' + self.options["conftest_cmd"][0] = 'apachectl' + + +class FedoraParser(parser.ApacheParser): + """Fedora 29+ specific ApacheParser override class""" + def __init__(self, *args, **kwargs): + # Fedora 29+ specific configuration file for Apache + self.sysconfig_filep = "/etc/sysconfig/httpd" + super(FedoraParser, self).__init__(*args, **kwargs) + + def update_runtime_variables(self): + """ Override for update_runtime_variables for custom parsing """ + # Opportunistic, works if SELinux not enforced + super(FedoraParser, self).update_runtime_variables() + self._parse_sysconfig_var() + + def _parse_sysconfig_var(self): + """ Parses Apache CLI options from Fedora configuration file """ + defines = apache_util.parse_define_file(self.sysconfig_filep, "OPTIONS") + for k in defines: + self.variables[k] = defines[k] diff --git a/certbot-apache/certbot_apache/_internal/override_gentoo.py b/certbot-apache/certbot_apache/_internal/override_gentoo.py new file mode 100644 index 000000000..38f8aebe9 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_gentoo.py @@ -0,0 +1,75 @@ +""" Distribution specific override class for Gentoo Linux """ +import pkg_resources +import zope.interface + +from certbot import interfaces +from certbot.compat import os +from certbot_apache._internal import apache_util +from certbot_apache._internal import configurator +from certbot_apache._internal import parser + + +@zope.interface.provider(interfaces.IPluginFactory) +class GentooConfigurator(configurator.ApacheConfigurator): + """Gentoo specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/vhosts.d", + vhost_files="*.conf", + logs_root="/var/log/apache2", + ctl="apache2ctl", + version_cmd=['apache2ctl', '-v'], + restart_cmd=['apache2ctl', 'graceful'], + restart_cmd_alt=['apache2ctl', 'restart'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod=None, + dismod=None, + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/apache2/vhosts.d", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) + + def _prepare_options(self): + """ + Override the options dictionary initialization in order to support + alternative restart cmd used in Gentoo. + """ + super(GentooConfigurator, self)._prepare_options() + self.options["restart_cmd_alt"][0] = self.option("ctl") + + def get_parser(self): + """Initializes the ApacheParser""" + return GentooParser( + self.option("server_root"), self.option("vhost_root"), + self.version, configurator=self) + + +class GentooParser(parser.ApacheParser): + """Gentoo specific ApacheParser override class""" + def __init__(self, *args, **kwargs): + # Gentoo specific configuration file for Apache2 + self.apacheconfig_filep = "/etc/conf.d/apache2" + super(GentooParser, self).__init__(*args, **kwargs) + + def update_runtime_variables(self): + """ Override for update_runtime_variables for custom parsing """ + self.parse_sysconfig_var() + self.update_modules() + + def parse_sysconfig_var(self): + """ Parses Apache CLI options from Gentoo configuration file """ + defines = apache_util.parse_define_file(self.apacheconfig_filep, + "APACHE2_OPTS") + for k in defines: + self.variables[k] = defines[k] + + def update_modules(self): + """Get loaded modules from httpd process, and add them to DOM""" + mod_cmd = [self.configurator.option("ctl"), "modules"] + matches = self.parse_from_subprocess(mod_cmd, r"(.*)_module") + for mod in matches: + self.add_mod(mod.strip()) diff --git a/certbot-apache/certbot_apache/_internal/override_suse.py b/certbot-apache/certbot_apache/_internal/override_suse.py new file mode 100644 index 000000000..0c9219e6d --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/override_suse.py @@ -0,0 +1,31 @@ +""" Distribution specific override class for OpenSUSE """ +import pkg_resources +import zope.interface + +from certbot import interfaces +from certbot.compat import os +from certbot_apache._internal import configurator + + +@zope.interface.provider(interfaces.IPluginFactory) +class OpenSUSEConfigurator(configurator.ApacheConfigurator): + """OpenSUSE specific ApacheConfigurator override class""" + + OS_DEFAULTS = dict( + server_root="/etc/apache2", + vhost_root="/etc/apache2/vhosts.d", + vhost_files="*.conf", + logs_root="/var/log/apache2", + ctl="apache2ctl", + version_cmd=['apache2ctl', '-v'], + restart_cmd=['apache2ctl', 'graceful'], + conftest_cmd=['apache2ctl', 'configtest'], + enmod="a2enmod", + dismod="a2dismod", + le_vhost_ext="-le-ssl.conf", + handle_modules=False, + handle_sites=False, + challenge_location="/etc/apache2/vhosts.d", + MOD_SSL_CONF_SRC=pkg_resources.resource_filename( + "certbot_apache", os.path.join("_internal", "options-ssl-apache.conf")) + ) diff --git a/certbot-apache/certbot_apache/_internal/parser.py b/certbot-apache/certbot_apache/_internal/parser.py new file mode 100644 index 000000000..0703b8fb5 --- /dev/null +++ b/certbot-apache/certbot_apache/_internal/parser.py @@ -0,0 +1,1008 @@ +"""ApacheParser is a member object of the ApacheConfigurator class.""" +import copy +import fnmatch +import logging +import re +import subprocess +import sys + +import six + +from acme.magic_typing import Dict # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module +from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module +from certbot import errors +from certbot.compat import os +from certbot_apache._internal import constants + +logger = logging.getLogger(__name__) + + +class ApacheParser(object): + """Class handles the fine details of parsing the Apache Configuration. + + .. todo:: Make parsing general... remove sites-available etc... + + :ivar str root: Normalized absolute path to the server root + directory. Without trailing slash. + :ivar set modules: All module names that are currently enabled. + :ivar dict loc: Location to place directives, root - configuration origin, + default - user config file, name - NameVirtualHost, + + """ + arg_var_interpreter = re.compile(r"\$\{[^ \}]*}") + fnmatch_chars = set(["*", "?", "\\", "[", "]"]) + + def __init__(self, root, vhostroot=None, version=(2, 4), + configurator=None): + # Note: Order is important here. + + # Needed for calling save() with reverter functionality that resides in + # AugeasConfigurator superclass of ApacheConfigurator. This resolves + # issues with aug.load() after adding new files / defines to parse tree + self.configurator = configurator + + # Initialize augeas + self.aug = None + self.init_augeas() + + if not self.check_aug_version(): + raise errors.NotSupportedError( + "Apache plugin support requires libaugeas0 and augeas-lenses " + "version 1.2.0 or higher, please make sure you have you have " + "those installed.") + + self.modules = set() # type: Set[str] + self.parser_paths = {} # type: Dict[str, List[str]] + self.variables = {} # type: Dict[str, str] + + # Find configuration root and make sure augeas can parse it. + self.root = os.path.abspath(root) + self.loc = {"root": self._find_config_root()} + self.parse_file(self.loc["root"]) + + if version >= (2, 4): + # Look up variables from httpd and add to DOM if not already parsed + self.update_runtime_variables() + + # This problem has been fixed in Augeas 1.0 + self.standardize_excl() + + # Parse LoadModule directives from configuration files + self.parse_modules() + + # Set up rest of locations + self.loc.update(self._set_locations()) + + # list of the active include paths, before modifications + self.existing_paths = copy.deepcopy(self.parser_paths) + + # Must also attempt to parse additional virtual host root + if vhostroot: + self.parse_file(os.path.abspath(vhostroot) + "/" + + self.configurator.option("vhost_files")) + + # check to see if there were unparsed define statements + if version < (2, 4): + if self.find_dir("Define", exclude=False): + raise errors.PluginError("Error parsing runtime variables") + + def init_augeas(self): + """ Initialize the actual Augeas instance """ + + try: + import augeas + except ImportError: # pragma: no cover + raise errors.NoInstallationError("Problem in Augeas installation") + + self.aug = augeas.Augeas( + # specify a directory to load our preferred lens from + loadpath=constants.AUGEAS_LENS_DIR, + # Do not save backup (we do it ourselves), do not load + # anything by default + flags=(augeas.Augeas.NONE | + augeas.Augeas.NO_MODL_AUTOLOAD | + augeas.Augeas.ENABLE_SPAN)) + + def check_parsing_errors(self, lens): + """Verify Augeas can parse all of the lens files. + + :param str lens: lens to check for errors + + :raises .errors.PluginError: If there has been an error in parsing with + the specified lens. + + """ + error_files = self.aug.match("/augeas//error") + + for path in error_files: + # Check to see if it was an error resulting from the use of + # the httpd lens + lens_path = self.aug.get(path + "/lens") + # As aug.get may return null + if lens_path and lens in lens_path: + msg = ( + "There has been an error in parsing the file {0} on line {1}: " + "{2}".format( + # Strip off /augeas/files and /error + path[13:len(path) - 6], + self.aug.get(path + "/line"), + self.aug.get(path + "/message"))) + raise errors.PluginError(msg) + + def check_aug_version(self): + """ Checks that we have recent enough version of libaugeas. + If augeas version is recent enough, it will support case insensitive + regexp matching""" + + self.aug.set("/test/path/testing/arg", "aRgUMeNT") + try: + matches = self.aug.match( + "/test//*[self::arg=~regexp('argument', 'i')]") + except RuntimeError: + self.aug.remove("/test/path") + return False + self.aug.remove("/test/path") + return matches + + def unsaved_files(self): + """Lists files that have modified Augeas DOM but the changes have not + been written to the filesystem yet, used by `self.save()` and + ApacheConfigurator to check the file state. + + :raises .errors.PluginError: If there was an error in Augeas, in + an attempt to save the configuration, or an error creating a + checkpoint + + :returns: `set` of unsaved files + """ + save_state = self.aug.get("/augeas/save") + self.aug.set("/augeas/save", "noop") + # Existing Errors + ex_errs = self.aug.match("/augeas//error") + try: + # This is a noop save + self.aug.save() + except (RuntimeError, IOError): + self._log_save_errors(ex_errs) + # Erase Save Notes + self.configurator.save_notes = "" + raise errors.PluginError( + "Error saving files, check logs for more info.") + + # Return the original save method + self.aug.set("/augeas/save", save_state) + + # Retrieve list of modified files + # Note: Noop saves can cause the file to be listed twice, I used a + # set to remove this possibility. This is a known augeas 0.10 error. + save_paths = self.aug.match("/augeas/events/saved") + + save_files = set() + if save_paths: + for path in save_paths: + save_files.add(self.aug.get(path)[6:]) + return save_files + + def ensure_augeas_state(self): + """Makes sure that all Augeas dom changes are written to files to avoid + loss of configuration directives when doing additional augeas parsing, + causing a possible augeas.load() resulting dom reset + """ + + if self.unsaved_files(): + self.configurator.save_notes += "(autosave)" + self.configurator.save() + + def save(self, save_files): + """Saves all changes to the configuration files. + + save() is called from ApacheConfigurator to handle the parser specific + tasks of saving. + + :param list save_files: list of strings of file paths that we need to save. + + """ + self.configurator.save_notes = "" + self.aug.save() + + # Force reload if files were modified + # This is needed to recalculate augeas directive span + if save_files: + for sf in save_files: + self.aug.remove("/files/"+sf) + self.aug.load() + + def _log_save_errors(self, ex_errs): + """Log errors due to bad Augeas save. + + :param list ex_errs: Existing errors before save + + """ + # Check for the root of save problems + new_errs = self.aug.match("/augeas//error") + # logger.error("During Save - %s", mod_conf) + logger.error("Unable to save files: %s. Attempted Save Notes: %s", + ", ".join(err[13:len(err) - 6] for err in new_errs + # Only new errors caused by recent save + if err not in ex_errs), self.configurator.save_notes) + + def add_include(self, main_config, inc_path): + """Add Include for a new configuration file if one does not exist + + :param str main_config: file path to main Apache config file + :param str inc_path: path of file to include + + """ + if not self.find_dir(case_i("Include"), inc_path): + logger.debug("Adding Include %s to %s", + inc_path, get_aug_path(main_config)) + self.add_dir( + get_aug_path(main_config), + "Include", inc_path) + + # Add new path to parser paths + new_dir = os.path.dirname(inc_path) + new_file = os.path.basename(inc_path) + self.existing_paths.setdefault(new_dir, []).append(new_file) + + def add_mod(self, mod_name): + """Shortcut for updating parser modules.""" + if mod_name + "_module" not in self.modules: + self.modules.add(mod_name + "_module") + if "mod_" + mod_name + ".c" not in self.modules: + self.modules.add("mod_" + mod_name + ".c") + + def reset_modules(self): + """Reset the loaded modules list. This is called from cleanup to clear + temporarily loaded modules.""" + self.modules = set() + self.update_modules() + self.parse_modules() + + def parse_modules(self): + """Iterates on the configuration until no new modules are loaded. + + ..todo:: This should be attempted to be done with a binary to avoid + the iteration issue. Else... parse and enable mods at same time. + + """ + mods = set() # type: Set[str] + matches = self.find_dir("LoadModule") + iterator = iter(matches) + # Make sure prev_size != cur_size for do: while: iteration + prev_size = -1 + + while len(mods) != prev_size: + prev_size = len(mods) + + for match_name, match_filename in six.moves.zip( + iterator, iterator): + mod_name = self.get_arg(match_name) + mod_filename = self.get_arg(match_filename) + if mod_name and mod_filename: + mods.add(mod_name) + mods.add(os.path.basename(mod_filename)[:-2] + "c") + else: + logger.debug("Could not read LoadModule directive from Augeas path: %s", + match_name[6:]) + self.modules.update(mods) + + def update_runtime_variables(self): + """Update Includes, Defines and Includes from httpd config dump data""" + self.update_defines() + self.update_includes() + self.update_modules() + + def update_defines(self): + """Get Defines from httpd process""" + + variables = dict() + define_cmd = [self.configurator.option("ctl"), "-t", "-D", + "DUMP_RUN_CFG"] + matches = self.parse_from_subprocess(define_cmd, r"Define: ([^ \n]*)") + try: + matches.remove("DUMP_RUN_CFG") + except ValueError: + return + + for match in matches: + if match.count("=") > 1: + logger.error("Unexpected number of equal signs in " + "runtime config dump.") + raise errors.PluginError( + "Error parsing Apache runtime variables") + parts = match.partition("=") + variables[parts[0]] = parts[2] + + self.variables = variables + + def update_includes(self): + """Get includes from httpd process, and add them to DOM if needed""" + + # Find_dir iterates over configuration for Include and IncludeOptional + # directives to make sure we see the full include tree present in the + # configuration files + _ = self.find_dir("Include") + + inc_cmd = [self.configurator.option("ctl"), "-t", "-D", + "DUMP_INCLUDES"] + matches = self.parse_from_subprocess(inc_cmd, r"\(.*\) (.*)") + if matches: + for i in matches: + if not self.parsed_in_current(i): + self.parse_file(i) + + def update_modules(self): + """Get loaded modules from httpd process, and add them to DOM""" + + mod_cmd = [self.configurator.option("ctl"), "-t", "-D", + "DUMP_MODULES"] + matches = self.parse_from_subprocess(mod_cmd, r"(.*)_module") + for mod in matches: + self.add_mod(mod.strip()) + + def parse_from_subprocess(self, command, regexp): + """Get values from stdout of subprocess command + + :param list command: Command to run + :param str regexp: Regexp for parsing + + :returns: list parsed from command output + :rtype: list + + """ + stdout = self._get_runtime_cfg(command) + return re.compile(regexp).findall(stdout) + + def _get_runtime_cfg(self, command): # pylint: disable=no-self-use + """Get runtime configuration info. + :param command: Command to run + + :returns: stdout from command + + """ + try: + proc = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) + stdout, stderr = proc.communicate() + + except (OSError, ValueError): + logger.error( + "Error running command %s for runtime parameters!%s", + command, os.linesep) + raise errors.MisconfigurationError( + "Error accessing loaded Apache parameters: {0}".format( + command)) + # Small errors that do not impede + if proc.returncode != 0: + logger.warning("Error in checking parameter list: %s", stderr) + raise errors.MisconfigurationError( + "Apache is unable to check whether or not the module is " + "loaded because Apache is misconfigured.") + + return stdout + + def filter_args_num(self, matches, args): # pylint: disable=no-self-use + """Filter out directives with specific number of arguments. + + This function makes the assumption that all related arguments are given + in order. Thus /files/apache/directive[5]/arg[2] must come immediately + after /files/apache/directive[5]/arg[1]. Runs in 1 linear pass. + + :param string matches: Matches of all directives with arg nodes + :param int args: Number of args you would like to filter + + :returns: List of directives that contain # of arguments. + (arg is stripped off) + + """ + filtered = [] + if args == 1: + for i, match in enumerate(matches): + if match.endswith("/arg"): + filtered.append(matches[i][:-4]) + else: + for i, match in enumerate(matches): + if match.endswith("/arg[%d]" % args): + # Make sure we don't cause an IndexError (end of list) + # Check to make sure arg + 1 doesn't exist + if (i == (len(matches) - 1) or + not matches[i + 1].endswith("/arg[%d]" % + (args + 1))): + filtered.append(matches[i][:-len("/arg[%d]" % args)]) + + return filtered + + def add_dir_to_ifmodssl(self, aug_conf_path, directive, args): + """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, e.g. Listen + :param args: Values of the directive; str "443" or list of str + :type args: list + + """ + # 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) + if len(args) == 1: + self.aug.set(nvh_path + "/arg", args[0]) + else: + for i, arg in enumerate(args): + self.aug.set("%s/arg[%d]" % (nvh_path, i + 1), arg) + + def get_ifmod(self, aug_conf_path, mod, beginning=False): + """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 + :param bool beginning: If the IfModule should be created to the beginning + of augeas path DOM tree. + + :returns: Augeas path of the requested IfModule directive that pre-existed + or was created during the process. The path may be dynamic, + i.e. .../IfModule[last()] + :rtype: str + + """ + if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % + (aug_conf_path, mod))) + if not if_mods: + return self.create_ifmod(aug_conf_path, mod, beginning) + + # Strip off "arg" at end of first ifmod path + return if_mods[0].rpartition("arg")[0] + + def create_ifmod(self, aug_conf_path, mod, beginning=False): + """Creates a new <IfMod mod> and returns its path. + + :param str aug_conf_path: Augeas configuration path + :param str mod: module ie. mod_ssl.c + :param bool beginning: If the IfModule should be created to the beginning + of augeas path DOM tree. + + :returns: Augeas path of the newly created IfModule directive. + The path may be dynamic, i.e. .../IfModule[last()] + :rtype: str + + """ + if beginning: + c_path_arg = "{}/IfModule[1]/arg".format(aug_conf_path) + # Insert IfModule before the first directive + self.aug.insert("{}/directive[1]".format(aug_conf_path), + "IfModule", True) + retpath = "{}/IfModule[1]/".format(aug_conf_path) + else: + c_path = "{}/IfModule[last() + 1]".format(aug_conf_path) + c_path_arg = "{}/IfModule[last()]/arg".format(aug_conf_path) + self.aug.set(c_path, "") + retpath = "{}/IfModule[last()]/".format(aug_conf_path) + self.aug.set(c_path_arg, mod) + return retpath + + def add_dir(self, aug_conf_path, directive, args): + """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 args: Value of the directive. ie. Listen 443, 443 is arg + :type args: list or str + + """ + self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) + if isinstance(args, list): + for i, value in enumerate(args, 1): + self.aug.set( + "%s/directive[last()]/arg[%d]" % (aug_conf_path, i), value) + else: + self.aug.set(aug_conf_path + "/directive[last()]/arg", args) + + def add_dir_beginning(self, aug_conf_path, dirname, args): + """Adds the directive to the beginning of defined aug_conf_path. + + :param str aug_conf_path: Augeas configuration path to add directive + :param str dirname: Directive to add + :param args: Value of the directive. ie. Listen 443, 443 is arg + :type args: list or str + """ + first_dir = aug_conf_path + "/directive[1]" + self.aug.insert(first_dir, "directive", True) + self.aug.set(first_dir, dirname) + if isinstance(args, list): + for i, value in enumerate(args, 1): + self.aug.set(first_dir + "/arg[%d]" % (i), value) + else: + self.aug.set(first_dir + "/arg", args) + + def add_comment(self, aug_conf_path, comment): + """Adds the comment to the augeas path + + :param str aug_conf_path: Augeas configuration path to add directive + :param str comment: Comment content + + """ + self.aug.set(aug_conf_path + "/#comment[last() + 1]", comment) + + def find_comments(self, arg, start=None): + """Finds a comment with specified content from the provided DOM path + + :param str arg: Comment content to search + :param str start: Beginning Augeas path to begin looking + + :returns: List of augeas paths containing the comment content + :rtype: list + + """ + if not start: + start = get_aug_path(self.root) + + comments = self.aug.match("%s//*[label() = '#comment']" % start) + + results = [] + for comment in comments: + c_content = self.aug.get(comment) + if c_content and arg in c_content: + results.append(comment) + return results + + def find_dir(self, directive, arg=None, start=None, exclude=True): + """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 + .. todo:: arg search currently only supports direct matching. It does + not handle the case of variables or quoted arguments. This should + be adapted to use a generic search for the directive and then do a + case-insensitive self.get_arg filter + + 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 directive must have, None if all should + be considered + :type arg: str or None + + :param str start: Beginning Augeas path to begin looking + :param bool exclude: Whether or not to exclude directives based on + variables and enabled modules + + """ + # Cannot place member variable in the definition of the function so... + if not start: + start = get_aug_path(self.loc["root"]) + + # 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']") + + regex = "(%s)|(%s)|(%s)" % (case_i(directive), + case_i("Include"), + case_i("IncludeOptional")) + matches = self.aug.match( + "%s//*[self::directive=~regexp('%s')]" % (start, regex)) + + if exclude: + matches = self._exclude_dirs(matches) + + if arg is None: + arg_suffix = "/arg" + else: + arg_suffix = "/*[self::arg=~regexp('%s')]" % case_i(arg) + + ordered_matches = [] # type: List[str] + + # TODO: Wildcards should be included in alphabetical order + # https://httpd.apache.org/docs/2.4/mod/core.html#include + for match in matches: + dir_ = self.aug.get(match).lower() + if dir_ in ("include", "includeoptional"): + ordered_matches.extend(self.find_dir( + directive, arg, + self._get_include_path(self.get_arg(match + "/arg")), + exclude)) + # This additionally allows Include + if dir_ == directive.lower(): + ordered_matches.extend(self.aug.match(match + arg_suffix)) + + return ordered_matches + + def get_all_args(self, match): + """ + Tries to fetch all arguments for a directive. See get_arg. + + Note that if match is an ancestor node, it returns all names of + child directives as well as the list of arguments. + + """ + + if match[-1] != "/": + match = match+"/" + allargs = self.aug.match(match + '*') + return [self.get_arg(arg) for arg in allargs] + + def get_arg(self, match): + """Uses augeas.get to get argument value and interprets result. + + This also converts all variables and parameters appropriately. + + """ + value = self.aug.get(match) + + # No need to strip quotes for variables, as apache2ctl already does + # this, but we do need to strip quotes for all normal arguments. + + # Note: normal argument may be a quoted variable + # e.g. strip now, not later + if not value: + return None + value = value.strip("'\"") + + variables = ApacheParser.arg_var_interpreter.findall(value) + + for var in variables: + # Strip off ${ and } + try: + value = value.replace(var, self.variables[var[2:-1]]) + except KeyError: + raise errors.PluginError("Error Parsing variable: %s" % var) + + return value + + def _exclude_dirs(self, matches): + """Exclude directives that are not loaded into the configuration.""" + filters = [("ifmodule", self.modules), ("ifdefine", self.variables)] + + valid_matches = [] + + for match in matches: + for filter_ in filters: + if not self._pass_filter(match, filter_): + break + else: + valid_matches.append(match) + return valid_matches + + def _pass_filter(self, match, filter_): + """Determine if directive passes a filter. + + :param str match: Augeas path + :param list filter: list of tuples of form + [("lowercase if directive", set of relevant parameters)] + + """ + match_l = match.lower() + last_match_idx = match_l.find(filter_[0]) + + while last_match_idx != -1: + # Check args + end_of_if = match_l.find("/", last_match_idx) + # This should be aug.get (vars are not used e.g. parser.aug_get) + expression = self.aug.get(match[:end_of_if] + "/arg") + + if expression.startswith("!"): + # Strip off "!" + if expression[1:] in filter_[1]: + return False + else: + if expression not in filter_[1]: + return False + + last_match_idx = match_l.find(filter_[0], end_of_if) + + return True + + def _get_include_path(self, 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 arg: Argument of Include directive + + :returns: Augeas path string + :rtype: str + + """ + # 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 [] + + # Remove beginning and ending quotes + arg = arg.strip("'\"") + + # Standardize the include argument based on server root + if not arg.startswith("/"): + # Normpath will condense ../ + arg = os.path.normpath(os.path.join(self.root, arg)) + else: + arg = os.path.normpath(arg) + + # Attempts to add a transform to the file if one does not already exist + if os.path.isdir(arg): + self.parse_file(os.path.join(arg, "*")) + else: + 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 + split_arg = arg.split("/") + for idx, split in enumerate(split_arg): + if any(char in ApacheParser.fnmatch_chars for char in split): + # Turn it into an 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 + # Note: This also normalizes the argument /serverroot/ -> /serverroot + arg = "/".join(split_arg) + + return get_aug_path(arg) + + def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use + """Method converts Apache's basic fnmatch to regular expression. + + Assumption - Configs are assumed to be well-formed and only writable by + privileged users. + + https://apr.apache.org/docs/apr/2.0/apr__fnmatch_8h_source.html + http://apache2.sourcearchive.com/documentation/2.2.16-6/apr__fnmatch_8h_source.html + + :param str clean_fn_match: Apache style filename match, like globs + + :returns: regex suitable for augeas + :rtype: str + + """ + if sys.version_info < (3, 6): + # This strips off final /Z(?ms) + return fnmatch.translate(clean_fn_match)[:-7] + # Since Python 3.6, it returns a different pattern like (?s:.*\.load)\Z + return fnmatch.translate(clean_fn_match)[4:-3] # pragma: no cover + + def parse_file(self, filepath): + """Parse file with Augeas + + Checks to see if file_path is parsed by Augeas + If filepath isn't parsed, the file is added and Augeas is reloaded + + :param str filepath: Apache config file path + + """ + use_new, remove_old = self._check_path_actions(filepath) + # Ensure that we have the latest Augeas DOM state on disk before + # calling aug.load() which reloads the state from disk + self.ensure_augeas_state() + # Test if augeas included file for Httpd.lens + # Note: This works for augeas globs, ie. *.conf + if use_new: + inc_test = self.aug.match( + "/augeas/load/Httpd['%s' =~ glob(incl)]" % filepath) + if not inc_test: + # Load up files + # This doesn't seem to work on TravisCI + # self.aug.add_transform("Httpd.lns", [filepath]) + if remove_old: + self._remove_httpd_transform(filepath) + self._add_httpd_transform(filepath) + self.aug.load() + + def parsed_in_current(self, filep): + """Checks if the file path is parsed by current Augeas parser config + ie. returns True if the file is found on a path that's found in live + Augeas configuration. + + :param str filep: Path to match + + :returns: True if file is parsed in existing configuration tree + :rtype: bool + """ + return self._parsed_by_parser_paths(filep, self.parser_paths) + + def parsed_in_original(self, filep): + """Checks if the file path is parsed by existing Apache config. + ie. returns True if the file is found on a path that matches Include or + IncludeOptional statement in the Apache configuration. + + :param str filep: Path to match + + :returns: True if file is parsed in existing configuration tree + :rtype: bool + """ + return self._parsed_by_parser_paths(filep, self.existing_paths) + + def _parsed_by_parser_paths(self, filep, paths): + """Helper function that searches through provided paths and returns + True if file path is found in the set""" + for directory in paths.keys(): + for filename in paths[directory]: + if fnmatch.fnmatch(filep, os.path.join(directory, filename)): + return True + return False + + def _check_path_actions(self, filepath): + """Determine actions to take with a new augeas path + + This helper function will return a tuple that defines + if we should try to append the new filepath to augeas + parser paths, and / or remove the old one with more + narrow matching. + + :param str filepath: filepath to check the actions for + + """ + + try: + new_file_match = os.path.basename(filepath) + existing_matches = self.parser_paths[os.path.dirname(filepath)] + if "*" in existing_matches: + use_new = False + else: + use_new = True + remove_old = new_file_match == "*" + except KeyError: + use_new = True + remove_old = False + return use_new, remove_old + + def _remove_httpd_transform(self, filepath): + """Remove path from Augeas transform + + :param str filepath: filepath to remove + """ + + remove_basenames = self.parser_paths[os.path.dirname(filepath)] + remove_dirname = os.path.dirname(filepath) + for name in remove_basenames: + remove_path = remove_dirname + "/" + name + remove_inc = self.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % remove_path) + self.aug.remove(remove_inc[0]) + self.parser_paths.pop(remove_dirname) + + 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 doesn't seem to work for + Travis CI as it loads in libaugeas.so.0.10.0 + + :param str incl: filepath to include for transform + + """ + last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") + if last_include: + # Insert a new node immediately after the last incl + self.aug.insert(last_include[0], "incl", False) + self.aug.set("/augeas/load/Httpd/incl[last()]", incl) + # On first use... must load lens and add file to incl + else: + # Augeas uses base 1 indexing... insert at beginning... + self.aug.set("/augeas/load/Httpd/lens", "Httpd.lns") + self.aug.set("/augeas/load/Httpd/incl", incl) + # Add included path to paths dictionary + try: + self.parser_paths[os.path.dirname(incl)].append( + os.path.basename(incl)) + except KeyError: + self.parser_paths[os.path.dirname(incl)] = [ + os.path.basename(incl)] + + 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.root + "/*.augsave", + self.root + "/*~", + self.root + "/*/*augsave", + self.root + "/*/*~", + self.root + "/*/*/*.augsave", + self.root + "/*/*/*~"] + + for i, excluded in enumerate(excl, 1): + self.aug.set("/augeas/load/Httpd/excl[%d]" % i, excluded) + + self.aug.load() + + def _set_locations(self): + """Set default location for directives. + + Locations are given as file_paths + .. todo:: Make sure that files are included + + """ + default = self.loc["root"] + + temp = os.path.join(self.root, "ports.conf") + if os.path.isfile(temp): + listen = temp + name = temp + else: + listen = default + name = default + + return {"default": default, "listen": listen, "name": name} + + def _find_config_root(self): + """Find the Apache Configuration Root file.""" + location = ["apache2.conf", "httpd.conf", "conf/httpd.conf"] + for name in location: + if os.path.isfile(os.path.join(self.root, name)): + return os.path.join(self.root, name) + raise errors.NoInstallationError("Could not find configuration root") + + +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_aug_path(file_path): + """Return augeas path for full filepath. + + :param str file_path: Full filepath + + """ + return "/files%s" % file_path |