Merge branch 'master' of github.com:letsencrypt/letsencrypt into rm-py26

This commit is contained in:
Jacob Hoffman-Andrews
2015-08-06 10:30:19 -07:00
53 changed files with 2823 additions and 943 deletions
+7 -1
View File
@@ -14,6 +14,7 @@ before_install:
env: env:
global: global:
- GOPATH=/tmp/go - GOPATH=/tmp/go
- PATH=$GOPATH/bin:$PATH
matrix: matrix:
- TOXENV=py27 BOULDER_INTEGRATION=1 - TOXENV=py27 BOULDER_INTEGRATION=1
- TOXENV=py33 - TOXENV=py33
@@ -21,8 +22,13 @@ env:
- TOXENV=lint - TOXENV=lint
- TOXENV=cover - TOXENV=cover
# make sure simplehttp simple verification works (custom /etc/hosts)
addons:
hosts:
- le.wtf
install: "travis_retry pip install tox coveralls" install: "travis_retry pip install tox coveralls"
before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh amqp' before_script: '[ "xxx$BOULDER_INTEGRATION" = "xxx" ] || ./tests/boulder-start.sh'
script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || (source .tox/$TOXENV/bin/activate && ./tests/boulder-integration.sh))' script: 'travis_retry tox && ([ "xxx$BOULDER_INTEGRATION" = "xxx" ] || (source .tox/$TOXENV/bin/activate && ./tests/boulder-integration.sh))'
after_success: '[ "$TOXENV" == "cover" ] && coveralls' after_success: '[ "$TOXENV" == "cover" ] && coveralls'
+20 -16
View File
@@ -1,12 +1,18 @@
.. notice for github users .. notice for github users
Official **documentation**, including `installation instructions`_, is Disclaimer
available at https://letsencrypt.readthedocs.org. ==========
Generic information about Let's Encrypt project can be found at This is a **DEVELOPER PREVIEW** intended for developers and testers only.
https://letsencrypt.org. Please read `Frequently Asked Questions (FAQ)
<https://letsencrypt.org/faq/>`_.
**DO NOT RUN THIS CODE ON A PRODUCTION SERVER. IT WILL INSTALL CERTIFICATES
SIGNED BY A TEST CA, AND WILL CAUSE CERT WARNINGS FOR USERS.**
Browser-trusted certificates will be available in the coming months.
For more information regarding the status of the project, please see
https://letsencrypt.org. Be sure to checkout the
`Frequently Asked Questions (FAQ) <https://letsencrypt.org/faq/>`_.
About the Let's Encrypt Client About the Let's Encrypt Client
============================== ==============================
@@ -18,7 +24,7 @@ In short: getting and installing SSL/TLS certificates made easy (`watch demo vid
The Let's Encrypt Client is a tool to automatically receive and install The Let's Encrypt Client is a tool to automatically receive and install
X.509 certificates to enable TLS on servers. The client will X.509 certificates to enable TLS on servers. The client will
interoperate with the Let's Encrypt CA which will be issuing browser-trusted interoperate with the Let's Encrypt CA which will be issuing browser-trusted
certificates for free beginning the summer of 2015. certificates for free.
It's all automated: It's all automated:
@@ -32,7 +38,7 @@ All you need to do to sign a single domain is::
user@www:~$ sudo letsencrypt -d www.example.org auth user@www:~$ sudo letsencrypt -d www.example.org auth
For multiple domains (SAN) use:: For multiple domains (SAN) use::
user@www:~$ sudo letsencrypt -d www.example.org -d example.org auth user@www:~$ sudo letsencrypt -d www.example.org -d example.org auth
and if you have a compatible web server (Apache or Nginx), Let's Encrypt can and if you have a compatible web server (Apache or Nginx), Let's Encrypt can
@@ -67,15 +73,6 @@ server automatically!::
.. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU .. _watch demo video: https://www.youtube.com/watch?v=Gas_sSB-5SU
Disclaimer
----------
This is a **DEVELOPER PREVIEW** intended for developers and testers only.
**DO NOT RUN THIS CODE ON A PRODUCTION SERVER. IT WILL INSTALL CERTIFICATES
SIGNED BY A TEST CA, AND WILL CAUSE CERT WARNINGS FOR USERS.**
Current Features Current Features
---------------- ----------------
@@ -99,6 +96,13 @@ Current Features
* Free and Open Source Software, made with Python. * Free and Open Source Software, made with Python.
Installation Instructions
-------------------------
Official **documentation**, including `installation instructions`_, is
available at https://letsencrypt.readthedocs.org.
Links Links
----- -----
+1 -1
View File
@@ -83,7 +83,7 @@ Mac OSX
.. code-block:: shell .. code-block:: shell
sudo ./bootstrap/mac.sh ./bootstrap/mac.sh
Fedora Fedora
@@ -3,6 +3,7 @@ import logging
import augeas import augeas
from letsencrypt import errors
from letsencrypt import reverter from letsencrypt import reverter
from letsencrypt.plugins import common from letsencrypt.plugins import common
@@ -23,7 +24,6 @@ class AugeasConfigurator(common.Plugin):
:type reverter: :class:`letsencrypt.reverter.Reverter` :type reverter: :class:`letsencrypt.reverter.Reverter`
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(AugeasConfigurator, self).__init__(*args, **kwargs) super(AugeasConfigurator, self).__init__(*args, **kwargs)
@@ -38,13 +38,16 @@ class AugeasConfigurator(common.Plugin):
# because this will change the underlying configuration and potential # because this will change the underlying configuration and potential
# vhosts # vhosts
self.reverter = reverter.Reverter(self.config) self.reverter = reverter.Reverter(self.config)
self.reverter.recovery_routine() self.recovery_routine()
def check_parsing_errors(self, lens): def check_parsing_errors(self, lens):
"""Verify Augeas can parse all of the lens files. """Verify Augeas can parse all of the lens files.
:param str lens: lens to check for errors :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") error_files = self.aug.match("/augeas//error")
@@ -54,11 +57,13 @@ class AugeasConfigurator(common.Plugin):
lens_path = self.aug.get(path + "/lens") lens_path = self.aug.get(path + "/lens")
# As aug.get may return null # As aug.get may return null
if lens_path and lens in lens_path: if lens_path and lens in lens_path:
logger.error( msg = (
"There has been an error in parsing the file (%s): %s", "There has been an error in parsing the file (%s): %s",
# Strip off /augeas/files and /error # Strip off /augeas/files and /error
path[13:len(path) - 6], self.aug.get(path + "/message")) path[13:len(path) - 6], self.aug.get(path + "/message"))
raise errors.PluginError(msg)
# TODO: Cleanup this function
def save(self, title=None, temporary=False): def save(self, title=None, temporary=False):
"""Saves all changes to the configuration files. """Saves all changes to the configuration files.
@@ -73,6 +78,9 @@ class AugeasConfigurator(common.Plugin):
:param bool temporary: Indicates whether the changes made will :param bool temporary: Indicates whether the changes made will
be quickly reversed in the future (ie. challenges) be quickly reversed in the future (ie. challenges)
:raises .errors.PluginError: If there was an error in Augeas, in an
attempt to save the configuration, or an error creating a checkpoint
""" """
save_state = self.aug.get("/augeas/save") save_state = self.aug.get("/augeas/save")
self.aug.set("/augeas/save", "noop") self.aug.set("/augeas/save", "noop")
@@ -85,7 +93,8 @@ class AugeasConfigurator(common.Plugin):
self._log_save_errors(ex_errs) self._log_save_errors(ex_errs)
# Erase Save Notes # Erase Save Notes
self.save_notes = "" self.save_notes = ""
return False raise errors.PluginError(
"Error saving files, check logs for more info.")
# Retrieve list of modified files # Retrieve list of modified files
# Note: Noop saves can cause the file to be listed twice, I used a # Note: Noop saves can cause the file to be listed twice, I used a
@@ -99,22 +108,26 @@ class AugeasConfigurator(common.Plugin):
for path in save_paths: for path in save_paths:
save_files.add(self.aug.get(path)[6:]) save_files.add(self.aug.get(path)[6:])
# Create Checkpoint try:
if temporary: # Create Checkpoint
self.reverter.add_to_temp_checkpoint( if temporary:
save_files, self.save_notes) self.reverter.add_to_temp_checkpoint(
else: save_files, self.save_notes)
self.reverter.add_to_checkpoint(save_files, self.save_notes) else:
self.reverter.add_to_checkpoint(save_files, self.save_notes)
except errors.ReverterError as err:
raise errors.PluginError(str(err))
if title and not temporary: if title and not temporary:
self.reverter.finalize_checkpoint(title) try:
self.reverter.finalize_checkpoint(title)
except errors.ReverterError as err:
raise errors.PluginError(str(err))
self.aug.set("/augeas/save", save_state) self.aug.set("/augeas/save", save_state)
self.save_notes = "" self.save_notes = ""
self.aug.save() self.aug.save()
return True
def _log_save_errors(self, ex_errs): def _log_save_errors(self, ex_errs):
"""Log errors due to bad Augeas save. """Log errors due to bad Augeas save.
@@ -135,14 +148,26 @@ class AugeasConfigurator(common.Plugin):
Reverts all modified files that have not been saved as a checkpoint Reverts all modified files that have not been saved as a checkpoint
:raises .errors.PluginError: If unable to recover the configuration
""" """
self.reverter.recovery_routine() try:
self.reverter.recovery_routine()
except errors.ReverterError as err:
raise errors.PluginError(str(err))
# Need to reload configuration after these changes take effect # Need to reload configuration after these changes take effect
self.aug.load() self.aug.load()
def revert_challenge_config(self): def revert_challenge_config(self):
"""Used to cleanup challenge configurations.""" """Used to cleanup challenge configurations.
self.reverter.revert_temporary_config()
:raises .errors.PluginError: If unable to revert the challenge config.
"""
try:
self.reverter.revert_temporary_config()
except errors.ReverterError as err:
raise errors.PluginError(str(err))
self.aug.load() self.aug.load()
def rollback_checkpoints(self, rollback=1): def rollback_checkpoints(self, rollback=1):
@@ -150,10 +175,24 @@ class AugeasConfigurator(common.Plugin):
:param int rollback: Number of checkpoints to revert :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
""" """
self.reverter.rollback_checkpoints(rollback) try:
self.reverter.rollback_checkpoints(rollback)
except errors.ReverterError as err:
raise errors.PluginError(str(err))
self.aug.load() self.aug.load()
def view_config_changes(self): def view_config_changes(self):
"""Show all of the configuration changes that have taken place.""" """Show all of the configuration changes that have taken place.
self.reverter.view_config_changes()
:raises .errors.PluginError: If there is a problem while processing
the checkpoints directories.
"""
try:
self.reverter.view_config_changes()
except errors.ReverterError as err:
raise errors.PluginError(str(err))
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ CLI_DEFAULTS = dict(
server_root="/etc/apache2", server_root="/etc/apache2",
ctl="apache2ctl", ctl="apache2ctl",
enmod="a2enmod", enmod="a2enmod",
dismod="a2dismod",
init_script="/etc/init.d/apache2", init_script="/etc/init.d/apache2",
le_vhost_ext="-le-ssl.conf", le_vhost_ext="-le-ssl.conf",
) )
@@ -20,5 +21,5 @@ MOD_SSL_CONF_SRC = pkg_resources.resource_filename(
distribution.""" distribution."""
REWRITE_HTTPS_ARGS = [ REWRITE_HTTPS_ARGS = [
"^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"] "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"]
"""Apache rewrite rule arguments used for redirections to https vhost""" """Apache rewrite rule arguments used for redirections to https vhost"""
@@ -60,9 +60,9 @@ def _vhost_menu(domain, vhosts):
choices = [] choices = []
for vhost in vhosts: for vhost in vhosts:
if len(vhost.names) == 1: if len(vhost.get_names()) == 1:
disp_name = next(iter(vhost.names)) disp_name = next(iter(vhost.get_names()))
elif len(vhost.names) == 0: elif len(vhost.get_names()) == 0:
disp_name = "" disp_name = ""
else: else:
disp_name = "Multiple Names" disp_name = "Multiple Names"
+45 -22
View File
@@ -3,6 +3,7 @@ import os
from letsencrypt.plugins import common from letsencrypt.plugins import common
from letsencrypt_apache import obj
from letsencrypt_apache import parser from letsencrypt_apache import parser
@@ -44,6 +45,13 @@ class ApacheDvsni(common.Dvsni):
""" """
def __init__(self, *args, **kwargs):
super(ApacheDvsni, self).__init__(*args, **kwargs)
self.challenge_conf = os.path.join(
self.configurator.conf("server-root"),
"le_dvsni_cert_challenge.conf")
def perform(self): def perform(self):
"""Peform a DVSNI challenge.""" """Peform a DVSNI challenge."""
if not self.achalls: if not self.achalls:
@@ -52,20 +60,9 @@ class ApacheDvsni(common.Dvsni):
# About to make temporary changes to the config # About to make temporary changes to the config
self.configurator.save() self.configurator.save()
addresses = [] # Prepare the server for HTTPS
default_addr = "*:443" self.configurator.prepare_server_https(
for achall in self.achalls: str(self.configurator.config.dvsni_port))
vhost = self.configurator.choose_vhost(achall.domain)
# TODO - @jdkasten review this code to make sure it makes sense
self.configurator.make_server_sni_ready(vhost, default_addr)
for addr in vhost.addrs:
if "_default_" == addr.get_addr():
addresses.append([default_addr])
break
else:
addresses.append(list(vhost.addrs))
responses = [] responses = []
@@ -74,25 +71,32 @@ class ApacheDvsni(common.Dvsni):
responses.append(self._setup_challenge_cert(achall)) responses.append(self._setup_challenge_cert(achall))
# Setup the configuration # Setup the configuration
self._mod_config(addresses) dvsni_addrs = self._mod_config()
self.configurator.make_addrs_sni_ready(dvsni_addrs)
# Save reversible changes # Save reversible changes
self.configurator.save("SNI Challenge", True) self.configurator.save("SNI Challenge", True)
return responses return responses
def _mod_config(self, ll_addrs): def _mod_config(self):
"""Modifies Apache config files to include challenge vhosts. """Modifies Apache config files to include challenge vhosts.
Result: Apache config includes virtual servers for issued challs Result: Apache config includes virtual servers for issued challs
:param list ll_addrs: list of list of `~.common.Addr` to apply :returns: All DVSNI addresses used
:rtype: set
""" """
# TODO: Use ip address of existing vhost instead of relying on FQDN dvsni_addrs = set()
config_text = "<IfModule mod_ssl.c>\n" config_text = "<IfModule mod_ssl.c>\n"
for idx, lis in enumerate(ll_addrs):
config_text += self._get_config_text(self.achalls[idx], lis) for achall in self.achalls:
achall_addrs = self.get_dvsni_addrs(achall)
dvsni_addrs.update(achall_addrs)
config_text += self._get_config_text(achall, achall_addrs)
config_text += "</IfModule>\n" config_text += "</IfModule>\n"
self._conf_include_check(self.configurator.parser.loc["default"]) self._conf_include_check(self.configurator.parser.loc["default"])
@@ -102,6 +106,25 @@ class ApacheDvsni(common.Dvsni):
with open(self.challenge_conf, "w") as new_conf: with open(self.challenge_conf, "w") as new_conf:
new_conf.write(config_text) new_conf.write(config_text)
return dvsni_addrs
def get_dvsni_addrs(self, achall):
"""Return the Apache addresses needed for DVSNI."""
vhost = self.configurator.choose_vhost(achall.domain)
# TODO: Checkout _default_ rules.
dvsni_addrs = set()
default_addr = obj.Addr(("*", str(self.configurator.config.dvsni_port)))
for addr in vhost.addrs:
if "_default_" == addr.get_addr():
dvsni_addrs.add(default_addr)
else:
dvsni_addrs.add(
addr.get_sni_addr(self.configurator.config.dvsni_port))
return dvsni_addrs
def _conf_include_check(self, main_config): def _conf_include_check(self, main_config):
"""Adds DVSNI challenge conf file into configuration. """Adds DVSNI challenge conf file into configuration.
@@ -125,7 +148,7 @@ class ApacheDvsni(common.Dvsni):
:type achall: :class:`letsencrypt.achallenges.DVSNI` :type achall: :class:`letsencrypt.achallenges.DVSNI`
:param list ip_addrs: addresses of challenged domain :param list ip_addrs: addresses of challenged domain
:class:`list` of type `~.common.Addr` :class:`list` of type `~.obj.Addr`
:returns: virtual host configuration text :returns: virtual host configuration text
:rtype: str :rtype: str
@@ -141,7 +164,7 @@ class ApacheDvsni(common.Dvsni):
# https://docs.python.org/2.7/reference/lexical_analysis.html # https://docs.python.org/2.7/reference/lexical_analysis.html
return self.VHOST_TEMPLATE.format( return self.VHOST_TEMPLATE.format(
vhost=ips, server_name=achall.nonce_domain, vhost=ips, server_name=achall.nonce_domain,
ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], ssl_options_conf_path=self.configurator.mod_ssl_conf,
cert_path=self.get_cert_path(achall), cert_path=self.get_cert_path(achall),
key_path=self.get_key_path(achall), key_path=self.get_key_path(achall),
document_root=document_root).replace("\n", os.linesep) document_root=document_root).replace("\n", os.linesep)
+176 -9
View File
@@ -1,4 +1,88 @@
"""Module contains classes used by the Apache Configurator.""" """Module contains classes used by the Apache Configurator."""
import re
from letsencrypt.plugins import common
class Addr(common.Addr):
"""Represents an Apache address."""
def __eq__(self, other):
"""This is defined as equalivalent 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 _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
else:
return 2
def conflicts(self, addr):
"""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.
Example:
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): # pylint: disable=too-few-public-methods class VirtualHost(object): # pylint: disable=too-few-public-methods
@@ -8,39 +92,56 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
:ivar str path: Augeas path to virtual host :ivar str path: Augeas path to virtual host
:ivar set addrs: Virtual Host addresses (:class:`set` of :ivar set addrs: Virtual Host addresses (:class:`set` of
:class:`common.Addr`) :class:`common.Addr`)
:ivar set names: Server names/aliases of vhost :ivar str name: ServerName of VHost
:ivar list aliases: Server aliases of vhost
(:class:`list` of :class:`str`) (:class:`list` of :class:`str`)
:ivar bool ssl: SSLEngine on in vhost :ivar bool ssl: SSLEngine on in vhost
:ivar bool enabled: Virtual host is enabled :ivar bool enabled: Virtual host is enabled
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.
""" """
def __init__(self, filep, path, addrs, ssl, enabled, names=None): # ?: is used for not returning enclosed characters
strip_name = re.compile(r"^(?:.+://)?([^ :$]*)")
def __init__(self, filep, path, addrs, ssl, enabled, name=None, aliases=None):
# pylint: disable=too-many-arguments # pylint: disable=too-many-arguments
"""Initialize a VH.""" """Initialize a VH."""
self.filep = filep self.filep = filep
self.path = path self.path = path
self.addrs = addrs self.addrs = addrs
self.names = set() if names is None else set(names) self.name = name
self.aliases = aliases if aliases is not None else set()
self.ssl = ssl self.ssl = ssl
self.enabled = enabled self.enabled = enabled
def add_name(self, name): def get_names(self):
"""Add name to vhost.""" """Return a set of all names."""
self.names.add(name) all_names = set()
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): def __str__(self):
return ( return (
"File: {filename}\n" "File: {filename}\n"
"Vhost path: {vhpath}\n" "Vhost path: {vhpath}\n"
"Addresses: {addrs}\n" "Addresses: {addrs}\n"
"Names: {names}\n" "Name: {name}\n"
"Aliases: {aliases}\n"
"TLS Enabled: {tls}\n" "TLS Enabled: {tls}\n"
"Site Enabled: {active}".format( "Site Enabled: {active}".format(
filename=self.filep, filename=self.filep,
vhpath=self.path, vhpath=self.path,
addrs=", ".join(str(addr) for addr in self.addrs), addrs=", ".join(str(addr) for addr in self.addrs),
names=", ".join(name for name in self.names), 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", tls="Yes" if self.ssl else "No",
active="Yes" if self.enabled else "No")) active="Yes" if self.enabled else "No"))
@@ -48,7 +149,73 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
if isinstance(other, self.__class__): if isinstance(other, self.__class__):
return (self.filep == other.filep and self.path == other.path and return (self.filep == other.filep and self.path == other.path and
self.addrs == other.addrs and self.addrs == other.addrs and
self.names == other.names and self.get_names() == other.get_names() and
self.ssl == other.ssl and self.enabled == other.enabled) self.ssl == other.ssl and self.enabled == other.enabled)
return False return False
def __ne__(self, other):
return not self.__eq__(other)
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):
"""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.
.. todo:: Handle _default_
"""
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
# 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()
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
+286 -119
View File
@@ -1,33 +1,175 @@
"""ApacheParser is a member object of the ApacheConfigurator class.""" """ApacheParser is a member object of the ApacheConfigurator class."""
import fnmatch
import itertools
import logging
import os import os
import re import re
import subprocess
from letsencrypt import errors from letsencrypt import errors
logger = logging.getLogger(__name__)
class ApacheParser(object): class ApacheParser(object):
"""Class handles the fine details of parsing the Apache Configuration. """Class handles the fine details of parsing the Apache Configuration.
:ivar str root: Normalized abosulte path to the server root .. todo:: Make parsing general... remove sites-available etc...
:ivar str root: Normalized absolute path to the server root
directory. Without trailing slash. directory. Without trailing slash.
:ivar str root: Server root
: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, aug, root, ctl):
# Note: Order is important here.
# This uses the binary, so it can be done first.
# https://httpd.apache.org/docs/2.4/mod/core.html#define
# https://httpd.apache.org/docs/2.4/mod/core.html#ifdefine
# This only handles invocation parameters and Define directives!
self.variables = {}
self.update_runtime_variables(ctl)
def __init__(self, aug, root, ssl_options):
# Find configuration root and make sure augeas can parse it.
self.aug = aug self.aug = aug
# Find configuration root and make sure augeas can parse it.
self.root = os.path.abspath(root) self.root = os.path.abspath(root)
self.loc = self._set_locations(ssl_options) self.loc = {"root": self._find_config_root()}
self._parse_file(self.loc["root"]) self._parse_file(self.loc["root"])
# This problem has been fixed in Augeas 1.0
self.standardize_excl()
# Temporarily set modules to be empty, so that find_dirs can work
# https://httpd.apache.org/docs/2.4/mod/core.html#ifmodule
# This needs to come before locations are set.
self.modules = set()
self._init_modules()
# Set up rest of locations
self.loc.update(self._set_locations())
# Must also attempt to parse sites-available or equivalent # Must also attempt to parse sites-available or equivalent
# Sites-available is not included naturally in configuration # Sites-available is not included naturally in configuration
self._parse_file(os.path.join(self.root, "sites-available") + "/*") self._parse_file(os.path.join(self.root, "sites-available") + "/*")
# This problem has been fixed in Augeas 1.0 def _init_modules(self):
self.standardize_excl() """Iterates on the configuration until no new modules are loaded.
def add_dir_to_ifmodssl(self, aug_conf_path, directive, val): ..todo:: This should be attempted to be done with a binary to avoid
the iteration issue. Else... parse and enable mods at same time.
"""
matches = self.find_dir("LoadModule")
iterator = iter(matches)
# Make sure prev_size != cur_size for do: while: iteration
prev_size = -1
while len(self.modules) != prev_size:
prev_size = len(self.modules)
for match_name, match_filename in itertools.izip(
iterator, iterator):
self.modules.add(self.get_arg(match_name))
self.modules.add(
os.path.basename(self.get_arg(match_filename))[:-2] + "c")
def update_runtime_variables(self, ctl):
""""
.. note:: Compile time variables (apache2ctl -V) are not used within the
dynamic configuration files. These should not be parsed or
interpreted.
.. todo:: Create separate compile time variables... simply for arg_get()
"""
stdout = self._get_runtime_cfg(ctl)
variables = dict()
matches = re.compile(r"Define: ([^ \n]*)").findall(stdout)
try:
matches.remove("DUMP_RUN_CFG")
except ValueError:
raise errors.PluginError("Unable to parse runtime variables")
for match in matches:
if match.count("=") > 1:
logger.error("Unexpected number of equal signs in "
"apache2ctl -D DUMP_RUN_CFG")
raise errors.PluginError(
"Error parsing Apache runtime variables")
parts = match.partition("=")
variables[parts[0]] = parts[2]
self.variables = variables
def _get_runtime_cfg(self, ctl): # pylint: disable=no-self-use
"""Get runtime configuration info.
:returns: stdout from DUMP_RUN_CFG
"""
try:
proc = subprocess.Popen(
[ctl, "-D", "DUMP_RUN_CFG"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
stdout, stderr = proc.communicate()
except (OSError, ValueError):
logger.error(
"Error accessing %s for runtime parameters!%s", ctl, os.linesep)
raise errors.MisconfigurationError(
"Error accessing loaded Apache parameters: %s", ctl)
# Small errors that do not impede
if proc.returncode != 0:
logger.warn("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 in range(len(matches)):
if matches[i].endswith("/arg"):
filtered.append(matches[i][:-4])
else:
for i in range(len(matches)):
if matches[i].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 directive and value to IfMod ssl block.
Adds given directive and value along configuration path within Adds given directive and value along configuration path within
@@ -35,8 +177,9 @@ class ApacheParser(object):
the file, it is created. the file, it is created.
:param str aug_conf_path: Desired Augeas config path to add directive :param str aug_conf_path: Desired Augeas config path to add directive
:param str directive: Directive you would like to add :param str directive: Directive you would like to add, e.g. Listen
:param str val: Value of directive ie. Listen 443, 443 is the value :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? # TODO: Add error checking code... does the path given even exist?
@@ -46,7 +189,12 @@ class ApacheParser(object):
self.aug.insert(if_mod_path + "arg", "directive", False) self.aug.insert(if_mod_path + "arg", "directive", False)
nvh_path = if_mod_path + "directive[1]" nvh_path = if_mod_path + "directive[1]"
self.aug.set(nvh_path, directive) self.aug.set(nvh_path, directive)
self.aug.set(nvh_path + "/arg", val) 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): def _get_ifmod(self, aug_conf_path, mod):
"""Returns the path to <IfMod mod> and creates one if it doesn't exist. """Returns the path to <IfMod mod> and creates one if it doesn't exist.
@@ -65,7 +213,7 @@ class ApacheParser(object):
# Strip off "arg" at end of first ifmod path # Strip off "arg" at end of first ifmod path
return if_mods[0][:len(if_mods[0]) - 3] return if_mods[0][:len(if_mods[0]) - 3]
def add_dir(self, aug_conf_path, directive, arg): def add_dir(self, aug_conf_path, directive, args):
"""Appends directive to the end fo the file given by aug_conf_path. """Appends directive to the end fo the file given by aug_conf_path.
.. note:: Not added to AugeasConfigurator because it may depend .. note:: Not added to AugeasConfigurator because it may depend
@@ -73,24 +221,24 @@ class ApacheParser(object):
:param str aug_conf_path: Augeas configuration path to add directive :param str aug_conf_path: Augeas configuration path to add directive
:param str directive: Directive to add :param str directive: Directive to add
:param str arg: Value of the directive. ie. Listen 443, 443 is arg :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) self.aug.set(aug_conf_path + "/directive[last() + 1]", directive)
if isinstance(arg, list): if isinstance(args, list):
for i, value in enumerate(arg, 1): for i, value in enumerate(args, 1):
self.aug.set( self.aug.set(
"%s/directive[last()]/arg[%d]" % (aug_conf_path, i), value) "%s/directive[last()]/arg[%d]" % (aug_conf_path, i), value)
else: else:
self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) self.aug.set(aug_conf_path + "/directive[last()]/arg", args)
def find_dir(self, directive, arg=None, start=None): def find_dir(self, directive, arg=None, start=None, exclude=True):
"""Finds directive in the configuration. """Finds directive in the configuration.
Recursively searches through config files to find directives Recursively searches through config files to find directives
Directives should be in the form of a case insensitive regex currently Directives should be in the form of a case insensitive regex currently
.. todo:: Add order to directives returned. Last directive comes last..
.. todo:: arg should probably be a list .. todo:: arg should probably be a list
Note: Augeas is inherently case sensitive while Apache is case Note: Augeas is inherently case sensitive while Apache is case
@@ -101,20 +249,19 @@ class ApacheParser(object):
compatibility. compatibility.
:param str directive: Directive to look for :param str directive: Directive to look for
:param arg: Specific value directive must have, None if all should :param arg: Specific value directive must have, None if all should
be considered be considered
:type arg: str or None :type arg: str or None
:param str start: Beginning Augeas path to begin looking :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... # Cannot place member variable in the definition of the function so...
if not start: if not start:
start = get_aug_path(self.loc["root"]) start = get_aug_path(self.loc["root"])
# Debug code
# print "find_dir:", directive, "arg:", arg, " | Looking in:", start
# No regexp code # No regexp code
# if arg is None: # if arg is None:
# matches = self.aug.match(start + # matches = self.aug.match(start +
@@ -127,32 +274,101 @@ class ApacheParser(object):
# includes = self.aug.match(start + # includes = self.aug.match(start +
# "//* [self::directive='Include']/* [label()='arg']") # "//* [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: if arg is None:
matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" arg_suffix = "/arg"
% (start, directive)))
else: else:
matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" arg_suffix = "/*[self::arg=~regexp('%s')]" % case_i(arg)
"[self::arg=~regexp('%s')]" %
(start, directive, arg)))
incl_regex = "(%s)|(%s)" % (case_i('Include'), ordered_matches = []
case_i('IncludeOptional'))
includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " # TODO: Wildcards should be included in alphabetical order
"[label()='arg']" % (start, incl_regex))) # https://httpd.apache.org/docs/2.4/mod/core.html#include
for match in matches:
dir_ = self.aug.get(match).lower()
if dir_ == "include" or dir_ == "includeoptional":
# start[6:] to strip off /files
#print self._get_include_path(self.get_arg(match +"/arg")), directive, arg
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))
# for inc in includes: return ordered_matches
# print inc, self.aug.get(inc)
for include in includes: def get_arg(self, match):
# start[6:] to strip off /files """Uses augeas.get to get argument value and interprets result.
matches.extend(self.find_dir(
directive, arg, self._get_include_path(
strip_dir(start[6:]), self.aug.get(include))))
return matches This also converts all variables and parameters appropriately.
def _get_include_path(self, cur_dir, arg): """
value = self.aug.get(match)
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 into Augeas path.
Converts an Apache Include directive argument into an Augeas Converts an Apache Include directive argument into an Augeas
@@ -160,29 +376,12 @@ class ApacheParser(object):
.. todo:: convert to use os.path.join() .. todo:: convert to use os.path.join()
:param str cur_dir: current working directory
:param str arg: Argument of Include directive :param str arg: Argument of Include directive
:returns: Augeas path string :returns: Augeas path string
:rtype: str :rtype: str
""" """
# Sanity check argument - maybe
# Question: what can the attacker do with control over this string
# Effect parse file... maybe exploit unknown errors in Augeas
# If the attacker can Include anything though... and this function
# only operates on Apache real config data... then the attacker has
# already won.
# Perhaps it is better to simply check the permissions on all
# included files?
# check_config to validate apache config doesn't work because it
# would create a race condition between the check and this input
# TODO: Maybe... although I am convinced we have lost if
# Apache files can't be trusted. The augeas include path
# should be made to be exact.
# Check to make sure only expected characters are used <- maybe remove # Check to make sure only expected characters are used <- maybe remove
# validChars = re.compile("[a-zA-Z0-9.*?_-/]*") # validChars = re.compile("[a-zA-Z0-9.*?_-/]*")
# matchObj = validChars.match(arg) # matchObj = validChars.match(arg)
@@ -192,58 +391,48 @@ class ApacheParser(object):
# Standardize the include argument based on server root # Standardize the include argument based on server root
if not arg.startswith("/"): if not arg.startswith("/"):
arg = cur_dir + arg # Normpath will condense ../
# conf/ is a special variable for ServerRoot in Apache arg = os.path.normpath(os.path.join(self.root, arg))
elif arg.startswith("conf/"):
arg = self.root + arg[4:]
# TODO: Test if Apache allows ../ or ~/ for Includes
# Attempts to add a transform to the file if one does not already exist # Attempts to add a transform to the file if one does not already exist
self._parse_file(arg) 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 # Argument represents an fnmatch regular expression, convert it
# Split up the path and convert each into an Augeas accepted regex # Split up the path and convert each into an Augeas accepted regex
# then reassemble # then reassemble
if "*" in arg or "?" in arg: split_arg = arg.split("/")
split_arg = arg.split("/") for idx, split in enumerate(split_arg):
for idx, split in enumerate(split_arg): if any(char in ApacheParser.fnmatch_chars for char in split):
# * and ? are the two special fnmatch characters # Turn it into a augeas regex
if "*" in split or "?" in split: # TODO: Can this instead be an augeas glob instead of regex
# Turn it into a augeas regex split_arg[idx] = ("* [label()=~regexp('%s')]" %
# TODO: Can this instead be an augeas glob instead of regex self.fnmatch_to_re(split))
split_arg[idx] = ("* [label()=~regexp('%s')]" % # Reassemble the argument
self.fnmatch_to_re(split)) # Note: This also normalizes the argument /serverroot/ -> /serverroot
# Reassemble the argument arg = "/".join(split_arg)
arg = "/".join(split_arg)
# If the include is a directory, just return the directory as a file
if arg.endswith("/"):
return get_aug_path(arg[:len(arg)-1])
return get_aug_path(arg) return get_aug_path(arg)
def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use
"""Method converts Apache's basic fnmatch to regular expression. """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, similar to globs :param str clean_fn_match: Apache style filename match, similar to globs
:returns: regex suitable for augeas :returns: regex suitable for augeas
:rtype: str :rtype: str
""" """
# Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py # This strips off final /Z(?ms)
regex = "" return fnmatch.translate(clean_fn_match)[:-7]
for letter in clean_fn_match:
if letter == '.':
regex = regex + r"\."
elif letter == '*':
regex = regex + ".*"
# According to apache.org ? shouldn't appear
# but in case it is valid...
elif letter == '?':
regex = regex + "."
else:
regex = regex + letter
return regex
def _parse_file(self, filepath): def _parse_file(self, filepath):
"""Parse file with Augeas """Parse file with Augeas
@@ -318,15 +507,14 @@ class ApacheParser(object):
self.aug.load() self.aug.load()
def _set_locations(self, ssl_options): def _set_locations(self):
"""Set default location for directives. """Set default location for directives.
Locations are given as file_paths Locations are given as file_paths
.. todo:: Make sure that files are included .. todo:: Make sure that files are included
""" """
root = self._find_config_root() default = self._set_user_config_file()
default = self._set_user_config_file(root)
temp = os.path.join(self.root, "ports.conf") temp = os.path.join(self.root, "ports.conf")
if os.path.isfile(temp): if os.path.isfile(temp):
@@ -336,8 +524,7 @@ class ApacheParser(object):
listen = default listen = default
name = default name = default
return {"root": root, "default": default, "listen": listen, return {"default": default, "listen": listen, "name": name}
"name": name, "ssl_options": ssl_options}
def _find_config_root(self): def _find_config_root(self):
"""Find the Apache Configuration Root file.""" """Find the Apache Configuration Root file."""
@@ -349,7 +536,7 @@ class ApacheParser(object):
raise errors.NoInstallationError("Could not find configuration root") raise errors.NoInstallationError("Could not find configuration root")
def _set_user_config_file(self, root): def _set_user_config_file(self):
"""Set the appropriate user configuration file """Set the appropriate user configuration file
.. todo:: This will have to be updated for other distros versions .. todo:: This will have to be updated for other distros versions
@@ -360,12 +547,11 @@ class ApacheParser(object):
# Basic check to see if httpd.conf exists and # Basic check to see if httpd.conf exists and
# in hierarchy via direct include # in hierarchy via direct include
# httpd.conf was very common as a user file in Apache 2.2 # httpd.conf was very common as a user file in Apache 2.2
if (os.path.isfile(os.path.join(self.root, 'httpd.conf')) and if (os.path.isfile(os.path.join(self.root, "httpd.conf")) and
self.find_dir( self.find_dir("Include", "httpd.conf", self.loc["root"])):
case_i("Include"), case_i("httpd.conf"), root)): return os.path.join(self.root, "httpd.conf")
return os.path.join(self.root, 'httpd.conf')
else: else:
return os.path.join(self.root, 'apache2.conf') return os.path.join(self.root, "apache2.conf")
def case_i(string): def case_i(string):
@@ -391,22 +577,3 @@ def get_aug_path(file_path):
""" """
return "/files%s" % file_path return "/files%s" % file_path
def strip_dir(path):
"""Returns directory of file path.
.. todo:: Replace this with Python standard function
:param str path: path is a file path. not an augeas section or
directive path
:returns: directory
:rtype: str
"""
index = path.rfind("/")
if index > 0:
return path[:index+1]
# No directory
return ""
@@ -0,0 +1,115 @@
"""Test for letsencrypt_apache.augeas_configurator."""
import os
import shutil
import unittest
import mock
from letsencrypt import errors
from letsencrypt_apache.tests import util
class AugeasConfiguratorTest(util.ApacheTest):
"""Test for Augeas Configurator base class."""
def setUp(self): # pylint: disable=arguments-differ
super(AugeasConfiguratorTest, self).setUp()
self.config = util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir)
self.vh_truth = util.get_vh_truth(
self.temp_dir, "debian_apache_2_4/two_vhost_80")
def tearDown(self):
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
shutil.rmtree(self.temp_dir)
def test_bad_parse(self):
# pylint: disable=protected-access
self.config.parser._parse_file(os.path.join(
self.config.parser.root, "conf-available", "bad_conf_file.conf"))
self.assertRaises(
errors.PluginError, self.config.check_parsing_errors, "httpd.aug")
def test_bad_save(self):
mock_save = mock.Mock()
mock_save.side_effect = IOError
self.config.aug.save = mock_save
self.assertRaises(errors.PluginError, self.config.save)
def test_bad_save_checkpoint(self):
self.config.reverter.add_to_checkpoint = mock.Mock(
side_effect=errors.ReverterError)
self.config.parser.add_dir(
self.vh_truth[0].path, "Test", "bad_save_ckpt")
self.assertRaises(errors.PluginError, self.config.save)
def test_bad_save_finalize_checkpoint(self):
self.config.reverter.finalize_checkpoint = mock.Mock(
side_effect=errors.ReverterError)
self.config.parser.add_dir(
self.vh_truth[0].path, "Test", "bad_save_ckpt")
self.assertRaises(errors.PluginError, self.config.save, "Title")
def test_finalize_save(self):
mock_finalize = mock.Mock()
self.config.reverter = mock_finalize
self.config.save("Example Title")
self.assertTrue(mock_finalize.is_called)
def test_recovery_routine(self):
mock_load = mock.Mock()
self.config.aug.load = mock_load
self.config.recovery_routine()
self.assertEqual(mock_load.call_count, 1)
def test_recovery_routine_error(self):
self.config.reverter.recovery_routine = mock.Mock(
side_effect=errors.ReverterError)
self.assertRaises(
errors.PluginError, self.config.recovery_routine)
def test_revert_challenge_config(self):
mock_load = mock.Mock()
self.config.aug.load = mock_load
self.config.revert_challenge_config()
self.assertEqual(mock_load.call_count, 1)
def test_revert_challenge_config_error(self):
self.config.reverter.revert_temporary_config = mock.Mock(
side_effect=errors.ReverterError)
self.assertRaises(
errors.PluginError, self.config.revert_challenge_config)
def test_rollback_checkpoints(self):
mock_load = mock.Mock()
self.config.aug.load = mock_load
self.config.rollback_checkpoints()
self.assertEqual(mock_load.call_count, 1)
def test_rollback_error(self):
self.config.reverter.rollback_checkpoints = mock.Mock(
side_effect=errors.ReverterError)
self.assertRaises(errors.PluginError, self.config.rollback_checkpoints)
def test_view_config_changes(self):
self.config.view_config_changes()
def test_view_config_changes_error(self):
self.config.reverter.view_config_changes = mock.Mock(
side_effect=errors.ReverterError)
self.assertRaises(errors.PluginError, self.config.view_config_changes)
if __name__ == "__main__":
unittest.main() # pragma: no cover
@@ -0,0 +1,112 @@
"""Tests for letsencrypt_apache.parser."""
import os
import shutil
import unittest
from letsencrypt import errors
from letsencrypt_apache.tests import util
class ComplexParserTest(util.ParserTest):
"""Apache Parser Test."""
def setUp(self): # pylint: disable=arguments-differ
super(ComplexParserTest, self).setUp(
"complex_parsing", "complex_parsing")
self.setup_variables()
# This needs to happen after due to setup_variables not being run
# until after
self.parser._init_modules() # pylint: disable=protected-access
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def setup_variables(self):
"""Set up variables for parser."""
self.parser.variables.update(
{
"COMPLEX": "",
"tls_port": "1234",
"fnmatch_filename": "test_fnmatch.conf",
}
)
def test_filter_args_num(self):
"""Note: This may also fail do to Include conf-enabled/ syntax."""
matches = self.parser.find_dir("TestArgsDirective")
self.assertEqual(len(self.parser.filter_args_num(matches, 1)), 3)
self.assertEqual(len(self.parser.filter_args_num(matches, 2)), 2)
self.assertEqual(len(self.parser.filter_args_num(matches, 3)), 1)
def test_basic_variable_parsing(self):
matches = self.parser.find_dir("TestVariablePort")
self.assertEqual(len(matches), 1)
self.assertEqual(self.parser.get_arg(matches[0]), "1234")
def test_invalid_variable_parsing(self):
del self.parser.variables["tls_port"]
matches = self.parser.find_dir("TestVariablePort")
self.assertRaises(
errors.PluginError, self.parser.get_arg, matches[0])
def test_basic_ifdefine(self):
self.assertEqual(len(self.parser.find_dir("VAR_DIRECTIVE")), 2)
self.assertEqual(len(self.parser.find_dir("INVALID_VAR_DIRECTIVE")), 0)
def test_basic_ifmodule(self):
self.assertEqual(len(self.parser.find_dir("MOD_DIRECTIVE")), 2)
self.assertEqual(
len(self.parser.find_dir("INVALID_MOD_DIRECTIVE")), 0)
def test_nested(self):
self.assertEqual(len(self.parser.find_dir("NESTED_DIRECTIVE")), 3)
self.assertEqual(
len(self.parser.find_dir("INVALID_NESTED_DIRECTIVE")), 0)
def test_load_modules(self):
"""If only first is found, there is bad variable parsing."""
self.assertTrue("status_module" in self.parser.modules)
self.assertTrue("mod_status.c" in self.parser.modules)
# This is in an IfDefine
self.assertTrue("ssl_module" in self.parser.modules)
self.assertTrue("mod_ssl.c" in self.parser.modules)
def verify_fnmatch(self, arg, hit=True):
"""Test if Include was correctly parsed."""
from letsencrypt_apache import parser
self.parser.add_dir(parser.get_aug_path(self.parser.loc["default"]),
"Include", [arg])
if hit:
self.assertTrue(self.parser.find_dir("FNMATCH_DIRECTIVE"))
else:
self.assertFalse(self.parser.find_dir("FNMATCH_DIRECTIVE"))
def test_include(self):
self.verify_fnmatch("test_fnmatch.?onf")
def test_include_complex(self):
self.verify_fnmatch("../complex_parsing/[te][te]st_*.?onf")
def test_include_fullpath(self):
self.verify_fnmatch(os.path.join(self.config_path, "test_fnmatch.conf"))
def test_include_variable(self):
self.verify_fnmatch("../complex_parsing/${fnmatch_filename}")
def test_include_missing(self):
# This should miss
self.verify_fnmatch("test_*.onf", False)
if __name__ == "__main__":
unittest.main() # pragma: no cover
@@ -1,7 +1,8 @@
# pylint: disable=too-many-public-methods
"""Test for letsencrypt_apache.configurator.""" """Test for letsencrypt_apache.configurator."""
import os import os
import re
import shutil import shutil
import socket
import unittest import unittest
import mock import mock
@@ -12,27 +13,22 @@ from letsencrypt import achallenges
from letsencrypt import errors from letsencrypt import errors
from letsencrypt import le_util from letsencrypt import le_util
from letsencrypt.plugins import common
from letsencrypt.tests import acme_util from letsencrypt.tests import acme_util
from letsencrypt_apache import configurator from letsencrypt_apache import configurator
from letsencrypt_apache import parser from letsencrypt_apache import obj
from letsencrypt_apache.tests import util from letsencrypt_apache.tests import util
class TwoVhost80Test(util.ApacheTest): class TwoVhost80Test(util.ApacheTest):
"""Test two standard well configured HTTP vhosts.""" """Test two standard well-configured HTTP vhosts."""
def setUp(self): def setUp(self): # pylint: disable=arguments-differ
super(TwoVhost80Test, self).setUp() super(TwoVhost80Test, self).setUp()
with mock.patch("letsencrypt_apache.configurator.ApacheConfigurator." self.config = util.get_apache_configurator(
"mod_loaded") as mock_load: self.config_path, self.config_dir, self.work_dir)
mock_load.return_value = True
self.config = util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir)
self.vh_truth = util.get_vh_truth( self.vh_truth = util.get_vh_truth(
self.temp_dir, "debian_apache_2_4/two_vhost_80") self.temp_dir, "debian_apache_2_4/two_vhost_80")
@@ -42,16 +38,55 @@ class TwoVhost80Test(util.ApacheTest):
shutil.rmtree(self.config_dir) shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir) shutil.rmtree(self.work_dir)
@mock.patch("letsencrypt_apache.parser.ApacheParser")
def test_prepare_version(self, _):
self.config.version = None
self.config.config_test = mock.Mock()
self.config.get_version = mock.Mock(return_value=(1, 1))
self.assertRaises(
errors.NotSupportedError, self.config.prepare)
def test_add_parser_arguments(self): # pylint: disable=no-self-use
from letsencrypt_apache.configurator import ApacheConfigurator
# Weak test..
ApacheConfigurator.add_parser_arguments(mock.MagicMock())
def test_get_all_names(self): def test_get_all_names(self):
names = self.config.get_all_names() names = self.config.get_all_names()
self.assertEqual(names, set( self.assertEqual(names, set(
["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"]))
@mock.patch("letsencrypt_apache.configurator.socket.gethostbyaddr")
def test_get_all_names_addrs(self, mock_gethost):
mock_gethost.side_effect = [("google.com", "", ""), socket.error]
vhost = obj.VirtualHost(
"fp", "ap",
set([obj.Addr(("8.8.8.8", "443")),
obj.Addr(("zombo.com",)),
obj.Addr(("192.168.1.2"))]),
True, False)
self.config.vhosts.append(vhost)
names = self.config.get_all_names()
self.assertEqual(len(names), 5)
self.assertTrue("zombo.com" in names)
self.assertTrue("google.com" in names)
self.assertTrue("letsencrypt.demo" in names)
def test_add_servernames_alias(self):
self.config.parser.add_dir(
self.vh_truth[2].path, "ServerAlias", ["*.le.co"])
self.config._add_servernames(self.vh_truth[2]) # pylint: disable=protected-access
self.assertEqual(
self.vh_truth[2].get_names(), set(["*.le.co", "ip-172-30-0-17"]))
def test_get_virtual_hosts(self): def test_get_virtual_hosts(self):
"""Make sure all vhosts are being properly found. """Make sure all vhosts are being properly found.
.. note:: If test fails, only finding 1 Vhost... it is likely that .. note:: If test fails, only finding 1 Vhost... it is likely that
it is a problem with is_enabled. it is a problem with is_enabled. If finding only 3, likely is_ssl
""" """
vhs = self.config.get_virtual_hosts() vhs = self.config.get_virtual_hosts()
@@ -63,9 +98,77 @@ class TwoVhost80Test(util.ApacheTest):
if vhost == truth: if vhost == truth:
found += 1 found += 1
break break
else:
raise Exception("Missed: %s" % vhost) # pragma: no cover
self.assertEqual(found, 4) self.assertEqual(found, 4)
@mock.patch("letsencrypt_apache.display_ops.select_vhost")
def test_choose_vhost_none_avail(self, mock_select):
mock_select.return_value = None
self.assertRaises(
errors.PluginError, self.config.choose_vhost, "none.com")
@mock.patch("letsencrypt_apache.display_ops.select_vhost")
def test_choose_vhost_select_vhost_ssl(self, mock_select):
mock_select.return_value = self.vh_truth[1]
self.assertEqual(
self.vh_truth[1], self.config.choose_vhost("none.com"))
@mock.patch("letsencrypt_apache.display_ops.select_vhost")
def test_choose_vhost_select_vhost_non_ssl(self, mock_select):
mock_select.return_value = self.vh_truth[0]
chosen_vhost = self.config.choose_vhost("none.com")
self.assertEqual(
self.vh_truth[0].get_names(), chosen_vhost.get_names())
# Make sure we go from HTTP -> HTTPS
self.assertFalse(self.vh_truth[0].ssl)
self.assertTrue(chosen_vhost.ssl)
@mock.patch("letsencrypt_apache.display_ops.select_vhost")
def test_choose_vhost_select_vhost_conflicting_non_ssl(self, mock_select):
mock_select.return_value = self.vh_truth[3]
conflicting_vhost = obj.VirtualHost(
"path", "aug_path", set([obj.Addr.fromstring("*:443")]), True, True)
self.config.vhosts.append(conflicting_vhost)
self.assertRaises(
errors.PluginError, self.config.choose_vhost, "none.com")
def test_find_best_vhost(self):
# pylint: disable=protected-access
self.assertEqual(
self.vh_truth[3], self.config._find_best_vhost("letsencrypt.demo"))
self.assertEqual(
self.vh_truth[0],
self.config._find_best_vhost("encryption-example.demo"))
self.assertTrue(
self.config._find_best_vhost("does-not-exist.com") is None)
def test_find_best_vhost_variety(self):
# pylint: disable=protected-access
ssl_vh = obj.VirtualHost(
"fp", "ap", set([obj.Addr(("*", "443")), obj.Addr(("zombo.com",))]),
True, False)
self.config.vhosts.append(ssl_vh)
self.assertEqual(self.config._find_best_vhost("zombo.com"), ssl_vh)
def test_find_best_vhost_default(self):
# pylint: disable=protected-access
# Assume only the two default vhosts.
self.config.vhosts = [
vh for vh in self.config.vhosts
if vh.name not in ["letsencrypt.demo", "encryption-example.demo"]
]
self.assertEqual(
self.config._find_best_vhost("example.demo"), self.vh_truth[2])
def test_non_default_vhosts(self):
# pylint: disable=protected-access
self.assertEqual(len(self.config._non_default_vhosts()), 3)
def test_is_site_enabled(self): def test_is_site_enabled(self):
"""Test if site is enabled. """Test if site is enabled.
@@ -80,7 +183,59 @@ class TwoVhost80Test(util.ApacheTest):
self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep))
self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep))
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
@mock.patch("letsencrypt_apache.parser.subprocess.Popen")
def test_enable_mod(self, mock_popen, mock_exe_exists, mock_run_script):
mock_popen().communicate.return_value = ("Define: DUMP_RUN_CFG", "")
mock_popen().returncode = 0
mock_exe_exists.return_value = True
self.config.enable_mod("ssl")
self.assertTrue("ssl_module" in self.config.parser.modules)
self.assertTrue("mod_ssl.c" in self.config.parser.modules)
self.assertTrue(mock_run_script.called)
def test_enable_mod_unsupported_dirs(self):
shutil.rmtree(os.path.join(self.config.parser.root, "mods-enabled"))
self.assertRaises(
errors.NotSupportedError, self.config.enable_mod, "ssl")
@mock.patch("letsencrypt.le_util.exe_exists")
def test_enable_mod_no_disable(self, mock_exe_exists):
mock_exe_exists.return_value = False
self.assertRaises(
errors.MisconfigurationError, self.config.enable_mod, "ssl")
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
@mock.patch("letsencrypt_apache.parser.subprocess.Popen")
def test_enable_site(self, mock_popen, mock_exe_exists, mock_run_script):
mock_popen().returncode = 0
mock_popen().communicate.return_value = ("Define: DUMP_RUN_CFG", "")
mock_exe_exists.return_value = True
# Default 443 vhost
self.assertFalse(self.vh_truth[1].enabled)
self.config.enable_site(self.vh_truth[1])
self.assertTrue(self.vh_truth[1].enabled)
# Mod enabled
self.assertTrue(mock_run_script.called)
# Go again to make sure nothing fails
self.config.enable_site(self.vh_truth[1])
def test_enable_site_failure(self):
self.assertRaises(
errors.NotSupportedError,
self.config.enable_site,
obj.VirtualHost("asdf", "afsaf", set(), False, False))
def test_deploy_cert(self): def test_deploy_cert(self):
self.config.parser.modules.add("ssl_module")
self.config.parser.modules.add("mod_ssl.c")
# Get the default 443 vhost # Get the default 443 vhost
self.config.assoc["random.demo"] = self.vh_truth[1] self.config.assoc["random.demo"] = self.vh_truth[1]
self.config.deploy_cert( self.config.deploy_cert(
@@ -88,15 +243,17 @@ class TwoVhost80Test(util.ApacheTest):
"example/cert.pem", "example/key.pem", "example/cert_chain.pem") "example/cert.pem", "example/key.pem", "example/cert_chain.pem")
self.config.save() self.config.save()
# Verify ssl_module was enabled.
self.assertTrue(self.vh_truth[1].enabled)
self.assertTrue("ssl_module" in self.config.parser.modules)
loc_cert = self.config.parser.find_dir( loc_cert = self.config.parser.find_dir(
parser.case_i("sslcertificatefile"), "sslcertificatefile", "example/cert.pem", self.vh_truth[1].path)
re.escape("example/cert.pem"), self.vh_truth[1].path)
loc_key = self.config.parser.find_dir( loc_key = self.config.parser.find_dir(
parser.case_i("sslcertificateKeyfile"), "sslcertificateKeyfile", "example/key.pem", self.vh_truth[1].path)
re.escape("example/key.pem"), self.vh_truth[1].path)
loc_chain = self.config.parser.find_dir( loc_chain = self.config.parser.find_dir(
parser.case_i("SSLCertificateChainFile"), "SSLCertificateChainFile", "example/cert_chain.pem",
re.escape("example/cert_chain.pem"), self.vh_truth[1].path) self.vh_truth[1].path)
# Verify one directive was found in the correct file # Verify one directive was found in the correct file
self.assertEqual(len(loc_cert), 1) self.assertEqual(len(loc_cert), 1)
@@ -111,16 +268,53 @@ class TwoVhost80Test(util.ApacheTest):
self.assertEqual(configurator.get_file_path(loc_chain[0]), self.assertEqual(configurator.get_file_path(loc_chain[0]),
self.vh_truth[1].filep) self.vh_truth[1].filep)
# One more time for chain directive setting
self.config.deploy_cert(
"random.demo",
"two/cert.pem", "two/key.pem", "two/cert_chain.pem")
self.assertTrue(self.config.parser.find_dir(
"SSLCertificateChainFile", "two/cert_chain.pem",
self.vh_truth[1].path))
def test_deploy_cert_invalid_vhost(self):
self.config.parser.modules.add("ssl_module")
mock_find = mock.MagicMock()
mock_find.return_value = []
self.config.parser.find_dir = mock_find
# Get the default 443 vhost
self.config.assoc["random.demo"] = self.vh_truth[1]
self.assertRaises(
errors.PluginError, self.config.deploy_cert, "random.demo",
"example/cert.pem", "example/key.pem", "example/cert_chain.pem")
def test_is_name_vhost(self): def test_is_name_vhost(self):
addr = common.Addr.fromstring("*:80") addr = obj.Addr.fromstring("*:80")
self.assertTrue(self.config.is_name_vhost(addr)) self.assertTrue(self.config.is_name_vhost(addr))
self.config.version = (2, 2) self.config.version = (2, 2)
self.assertFalse(self.config.is_name_vhost(addr)) self.assertFalse(self.config.is_name_vhost(addr))
def test_add_name_vhost(self): def test_add_name_vhost(self):
self.config.add_name_vhost("*:443") self.config.add_name_vhost(obj.Addr.fromstring("*:443"))
self.config.add_name_vhost(obj.Addr.fromstring("*:80"))
self.assertTrue(self.config.parser.find_dir( self.assertTrue(self.config.parser.find_dir(
"NameVirtualHost", re.escape("*:443"))) "NameVirtualHost", "*:443", exclude=False))
self.assertTrue(self.config.parser.find_dir(
"NameVirtualHost", "*:80"))
def test_prepare_server_https(self):
self.config.parser.modules.add("ssl_module")
mock_find = mock.Mock()
mock_add_dir = mock.Mock()
mock_find.return_value = []
# This will test the Add listen
self.config.parser.find_dir = mock_find
self.config.parser.add_dir_to_ifmodssl = mock_add_dir
self.config.prepare_server_https("443")
self.config.prepare_server_https("8080")
self.assertEqual(mock_add_dir.call_count, 2)
def test_make_vhost_ssl(self): def test_make_vhost_ssl(self):
ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
@@ -133,28 +327,264 @@ class TwoVhost80Test(util.ApacheTest):
self.assertEqual(ssl_vhost.path, self.assertEqual(ssl_vhost.path,
"/files" + ssl_vhost.filep + "/IfModule/VirtualHost") "/files" + ssl_vhost.filep + "/IfModule/VirtualHost")
self.assertEqual(len(ssl_vhost.addrs), 1) self.assertEqual(len(ssl_vhost.addrs), 1)
self.assertEqual(set([common.Addr.fromstring("*:443")]), ssl_vhost.addrs) self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs)
self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) self.assertEqual(ssl_vhost.name, "encryption-example.demo")
self.assertTrue(ssl_vhost.ssl) self.assertTrue(ssl_vhost.ssl)
self.assertFalse(ssl_vhost.enabled) self.assertFalse(ssl_vhost.enabled)
self.assertTrue(self.config.parser.find_dir( self.assertTrue(self.config.parser.find_dir(
"SSLCertificateFile", None, ssl_vhost.path)) "SSLCertificateFile", None, ssl_vhost.path, False))
self.assertTrue(self.config.parser.find_dir( self.assertTrue(self.config.parser.find_dir(
"SSLCertificateKeyFile", None, ssl_vhost.path)) "SSLCertificateKeyFile", None, ssl_vhost.path, False))
self.assertTrue(self.config.parser.find_dir(
"Include", self.ssl_options, ssl_vhost.path))
self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]),
self.config.is_name_vhost(ssl_vhost)) self.config.is_name_vhost(ssl_vhost))
self.assertEqual(len(self.config.vhosts), 5) self.assertEqual(len(self.config.vhosts), 5)
def test_make_vhost_ssl_extra_vhs(self):
self.config.aug.match = mock.Mock(return_value=["p1", "p2"])
self.assertRaises(
errors.PluginError, self.config.make_vhost_ssl, self.vh_truth[0])
def test_make_vhost_ssl_bad_write(self):
mock_open = mock.mock_open()
# This calls open
self.config.reverter.register_file_creation = mock.Mock()
mock_open.side_effect = IOError
with mock.patch("__builtin__.open", mock_open):
self.assertRaises(
errors.PluginError,
self.config.make_vhost_ssl, self.vh_truth[0])
def test_get_ssl_vhost_path(self):
# pylint: disable=protected-access
self.assertTrue(
self.config._get_ssl_vhost_path("example_path").endswith(".conf"))
def test_add_name_vhost_if_necessary(self):
# pylint: disable=protected-access
self.config.save = mock.Mock()
self.config.version = (2, 2)
self.config._add_name_vhost_if_necessary(self.vh_truth[0])
self.assertTrue(self.config.save.called)
@mock.patch("letsencrypt_apache.configurator.dvsni.ApacheDvsni.perform") @mock.patch("letsencrypt_apache.configurator.dvsni.ApacheDvsni.perform")
@mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart") @mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart")
def test_perform(self, mock_restart, mock_dvsni_perform): def test_perform(self, mock_restart, mock_dvsni_perform):
# Only tests functionality specific to configurator.perform # Only tests functionality specific to configurator.perform
# Note: As more challenges are offered this will have to be expanded # Note: As more challenges are offered this will have to be expanded
_, achall1, achall2 = self.get_achalls()
dvsni_ret_val = [
challenges.DVSNIResponse(s="randomS1"),
challenges.DVSNIResponse(s="randomS2"),
]
mock_dvsni_perform.return_value = dvsni_ret_val
responses = self.config.perform([achall1, achall2])
self.assertEqual(mock_dvsni_perform.call_count, 1)
self.assertEqual(responses, dvsni_ret_val)
self.assertEqual(mock_restart.call_count, 1)
@mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart")
def test_cleanup(self, mock_restart):
_, achall1, achall2 = self.get_achalls()
self.config._chall_out.add(achall1) # pylint: disable=protected-access
self.config._chall_out.add(achall2) # pylint: disable=protected-access
self.config.cleanup([achall1])
self.assertFalse(mock_restart.called)
self.config.cleanup([achall2])
self.assertTrue(mock_restart.called)
@mock.patch("letsencrypt_apache.configurator.ApacheConfigurator.restart")
def test_cleanup_no_errors(self, mock_restart):
_, achall1, achall2 = self.get_achalls()
self.config._chall_out.add(achall1) # pylint: disable=protected-access
self.config.cleanup([achall2])
self.assertFalse(mock_restart.called)
self.config.cleanup([achall1, achall2])
self.assertTrue(mock_restart.called)
@mock.patch("letsencrypt.le_util.run_script")
def test_get_version(self, mock_script):
mock_script.return_value = (
"Server Version: Apache/2.4.2 (Debian)", "")
self.assertEqual(self.config.get_version(), (2, 4, 2))
mock_script.return_value = (
"Server Version: Apache/2 (Linux)", "")
self.assertEqual(self.config.get_version(), (2,))
mock_script.return_value = (
"Server Version: Apache (Debian)", "")
self.assertRaises(errors.PluginError, self.config.get_version)
mock_script.return_value = (
"Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "")
self.assertRaises(errors.PluginError, self.config.get_version)
mock_script.side_effect = errors.SubprocessError("Can't find program")
self.assertRaises(errors.PluginError, self.config.get_version)
@mock.patch("letsencrypt_apache.configurator.subprocess.Popen")
def test_restart(self, mock_popen):
"""These will be changed soon enough with reload."""
mock_popen().returncode = 0
mock_popen().communicate.return_value = ("", "")
self.config.restart()
@mock.patch("letsencrypt_apache.configurator.subprocess.Popen")
def test_restart_bad_process(self, mock_popen):
mock_popen.side_effect = OSError
self.assertRaises(errors.MisconfigurationError, self.config.restart)
@mock.patch("letsencrypt_apache.configurator.subprocess.Popen")
def test_restart_failure(self, mock_popen):
mock_popen().communicate.return_value = ("", "")
mock_popen().returncode = 1
self.assertRaises(errors.MisconfigurationError, self.config.restart)
@mock.patch("letsencrypt.le_util.run_script")
def test_config_test(self, _):
self.config.config_test()
@mock.patch("letsencrypt.le_util.run_script")
def test_config_test_bad_process(self, mock_run_script):
mock_run_script.side_effect = errors.SubprocessError
self.assertRaises(errors.MisconfigurationError, self.config.config_test)
def test_get_all_certs_keys(self):
c_k = self.config.get_all_certs_keys()
self.assertEqual(len(c_k), 1)
cert, key, path = next(iter(c_k))
self.assertTrue("cert" in cert)
self.assertTrue("key" in key)
self.assertTrue("default-ssl.conf" in path)
def test_get_all_certs_keys_malformed_conf(self):
self.config.parser.find_dir = mock.Mock(side_effect=[["path"], []])
c_k = self.config.get_all_certs_keys()
self.assertFalse(c_k)
def test_more_info(self):
self.assertTrue(self.config.more_info())
def test_get_chall_pref(self):
self.assertTrue(isinstance(self.config.get_chall_pref(""), list))
def test_temp_install(self):
from letsencrypt_apache.configurator import temp_install
path = os.path.join(self.work_dir, "test_it")
temp_install(path)
self.assertTrue(os.path.isfile(path))
# TEST ENHANCEMENTS
def test_supported_enhancements(self):
self.assertTrue(isinstance(self.config.supported_enhancements(), list))
def test_enhance_unknown_enhancement(self):
self.assertRaises(
errors.PluginError,
self.config.enhance, "letsencrypt.demo", "unknown_enhancement")
@mock.patch("letsencrypt.le_util.run_script")
@mock.patch("letsencrypt.le_util.exe_exists")
def test_redirect_well_formed_http(self, mock_exe, _):
self.config.parser.update_runtime_variables = mock.Mock()
mock_exe.return_value = True
# This will create an ssl vhost for letsencrypt.demo
self.config.enhance("letsencrypt.demo", "redirect")
# These are not immediately available in find_dir even with save() and
# load(). They must be found in sites-available
rw_engine = self.config.parser.find_dir(
"RewriteEngine", "on", self.vh_truth[3].path)
rw_rule = self.config.parser.find_dir(
"RewriteRule", None, self.vh_truth[3].path)
self.assertEqual(len(rw_engine), 1)
# three args to rw_rule
self.assertEqual(len(rw_rule), 3)
self.assertTrue(rw_engine[0].startswith(self.vh_truth[3].path))
self.assertTrue(rw_rule[0].startswith(self.vh_truth[3].path))
self.assertTrue("rewrite_module" in self.config.parser.modules)
def test_redirect_with_conflict(self):
self.config.parser.modules.add("rewrite_module")
ssl_vh = obj.VirtualHost(
"fp", "ap", set([obj.Addr(("*", "443")), obj.Addr(("zombo.com",))]),
True, False)
# No names ^ this guy should conflict.
# pylint: disable=protected-access
self.assertRaises(
errors.PluginError, self.config._enable_redirect, ssl_vh, "")
def test_redirect_twice(self):
# Skip the enable mod
self.config.parser.modules.add("rewrite_module")
self.config.enhance("encryption-example.demo", "redirect")
self.assertRaises(
errors.PluginError,
self.config.enhance, "encryption-example.demo", "redirect")
def test_unknown_rewrite(self):
# Skip the enable mod
self.config.parser.modules.add("rewrite_module")
self.config.parser.add_dir(
self.vh_truth[3].path, "RewriteRule", ["Unknown"])
self.config.save()
self.assertRaises(
errors.PluginError,
self.config.enhance, "letsencrypt.demo", "redirect")
def test_unknown_rewrite2(self):
# Skip the enable mod
self.config.parser.modules.add("rewrite_module")
self.config.parser.add_dir(
self.vh_truth[3].path, "RewriteRule", ["Unknown", "2", "3"])
self.config.save()
self.assertRaises(
errors.PluginError,
self.config.enhance, "letsencrypt.demo", "redirect")
def test_unknown_redirect(self):
# Skip the enable mod
self.config.parser.modules.add("rewrite_module")
self.config.parser.add_dir(
self.vh_truth[3].path, "Redirect", ["Unknown"])
self.config.save()
self.assertRaises(
errors.PluginError,
self.config.enhance, "letsencrypt.demo", "redirect")
def test_create_own_redirect(self):
self.config.parser.modules.add("rewrite_module")
# For full testing... give names...
self.vh_truth[1].name = "default.com"
self.vh_truth[1].aliases = set(["yes.default.com"])
self.config._enable_redirect(self.vh_truth[1], "") # pylint: disable=protected-access
self.assertEqual(len(self.config.vhosts), 5)
def get_achalls(self):
"""Return testing achallenges."""
auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem)
achall1 = achallenges.DVSNI( achall1 = achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
@@ -171,39 +601,16 @@ class TwoVhost80Test(util.ApacheTest):
"pending"), "pending"),
domain="letsencrypt.demo", key=auth_key) domain="letsencrypt.demo", key=auth_key)
dvsni_ret_val = [ return auth_key, achall1, achall2
challenges.DVSNIResponse(s="randomS1"),
challenges.DVSNIResponse(s="randomS2"),
]
mock_dvsni_perform.return_value = dvsni_ret_val def test_make_addrs_sni_ready(self):
responses = self.config.perform([achall1, achall2]) self.config.version = (2, 2)
self.config.make_addrs_sni_ready(
self.assertEqual(mock_dvsni_perform.call_count, 1) set([obj.Addr.fromstring("*:443"), obj.Addr.fromstring("*:80")]))
self.assertEqual(responses, dvsni_ret_val) self.assertTrue(self.config.parser.find_dir(
"NameVirtualHost", "*:80", exclude=False))
self.assertEqual(mock_restart.call_count, 1) self.assertTrue(self.config.parser.find_dir(
"NameVirtualHost", "*:443", exclude=False))
@mock.patch("letsencrypt_apache.configurator.subprocess.Popen")
def test_get_version(self, mock_popen):
mock_popen().communicate.return_value = (
"Server Version: Apache/2.4.2 (Debian)", "")
self.assertEqual(self.config.get_version(), (2, 4, 2))
mock_popen().communicate.return_value = (
"Server Version: Apache/2 (Linux)", "")
self.assertEqual(self.config.get_version(), (2,))
mock_popen().communicate.return_value = (
"Server Version: Apache (Debian)", "")
self.assertRaises(errors.PluginError, self.config.get_version)
mock_popen().communicate.return_value = (
"Server Version: Apache/2.3{0} Apache/2.4.7".format(os.linesep), "")
self.assertRaises(errors.PluginError, self.config.get_version)
mock_popen.side_effect = OSError("Can't find program")
self.assertRaises(errors.PluginError, self.config.get_version)
if __name__ == "__main__": if __name__ == "__main__":
@@ -5,10 +5,12 @@ import unittest
import mock import mock
import zope.component import zope.component
from letsencrypt_apache.tests import util
from letsencrypt.display import util as display_util from letsencrypt.display import util as display_util
from letsencrypt_apache import obj
from letsencrypt_apache.tests import util
class SelectVhostTest(unittest.TestCase): class SelectVhostTest(unittest.TestCase):
"""Tests for letsencrypt_apache.display_ops.select_vhost.""" """Tests for letsencrypt_apache.display_ops.select_vhost."""
@@ -53,6 +55,18 @@ class SelectVhostTest(unittest.TestCase):
self.assertEqual(mock_logger.debug.call_count, 1) self.assertEqual(mock_logger.debug.call_count, 1)
@mock.patch("letsencrypt_apache.display_ops.zope.component.getUtility")
def test_multiple_names(self, mock_util):
mock_util().menu.return_value = (display_util.OK, 4)
self.vhosts.append(
obj.VirtualHost(
"path", "aug_path", set([obj.Addr.fromstring("*:80")]),
False, False,
"wildcard.com", set(["*.wildcard.com"])))
self.assertEqual(self.vhosts[4], self._call(self.vhosts))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
@@ -6,9 +6,9 @@ import mock
from acme import challenges from acme import challenges
from letsencrypt.plugins import common
from letsencrypt.plugins import common_test from letsencrypt.plugins import common_test
from letsencrypt_apache import obj
from letsencrypt_apache.tests import util from letsencrypt_apache.tests import util
@@ -17,14 +17,12 @@ class DvsniPerformTest(util.ApacheTest):
achalls = common_test.DvsniTest.achalls achalls = common_test.DvsniTest.achalls
def setUp(self): def setUp(self): # pylint: disable=arguments-differ
super(DvsniPerformTest, self).setUp() super(DvsniPerformTest, self).setUp()
with mock.patch("letsencrypt_apache.configurator.ApacheConfigurator." config = util.get_apache_configurator(
"mod_loaded") as mock_load: self.config_path, self.config_dir, self.work_dir)
mock_load.return_value = True config.config.dvsni_port = 443
config = util.get_apache_configurator(
self.config_path, self.config_dir, self.work_dir)
from letsencrypt_apache import dvsni from letsencrypt_apache import dvsni
self.sni = dvsni.ApacheDvsni(config) self.sni = dvsni.ApacheDvsni(config)
@@ -38,7 +36,12 @@ class DvsniPerformTest(util.ApacheTest):
resp = self.sni.perform() resp = self.sni.perform()
self.assertEqual(len(resp), 0) self.assertEqual(len(resp), 0)
def test_perform1(self): @mock.patch("letsencrypt.le_util.exe_exists")
@mock.patch("letsencrypt.le_util.run_script")
def test_perform1(self, _, mock_exists):
mock_exists.return_value = True
self.sni.configurator.parser.update_runtime_variables = mock.Mock()
achall = self.achalls[0] achall = self.achalls[0]
self.sni.add_chall(achall) self.sni.add_chall(achall)
mock_setup_cert = mock.MagicMock( mock_setup_cert = mock.MagicMock(
@@ -53,12 +56,14 @@ class DvsniPerformTest(util.ApacheTest):
# Check to make sure challenge config path is included in apache config. # Check to make sure challenge config path is included in apache config.
self.assertEqual( self.assertEqual(
len(self.sni.configurator.parser.find_dir( len(self.sni.configurator.parser.find_dir(
"Include", self.sni.challenge_conf)), "Include", self.sni.challenge_conf)), 1)
1)
self.assertEqual(len(responses), 1) self.assertEqual(len(responses), 1)
self.assertEqual(responses[0].s, "randomS1") self.assertEqual(responses[0].s, "randomS1")
def test_perform2(self): def test_perform2(self):
# Avoid load module
self.sni.configurator.parser.modules.add("ssl_module")
for achall in self.achalls: for achall in self.achalls:
self.sni.add_chall(achall) self.sni.add_chall(achall)
@@ -89,13 +94,8 @@ class DvsniPerformTest(util.ApacheTest):
def test_mod_config(self): def test_mod_config(self):
for achall in self.achalls: for achall in self.achalls:
self.sni.add_chall(achall) self.sni.add_chall(achall)
v_addr1 = [common.Addr(("1.2.3.4", "443")),
common.Addr(("5.6.7.8", "443"))] self.sni._mod_config() # pylint: disable=protected-access
v_addr2 = [common.Addr(("127.0.0.1", "443"))]
ll_addr = []
ll_addr.append(v_addr1)
ll_addr.append(v_addr2)
self.sni._mod_config(ll_addr) # pylint: disable=protected-access
self.sni.configurator.save() self.sni.configurator.save()
self.sni.configurator.parser.find_dir( self.sni.configurator.parser.find_dir(
@@ -109,15 +109,22 @@ class DvsniPerformTest(util.ApacheTest):
vhs.append(self.sni.configurator._create_vhost(match)) vhs.append(self.sni.configurator._create_vhost(match))
self.assertEqual(len(vhs), 2) self.assertEqual(len(vhs), 2)
for vhost in vhs: for vhost in vhs:
if vhost.addrs == set(v_addr1): self.assertEqual(vhost.addrs, set([obj.Addr.fromstring("*:443")]))
self.assertEqual( names = vhost.get_names()
vhost.names, self.assertTrue(
set([self.achalls[0].nonce_domain])) names == set([self.achalls[0].nonce_domain]) or
else: names == set([self.achalls[1].nonce_domain]))
self.assertEqual(vhost.addrs, set(v_addr2))
self.assertEqual( def test_get_dvsni_addrs_default(self):
vhost.names, self.sni.configurator.choose_vhost = mock.Mock(
set([self.achalls[1].nonce_domain])) return_value=obj.VirtualHost(
"path", "aug_path", set([obj.Addr.fromstring("_default_:443")]),
False, False)
)
self.assertEqual(
set([obj.Addr.fromstring("*:443")]),
self.sni.get_dvsni_addrs(self.achalls[0]))
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,27 +1,135 @@
"""Tests for letsencrypt_apache.obj.""" """Tests for letsencrypt_apache.obj."""
import unittest import unittest
from letsencrypt.plugins import common
class VirtualHostTest(unittest.TestCase): class VirtualHostTest(unittest.TestCase):
"""Test the VirtualHost class.""" """Test the VirtualHost class."""
def setUp(self): def setUp(self):
from letsencrypt_apache.obj import Addr
from letsencrypt_apache.obj import VirtualHost from letsencrypt_apache.obj import VirtualHost
self.addr1 = Addr.fromstring("127.0.0.1")
self.addr2 = Addr.fromstring("127.0.0.1:443")
self.addr_default = Addr.fromstring("_default_:443")
self.vhost1 = VirtualHost( self.vhost1 = VirtualHost(
"filep", "vh_path", "filep", "vh_path", set([self.addr1]), False, False, "localhost")
set([common.Addr.fromstring("localhost")]), False, False)
self.vhost1b = VirtualHost(
"filep", "vh_path", set([self.addr1]), False, False, "localhost")
self.vhost2 = VirtualHost(
"fp", "vhp", set([self.addr2]), False, False, "localhost")
def test_eq(self): def test_eq(self):
from letsencrypt_apache.obj import VirtualHost self.assertTrue(self.vhost1b == self.vhost1)
vhost1b = VirtualHost( self.assertFalse(self.vhost1 == self.vhost2)
"filep", "vh_path", self.assertEqual(str(self.vhost1b), str(self.vhost1))
set([common.Addr.fromstring("localhost")]), False, False) self.assertFalse(self.vhost1b == 1234)
self.assertEqual(vhost1b, self.vhost1) def test_ne(self):
self.assertEqual(str(vhost1b), str(self.vhost1)) self.assertTrue(self.vhost1 != self.vhost2)
self.assertFalse(vhost1b == 1234) self.assertFalse(self.vhost1 != self.vhost1b)
def test_conflicts(self):
from letsencrypt_apache.obj import Addr
from letsencrypt_apache.obj import VirtualHost
complex_vh = VirtualHost(
"fp", "vhp",
set([Addr.fromstring("*:443"), Addr.fromstring("1.2.3.4:443")]),
False, False)
self.assertTrue(complex_vh.conflicts([self.addr1]))
self.assertTrue(complex_vh.conflicts([self.addr2]))
self.assertFalse(complex_vh.conflicts([self.addr_default]))
self.assertTrue(self.vhost1.conflicts([self.addr2]))
self.assertFalse(self.vhost1.conflicts([self.addr_default]))
self.assertFalse(self.vhost2.conflicts([self.addr1, self.addr_default]))
def test_same_server(self):
from letsencrypt_apache.obj import VirtualHost
no_name1 = VirtualHost(
"fp", "vhp", set([self.addr1]), False, False, None)
no_name2 = VirtualHost(
"fp", "vhp", set([self.addr2]), False, False, None)
no_name3 = VirtualHost(
"fp", "vhp", set([self.addr_default]),
False, False, None)
no_name4 = VirtualHost(
"fp", "vhp", set([self.addr2, self.addr_default]),
False, False, None)
self.assertTrue(self.vhost1.same_server(self.vhost2))
self.assertTrue(no_name1.same_server(no_name2))
self.assertFalse(self.vhost1.same_server(no_name1))
self.assertFalse(no_name1.same_server(no_name3))
self.assertFalse(no_name1.same_server(no_name4))
class AddrTest(unittest.TestCase):
"""Test obj.Addr."""
def setUp(self):
from letsencrypt_apache.obj import Addr
self.addr = Addr.fromstring("*:443")
self.addr1 = Addr.fromstring("127.0.0.1")
self.addr2 = Addr.fromstring("127.0.0.1:*")
self.addr_defined = Addr.fromstring("127.0.0.1:443")
self.addr_default = Addr.fromstring("_default_:443")
def test_wildcard(self):
self.assertFalse(self.addr.is_wildcard())
self.assertTrue(self.addr1.is_wildcard())
self.assertTrue(self.addr2.is_wildcard())
def test_get_sni_addr(self):
from letsencrypt_apache.obj import Addr
self.assertEqual(
self.addr.get_sni_addr("443"), Addr.fromstring("*:443"))
self.assertEqual(
self.addr.get_sni_addr("225"), Addr.fromstring("*:225"))
self.assertEqual(
self.addr1.get_sni_addr("443"), Addr.fromstring("127.0.0.1"))
def test_conflicts(self):
# Note: Defined IP is more important than defined port in match
self.assertTrue(self.addr.conflicts(self.addr1))
self.assertTrue(self.addr.conflicts(self.addr2))
self.assertTrue(self.addr.conflicts(self.addr_defined))
self.assertFalse(self.addr.conflicts(self.addr_default))
self.assertFalse(self.addr1.conflicts(self.addr))
self.assertTrue(self.addr1.conflicts(self.addr_defined))
self.assertFalse(self.addr1.conflicts(self.addr_default))
self.assertFalse(self.addr_defined.conflicts(self.addr1))
self.assertFalse(self.addr_defined.conflicts(self.addr2))
self.assertFalse(self.addr_defined.conflicts(self.addr))
self.assertFalse(self.addr_defined.conflicts(self.addr_default))
self.assertTrue(self.addr_default.conflicts(self.addr))
self.assertTrue(self.addr_default.conflicts(self.addr1))
self.assertTrue(self.addr_default.conflicts(self.addr_defined))
# Self test
self.assertTrue(self.addr.conflicts(self.addr))
self.assertTrue(self.addr1.conflicts(self.addr1))
# This is a tricky one...
self.assertTrue(self.addr1.conflicts(self.addr2))
def test_equal(self):
self.assertTrue(self.addr1 == self.addr2)
self.assertFalse(self.addr == self.addr1)
self.assertFalse(self.addr == 123)
def test_not_equal(self):
self.assertFalse(self.addr1 != self.addr2)
self.assertTrue(self.addr != self.addr1)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,52 +1,32 @@
"""Tests for letsencrypt_apache.parser.""" """Tests for letsencrypt_apache.parser."""
import os import os
import shutil import shutil
import sys
import unittest import unittest
import augeas import augeas
import mock import mock
import zope.component
from letsencrypt import errors from letsencrypt import errors
from letsencrypt.display import util as display_util
from letsencrypt_apache.tests import util from letsencrypt_apache.tests import util
class ApacheParserTest(util.ApacheTest): class BasicParserTest(util.ParserTest):
"""Apache Parser Test.""" """Apache Parser Test."""
def setUp(self): def setUp(self): # pylint: disable=arguments-differ
super(ApacheParserTest, self).setUp() super(BasicParserTest, self).setUp()
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
from letsencrypt_apache.parser import ApacheParser
self.aug = augeas.Augeas(flags=augeas.Augeas.NONE)
self.parser = ApacheParser(self.aug, self.config_path, self.ssl_options)
def tearDown(self): def tearDown(self):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir) shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir) shutil.rmtree(self.work_dir)
def test_root_normalized(self): def test_find_config_root_no_root(self):
from letsencrypt_apache.parser import ApacheParser # pylint: disable=protected-access
path = os.path.join(self.temp_dir, "debian_apache_2_4/////" os.remove(self.parser.loc["root"])
"two_vhost_80/../two_vhost_80/apache2") self.assertRaises(
parser = ApacheParser(self.aug, path, None) errors.NoInstallationError, self.parser._find_config_root)
self.assertEqual(parser.root, self.config_path)
def test_root_absolute(self):
from letsencrypt_apache.parser import ApacheParser
parser = ApacheParser(self.aug, os.path.relpath(self.config_path), None)
self.assertEqual(parser.root, self.config_path)
def test_root_no_trailing_slash(self):
from letsencrypt_apache.parser import ApacheParser
parser = ApacheParser(self.aug, self.config_path + os.path.sep, None)
self.assertEqual(parser.root, self.config_path)
def test_parse_file(self): def test_parse_file(self):
"""Test parse_file. """Test parse_file.
@@ -67,11 +47,11 @@ class ApacheParserTest(util.ApacheTest):
self.assertTrue(matches) self.assertTrue(matches)
def test_find_dir(self): def test_find_dir(self):
from letsencrypt_apache.parser import case_i test = self.parser.find_dir("Listen", "80")
test = self.parser.find_dir(case_i("Listen"), "443")
# This will only look in enabled hosts # This will only look in enabled hosts
test2 = self.parser.find_dir(case_i("documentroot")) test2 = self.parser.find_dir("documentroot")
self.assertEqual(len(test), 2)
self.assertEqual(len(test), 1)
self.assertEqual(len(test2), 3) self.assertEqual(len(test2), 3)
def test_add_dir(self): def test_add_dir(self):
@@ -93,15 +73,32 @@ class ApacheParserTest(util.ApacheTest):
""" """
from letsencrypt_apache.parser import get_aug_path from letsencrypt_apache.parser import get_aug_path
# This makes sure that find_dir will work
self.parser.modules.add("mod_ssl.c")
self.parser.add_dir_to_ifmodssl( self.parser.add_dir_to_ifmodssl(
get_aug_path(self.parser.loc["default"]), get_aug_path(self.parser.loc["default"]),
"FakeDirective", "123") "FakeDirective", ["123"])
matches = self.parser.find_dir("FakeDirective", "123") matches = self.parser.find_dir("FakeDirective", "123")
self.assertEqual(len(matches), 1) self.assertEqual(len(matches), 1)
self.assertTrue("IfModule" in matches[0]) self.assertTrue("IfModule" in matches[0])
def test_add_dir_to_ifmodssl_multiple(self):
from letsencrypt_apache.parser import get_aug_path
# This makes sure that find_dir will work
self.parser.modules.add("mod_ssl.c")
self.parser.add_dir_to_ifmodssl(
get_aug_path(self.parser.loc["default"]),
"FakeDirective", ["123", "456", "789"])
matches = self.parser.find_dir("FakeDirective")
self.assertEqual(len(matches), 3)
self.assertTrue("IfModule" in matches[0])
def test_get_aug_path(self): def test_get_aug_path(self):
from letsencrypt_apache.parser import get_aug_path from letsencrypt_apache.parser import get_aug_path
self.assertEqual("/files/etc/apache", get_aug_path("/etc/apache")) self.assertEqual("/files/etc/apache", get_aug_path("/etc/apache"))
@@ -109,20 +106,114 @@ class ApacheParserTest(util.ApacheTest):
def test_set_locations(self): def test_set_locations(self):
with mock.patch("letsencrypt_apache.parser.os.path") as mock_path: with mock.patch("letsencrypt_apache.parser.os.path") as mock_path:
mock_path.isfile.return_value = False
# pylint: disable=protected-access
self.assertRaises(errors.PluginError,
self.parser._set_locations, self.ssl_options)
mock_path.isfile.side_effect = [True, False, False] mock_path.isfile.side_effect = [True, False, False]
# pylint: disable=protected-access # pylint: disable=protected-access
results = self.parser._set_locations(self.ssl_options) results = self.parser._set_locations()
self.assertEqual(results["default"], results["listen"]) self.assertEqual(results["default"], results["listen"])
self.assertEqual(results["default"], results["name"]) self.assertEqual(results["default"], results["name"])
def test_set_user_config_file(self):
# pylint: disable=protected-access
path = os.path.join(self.parser.root, "httpd.conf")
open(path, 'w').close()
self.parser.add_dir(self.parser.loc["default"], "Include", "httpd.conf")
self.assertEqual(
path, self.parser._set_user_config_file())
@mock.patch("letsencrypt_apache.parser.ApacheParser._get_runtime_cfg")
def test_update_runtime_variables(self, mock_cfg):
mock_cfg.return_value = (
'ServerRoot: "/etc/apache2"\n'
'Main DocumentRoot: "/var/www"\n'
'Main ErrorLog: "/var/log/apache2/error.log"\n'
'Mutex ssl-stapling: using_defaults\n'
'Mutex ssl-cache: using_defaults\n'
'Mutex default: dir="/var/lock/apache2" mechanism=fcntl\n'
'Mutex watchdog-callback: using_defaults\n'
'PidFile: "/var/run/apache2/apache2.pid"\n'
'Define: TEST\n'
'Define: DUMP_RUN_CFG\n'
'Define: U_MICH\n'
'Define: TLS=443\n'
'Define: example_path=Documents/path\n'
'User: name="www-data" id=33 not_used\n'
'Group: name="www-data" id=33 not_used\n'
)
expected_vars = {"TEST": "", "U_MICH": "", "TLS": "443",
"example_path":"Documents/path"}
self.parser.update_runtime_variables("ctl")
self.assertEqual(self.parser.variables, expected_vars)
@mock.patch("letsencrypt_apache.parser.ApacheParser._get_runtime_cfg")
def test_update_runtime_vars_bad_output(self, mock_cfg):
mock_cfg.return_value = "Define: TLS=443=24"
self.assertRaises(
errors.PluginError, self.parser.update_runtime_variables, "ctl")
mock_cfg.return_value = "Define: DUMP_RUN_CFG\nDefine: TLS=443=24"
self.assertRaises(
errors.PluginError, self.parser.update_runtime_variables, "ctl")
@mock.patch("letsencrypt_apache.parser.subprocess.Popen")
def test_update_runtime_vars_bad_ctl(self, mock_popen):
mock_popen.side_effect = OSError
self.assertRaises(
errors.MisconfigurationError,
self.parser.update_runtime_variables, "ctl")
@mock.patch("letsencrypt_apache.parser.subprocess.Popen")
def test_update_runtime_vars_bad_exit(self, mock_popen):
mock_popen().communicate.return_value = ("", "")
mock_popen.returncode = -1
self.assertRaises(
errors.MisconfigurationError,
self.parser.update_runtime_variables, "ctl")
class ParserInitTest(util.ApacheTest):
def setUp(self): # pylint: disable=arguments-differ
super(ParserInitTest, self).setUp()
self.aug = augeas.Augeas(
flags=augeas.Augeas.NONE | augeas.Augeas.NO_MODL_AUTOLOAD)
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_root_normalized(self):
from letsencrypt_apache.parser import ApacheParser
with mock.patch("letsencrypt_apache.parser.ApacheParser."
"update_runtime_variables"):
path = os.path.join(
self.temp_dir,
"debian_apache_2_4/////two_vhost_80/../two_vhost_80/apache2")
parser = ApacheParser(self.aug, path, "dummy_ctl")
self.assertEqual(parser.root, self.config_path)
def test_root_absolute(self):
from letsencrypt_apache.parser import ApacheParser
with mock.patch("letsencrypt_apache.parser.ApacheParser."
"update_runtime_variables"):
parser = ApacheParser(
self.aug, os.path.relpath(self.config_path), "dummy_ctl")
self.assertEqual(parser.root, self.config_path)
def test_root_no_trailing_slash(self):
from letsencrypt_apache.parser import ApacheParser
with mock.patch("letsencrypt_apache.parser.ApacheParser."
"update_runtime_variables"):
parser = ApacheParser(
self.aug, self.config_path + os.path.sep, "dummy_ctl")
self.assertEqual(parser.root, self.config_path)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
@@ -0,0 +1,53 @@
# Global configuration
PidFile ${APACHE_PID_FILE}
#
# Timeout: The number of seconds before receives and sends time out.
#
Timeout 300
#
# KeepAlive: Whether or not to allow persistent connections (more than
# one request per connection). Set to "Off" to deactivate.
#
KeepAlive On
# These need to be set in /etc/apache2/envvars
User ${APACHE_RUN_USER}
Group ${APACHE_RUN_GROUP}
ErrorLog ${APACHE_LOG_DIR}/error.log
LogLevel warn
# Include module configuration:
IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf
<Directory />
Options FollowSymLinks
AllowOverride None
Require all denied
</Directory>
<Directory /var/www/>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
# Include generic snippets of statements
IncludeOptional conf-enabled/
# Include the virtual host configurations:
IncludeOptional sites-enabled/*.conf
Define COMPLEX
Define tls_port 1234
Define fnmatch_filename test_fnmatch.conf
Include test_variables.conf
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
@@ -0,0 +1,9 @@
# 3 - one arg directives
# 2 - two arg directives
# 1 - three arg directives
TestArgsDirective one_arg
TestArgsDirective one_arg two_arg
TestArgsDirective one_arg
TestArgsDirective one_arg two_arg
TestArgsDirective one_arg two_arg three_arg
TestArgsDirective one_arg
@@ -0,0 +1 @@
FNMATCH_DIRECTIVE Success
@@ -0,0 +1,65 @@
TestVariablePort ${tls_port}
LoadModule status_module modules/mod_status.so
# Basic IfDefine
<IfDefine COMPLEX>
VAR_DIRECTIVE success
LoadModule ssl_module modules/mod_ssl.so
</IfDefine>
<IfDefine !COMPLEX>
INVALID_VAR_DIRECTIVE failure
</IfDefine>
<IfDefine NOT_COMPLEX>
INVALID_VAR_DIRECTIVE failure
</IfDefine>
<IfDefine !NOT_COMPLEX>
VAR_DIRECTIVE failure
</IfDefine>
# Basic IfModule
<IfModule ssl_module>
MOD_DIRECTIVE Success
</IfModule>
<IfModule !ssl_module>
INVALID_MOD_DIRECTIVE failure
</IfModule>
<IfModule fake_module>
INVALID_MOD_DIRECTIVE failure
</IfModule>
<IfModule !fake_module>
MOD_DIRECTIVE Success
</IfModule>
# Nested Tests
<IfModule status_module>
<IfDefine COMPLEX>
NESTED_DIRECTIVE success
<IfModule mod_ssl.c>
NESTED_DIRECTIVE success
</IfModule>
<IfModule !mod_ssl.c>
INVALID_NESTED_DIRECTIVE failure
</IfModule>
</IfDefine>
<IfDefine !COMPLEX>
INVALID_NESTED_DIRECTIVE failure
<IfModule ssl_module>
INVALID_NESTED_DIRECTIVE failure
</IfModule>
</IfDefine>
NESTED_DIRECTIVE success
</IfModule>
@@ -0,0 +1,5 @@
<VirtualHost 1.1.1.1>
ServerName invalid.net
</virtualHost>
@@ -0,0 +1 @@
LoadModule rewrite_module /usr/lib/apache2/modules/mod_rewrite.so
@@ -1,9 +1,14 @@
"""Common utilities for letsencrypt_apache.""" """Common utilities for letsencrypt_apache."""
import os import os
import pkg_resources import pkg_resources
import sys
import unittest import unittest
import augeas
import mock import mock
import zope.component
from letsencrypt.display import util as display_util
from letsencrypt.plugins import common from letsencrypt.plugins import common
@@ -14,19 +19,20 @@ from letsencrypt_apache import obj
class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods
def setUp(self): def setUp(self, test_dir="debian_apache_2_4/two_vhost_80",
config_root="debian_apache_2_4/two_vhost_80/apache2"):
# pylint: disable=arguments-differ
super(ApacheTest, self).setUp() super(ApacheTest, self).setUp()
self.temp_dir, self.config_dir, self.work_dir = common.dir_setup( self.temp_dir, self.config_dir, self.work_dir = common.dir_setup(
test_dir="debian_apache_2_4/two_vhost_80", test_dir=test_dir,
pkg="letsencrypt_apache.tests") pkg="letsencrypt_apache.tests")
self.ssl_options = common.setup_ssl_options( self.ssl_options = common.setup_ssl_options(
self.config_dir, constants.MOD_SSL_CONF_SRC, self.config_dir, constants.MOD_SSL_CONF_SRC,
constants.MOD_SSL_CONF_DEST) constants.MOD_SSL_CONF_DEST)
self.config_path = os.path.join( self.config_path = os.path.join(self.temp_dir, config_root)
self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2")
self.rsa256_file = pkg_resources.resource_filename( self.rsa256_file = pkg_resources.resource_filename(
"letsencrypt.tests", os.path.join("testdata", "rsa256_key.pem")) "letsencrypt.tests", os.path.join("testdata", "rsa256_key.pem"))
@@ -34,29 +40,56 @@ class ApacheTest(unittest.TestCase): # pylint: disable=too-few-public-methods
"letsencrypt.tests", os.path.join("testdata", "rsa256_key.pem")) "letsencrypt.tests", os.path.join("testdata", "rsa256_key.pem"))
def get_apache_configurator( class ParserTest(ApacheTest): # pytlint: disable=too-few-public-methods
config_path, config_dir, work_dir, version=(2, 4, 7)):
"""Create an Apache Configurator with the specified options."""
def setUp(self, test_dir="debian_apache_2_4/two_vhost_80",
config_root="debian_apache_2_4/two_vhost_80/apache2"):
super(ParserTest, self).setUp(test_dir, config_root)
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
from letsencrypt_apache.parser import ApacheParser
self.aug = augeas.Augeas(
flags=augeas.Augeas.NONE | augeas.Augeas.NO_MODL_AUTOLOAD)
with mock.patch("letsencrypt_apache.parser.ApacheParser."
"update_runtime_variables"):
self.parser = ApacheParser(
self.aug, self.config_path, "dummy_ctl_path")
def get_apache_configurator(
config_path, config_dir, work_dir, version=(2, 4, 7), conf=None):
"""Create an Apache Configurator with the specified options.
:param conf: Function that returns binary paths. self.conf in Configurator
"""
backups = os.path.join(work_dir, "backups") backups = os.path.join(work_dir, "backups")
with mock.patch("letsencrypt_apache.configurator." with mock.patch("letsencrypt_apache.configurator."
"subprocess.Popen") as mock_popen: "subprocess.Popen") as mock_popen:
# This just states that the ssl module is already loaded with mock.patch("letsencrypt_apache.parser.ApacheParser."
mock_popen().communicate.return_value = ("ssl_module", "") "update_runtime_variables"):
config = configurator.ApacheConfigurator( # This indicates config_test passes
config=mock.MagicMock( mock_popen().communicate.return_value = ("Fine output", "No problems")
apache_server_root=config_path, mock_popen().returncode = 0
apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"],
backup_dir=backups,
config_dir=config_dir,
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
work_dir=work_dir),
name="apache",
version=version)
config.prepare() config = configurator.ApacheConfigurator(
config=mock.MagicMock(
apache_server_root=config_path,
apache_le_vhost_ext=constants.CLI_DEFAULTS["le_vhost_ext"],
backup_dir=backups,
config_dir=config_dir,
temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"),
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
work_dir=work_dir),
name="apache",
version=version)
# This allows testing scripts to set it a bit more quickly
if conf is not None:
config.conf = conf # pragma: no cover
config.prepare()
return config return config
@@ -71,23 +104,23 @@ def get_vh_truth(temp_dir, config_name):
obj.VirtualHost( obj.VirtualHost(
os.path.join(prefix, "encryption-example.conf"), os.path.join(prefix, "encryption-example.conf"),
os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), os.path.join(aug_pre, "encryption-example.conf/VirtualHost"),
set([common.Addr.fromstring("*:80")]), set([obj.Addr.fromstring("*:80")]),
False, True, set(["encryption-example.demo"])), False, True, "encryption-example.demo"),
obj.VirtualHost( obj.VirtualHost(
os.path.join(prefix, "default-ssl.conf"), os.path.join(prefix, "default-ssl.conf"),
os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"),
set([common.Addr.fromstring("_default_:443")]), True, False), set([obj.Addr.fromstring("_default_:443")]), True, False),
obj.VirtualHost( obj.VirtualHost(
os.path.join(prefix, "000-default.conf"), os.path.join(prefix, "000-default.conf"),
os.path.join(aug_pre, "000-default.conf/VirtualHost"), os.path.join(aug_pre, "000-default.conf/VirtualHost"),
set([common.Addr.fromstring("*:80")]), False, True, set([obj.Addr.fromstring("*:80")]), False, True,
set(["ip-172-30-0-17"])), "ip-172-30-0-17"),
obj.VirtualHost( obj.VirtualHost(
os.path.join(prefix, "letsencrypt.conf"), os.path.join(prefix, "letsencrypt.conf"),
os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"),
set([common.Addr.fromstring("*:80")]), False, True, set([obj.Addr.fromstring("*:80")]), False, True,
set(["letsencrypt.demo"])), "letsencrypt.demo"),
] ]
return vh_truth return vh_truth
return None return None # pragma: no cover
+1
View File
@@ -20,4 +20,5 @@ setup(
'apache = letsencrypt_apache.configurator:ApacheConfigurator', 'apache = letsencrypt_apache.configurator:ApacheConfigurator',
], ],
}, },
include_package_data=True,
) )
@@ -244,22 +244,15 @@ class NginxConfigurator(common.Plugin):
""" """
all_names = set() all_names = set()
# Kept in same function to avoid multiple compilations of the regex
priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|"
r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)")
private_ips = re.compile(priv_ip_regex)
hostname_regex = r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$"
hostnames = re.compile(hostname_regex, re.IGNORECASE)
for vhost in self.parser.get_vhosts(): for vhost in self.parser.get_vhosts():
all_names.update(vhost.names) all_names.update(vhost.names)
for addr in vhost.addrs: for addr in vhost.addrs:
host = addr.get_addr() host = addr.get_addr()
if hostnames.match(host): if common.hostname_regex.match(host):
# If it's a hostname, add it to the names. # If it's a hostname, add it to the names.
all_names.add(host) all_names.add(host)
elif not private_ips.match(host): elif not common.private_ips_regex.match(host):
# If it isn't a private IP, do a reverse DNS lookup # If it isn't a private IP, do a reverse DNS lookup
# TODO: IPv6 support # TODO: IPv6 support
try: try:
@@ -451,7 +444,7 @@ class NginxConfigurator(common.Plugin):
# nginx < 0.8.48 uses machine hostname as default server_name instead of # nginx < 0.8.48 uses machine hostname as default server_name instead of
# the empty string # the empty string
if nginx_version < (0, 8, 48): if nginx_version < (0, 8, 48):
raise errors.PluginError("Nginx version must be 0.8.48+") raise errors.NotSupportedError("Nginx version must be 0.8.48+")
return nginx_version return nginx_version
@@ -37,7 +37,7 @@ class RawNginxParser(object):
+ Group(ZeroOrMore(Group(comment | assignment) | block)) + Group(ZeroOrMore(Group(comment | assignment) | block))
+ right_bracket) + right_bracket)
script = OneOrMore(Group(comment | assignment) | block) + stringEnd script = OneOrMore(Group(comment | assignment) ^ block) + stringEnd
def __init__(self, source): def __init__(self, source):
self.source = source self.source = source
@@ -249,7 +249,7 @@ class NginxConfiguratorTest(util.NginxTest):
" (based on LLVM 3.5svn)", " (based on LLVM 3.5svn)",
"TLS SNI support enabled", "TLS SNI support enabled",
"configure arguments: --with-http_ssl_module"])) "configure arguments: --with-http_ssl_module"]))
self.assertRaises(errors.PluginError, self.config.get_version) self.assertRaises(errors.NotSupportedError, self.config.get_version)
mock_popen.side_effect = OSError("Can't find program") mock_popen.side_effect = OSError("Can't find program")
self.assertRaises(errors.PluginError, self.config.get_version) self.assertRaises(errors.PluginError, self.config.get_version)
@@ -5,7 +5,7 @@ import unittest
from pyparsing import ParseException from pyparsing import ParseException
from letsencrypt_nginx.nginxparser import ( from letsencrypt_nginx.nginxparser import (
RawNginxParser, load, dumps, dump) RawNginxParser, loads, load, dumps, dump)
from letsencrypt_nginx.tests import util from letsencrypt_nginx.tests import util
@@ -160,6 +160,13 @@ class TestRawNginxParser(unittest.TestCase):
['#', ' listen 80;']]], ['#', ' listen 80;']]],
]) ])
def test_issue_518(self):
parsed = loads('if ($http_accept ~* "webp") { set $webp "true"; }')
self.assertEqual(parsed, [
[['if', '($http_accept ~* "webp")'],
[['set', '$webp "true"']]]
])
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
+1
View File
@@ -19,4 +19,5 @@ setup(
'nginx = letsencrypt_nginx.configurator:NginxConfigurator', 'nginx = letsencrypt_nginx.configurator:NginxConfigurator',
], ],
}, },
include_package_data=True,
) )
+7 -2
View File
@@ -153,13 +153,18 @@ class AuthHandler(object):
""" """
active_achalls = [] active_achalls = []
for achall, resp in itertools.izip(achalls, resps): for achall, resp in itertools.izip(achalls, resps):
# XXX: make sure that all achalls, including those
# corresponding to None or False returned from
# Authenticator are removed from the queue and thus avoid
# infinite loop
active_achalls.append(achall)
# Don't send challenges for None and False authenticator responses # Don't send challenges for None and False authenticator responses
if resp: if resp is not None and resp:
self.acme.answer_challenge(achall.challb, resp) self.acme.answer_challenge(achall.challb, resp)
# TODO: answer_challenge returns challr, with URI, # TODO: answer_challenge returns challr, with URI,
# that can be used in _find_updated_challr # that can be used in _find_updated_challr
# comparisons... # comparisons...
active_achalls.append(achall)
if achall.domain in chall_update: if achall.domain in chall_update:
chall_update[achall.domain].append(achall) chall_update[achall.domain].append(achall)
else: else:
+1 -1
View File
@@ -172,7 +172,7 @@ def run(args, config, plugins):
authenticator = display_ops.pick_authenticator( authenticator = display_ops.pick_authenticator(
config, args.authenticator, plugins) config, args.authenticator, plugins)
else: else:
# TODO: this assume that user doesn't want to pick authenticator # TODO: this assumes that user doesn't want to pick authenticator
# and installer separately... # and installer separately...
authenticator = installer = display_ops.pick_configurator( authenticator = installer = display_ops.pick_configurator(
config, args.configurator, plugins) config, args.configurator, plugins)
+2 -2
View File
@@ -16,9 +16,9 @@ CLI_DEFAULTS = dict(
"letsencrypt", "cli.ini"), "letsencrypt", "cli.ini"),
], ],
verbose_count=-(logging.WARNING / 10), verbose_count=-(logging.WARNING / 10),
server="https://www.letsencrypt-demo.org/acme/new-reg", server="https://acme-staging.api.letsencrypt.org/acme/new-reg",
rsa_key_size=2048, rsa_key_size=2048,
rollback_checkpoints=0, rollback_checkpoints=1,
config_dir="/etc/letsencrypt", config_dir="/etc/letsencrypt",
work_dir="/var/lib/letsencrypt", work_dir="/var/lib/letsencrypt",
logs_dir="/var/log/letsencrypt", logs_dir="/var/log/letsencrypt",
+3 -1
View File
@@ -82,6 +82,8 @@ def pick_plugin(config, default, plugins, question, ifaces):
elif len(prepared) == 1: elif len(prepared) == 1:
plugin_ep = prepared.values()[0] plugin_ep = prepared.values()[0]
logger.debug("Single candidate plugin: %s", plugin_ep) logger.debug("Single candidate plugin: %s", plugin_ep)
if plugin_ep.misconfigured:
return None
return plugin_ep.init() return plugin_ep.init()
else: else:
logger.debug("No candidate plugin") logger.debug("No candidate plugin")
@@ -90,7 +92,7 @@ def pick_plugin(config, default, plugins, question, ifaces):
def pick_authenticator( def pick_authenticator(
config, default, plugins, question="How would you " config, default, plugins, question="How would you "
"like to authenticate with Let's Encrypt CA?"): "like to authenticate with the Let's Encrypt CA?"):
"""Pick authentication plugin.""" """Pick authentication plugin."""
return pick_plugin( return pick_plugin(
config, default, plugins, question, (interfaces.IAuthenticator,)) config, default, plugins, question, (interfaces.IAuthenticator,))
+11
View File
@@ -17,6 +17,14 @@ class ReverterError(Error):
"""Let's Encrypt Reverter error.""" """Let's Encrypt Reverter error."""
class SubprocessError(Error):
"""Subprocess handling error."""
class CertStorageError(Error):
"""Generic `.CertStorage` error."""
# Auth Handler Errors # Auth Handler Errors
class AuthorizationError(Error): class AuthorizationError(Error):
"""Authorization error.""" """Authorization error."""
@@ -65,6 +73,9 @@ class NoInstallationError(PluginError):
class MisconfigurationError(PluginError): class MisconfigurationError(PluginError):
"""Let's Encrypt Misconfiguration error.""" """Let's Encrypt Misconfiguration error."""
class NotSupportedError(PluginError):
"""Let's Encrypt Plugin function not supported error."""
class RevokerError(Error): class RevokerError(Error):
"""Let's Encrypt Revoker error.""" """Let's Encrypt Revoker error."""
+43 -11
View File
@@ -102,14 +102,19 @@ class IPlugin(zope.interface.Interface):
def prepare(): def prepare():
"""Prepare the plugin. """Prepare the plugin.
Finish up any additional initialization. Finish up any additional initialization.
:raises .MisconfigurationError: :raises .PluginError:
when full initialization cannot be completed. Plugin will when full initialization cannot be completed.
be displayed on a list of available plugins. :raises .MisconfigurationError:
:raises .NoInstallationError: when full initialization cannot be completed. Plugin will
when the necessary programs/files cannot be located. Plugin be displayed on a list of available plugins.
will NOT be displayed on a list of available plugins. :raises .NoInstallationError:
when the necessary programs/files cannot be located. Plugin
will NOT be displayed on a list of available plugins.
:raises .NotSupportedError:
when the installation is recognized, but the version is not
currently supported.
""" """
@@ -166,6 +171,8 @@ class IAuthenticator(IPlugin):
:rtype: :class:`list` of :rtype: :class:`list` of
:class:`acme.challenges.ChallengeResponse` :class:`acme.challenges.ChallengeResponse`
:raises .PluginError: If challenges cannot be performed
""" """
def cleanup(achalls): def cleanup(achalls):
@@ -175,6 +182,8 @@ class IAuthenticator(IPlugin):
:class:`~letsencrypt.achallenges.AnnotatedChallenge` :class:`~letsencrypt.achallenges.AnnotatedChallenge`
instances, a subset of those previously passed to :func:`perform`. instances, a subset of those previously passed to :func:`perform`.
:raises PluginError: if original configuration cannot be restored
""" """
@@ -248,6 +257,8 @@ class IInstaller(IPlugin):
:param str key_path: absolute path to the private key file :param str key_path: absolute path to the private key file
:param str chain_path: absolute path to the certificate chain file :param str chain_path: absolute path to the certificate chain file
:raises .PluginError: when cert cannot be deployed
""" """
def enhance(domain, enhancement, options=None): def enhance(domain, enhancement, options=None):
@@ -261,6 +272,9 @@ class IInstaller(IPlugin):
:const:`~letsencrypt.constants.ENHANCEMENTS` :const:`~letsencrypt.constants.ENHANCEMENTS`
for expected options for each enhancement. for expected options for each enhancement.
:raises .PluginError: If Enhancement is not supported, or if
an error occurs during the enhancement.
""" """
def supported_enhancements(): def supported_enhancements():
@@ -299,19 +313,37 @@ class IInstaller(IPlugin):
:param bool temporary: Indicates whether the changes made will :param bool temporary: Indicates whether the changes made will
be quickly reversed in the future (challenges) be quickly reversed in the future (challenges)
:raises .PluginError: when save is unsuccessful
""" """
def rollback_checkpoints(rollback=1): def rollback_checkpoints(rollback=1):
"""Revert `rollback` number of configuration checkpoints.""" """Revert `rollback` number of configuration checkpoints.
:raises .PluginError: when configuration cannot be fully reverted
"""
def view_config_changes(): def view_config_changes():
"""Display all of the LE config changes.""" """Display all of the LE config changes.
:raises .PluginError: when config changes cannot be parsed
"""
def config_test(): def config_test():
"""Make sure the configuration is valid.""" """Make sure the configuration is valid.
:raises .MisconfigurationError: when the config is not in a usable state
"""
def restart(): def restart():
"""Restart or refresh the server content.""" """Restart or refresh the server content.
:raises .PluginError: when server cannot be restarted
"""
class IDisplay(zope.interface.Interface): class IDisplay(zope.interface.Interface):
+53
View File
@@ -4,6 +4,7 @@ import errno
import logging import logging
import os import os
import re import re
import subprocess
import stat import stat
from letsencrypt import errors from letsencrypt import errors
@@ -17,6 +18,58 @@ Key = collections.namedtuple("Key", "file pem")
CSR = collections.namedtuple("CSR", "file data form") CSR = collections.namedtuple("CSR", "file data form")
def run_script(params):
"""Run the script with the given params.
:param list params: List of parameters to pass to Popen
"""
try:
proc = subprocess.Popen(params,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
except (OSError, ValueError):
msg = "Unable to run the command: %s" % " ".join(params)
logger.error(msg)
raise errors.SubprocessError(msg)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
msg = "Error while running %s.\n%s\n%s" % (
" ".join(params), stdout, stderr)
# Enter recovery routine...
logger.error(msg)
raise errors.SubprocessError(msg)
return stdout, stderr
def exe_exists(exe):
"""Determine whether path/name refers to an executable.
:param str exe: Executable path or name
:returns: If exe is a valid executable
:rtype: bool
"""
def is_exe(path):
"""Determine if path is an exe."""
return os.path.isfile(path) and os.access(path, os.X_OK)
path, _ = os.path.split(exe)
if path:
return is_exe(exe)
else:
for path in os.environ["PATH"].split(os.pathsep):
if is_exe(os.path.join(path, exe)):
return True
return False
def make_or_verify_dir(directory, mode=0o755, uid=0): def make_or_verify_dir(directory, mode=0o755, uid=0):
"""Make sure directory exists with proper permissions. """Make sure directory exists with proper permissions.
+7
View File
@@ -1,6 +1,7 @@
"""Plugin common functions.""" """Plugin common functions."""
import os import os
import pkg_resources import pkg_resources
import re
import shutil import shutil
import tempfile import tempfile
@@ -22,6 +23,12 @@ def dest_namespace(name):
"""ArgumentParser dest namespace (prefix of all destinations).""" """ArgumentParser dest namespace (prefix of all destinations)."""
return name + "_" return name + "_"
private_ips_regex = re.compile( # pylint: disable=invalid-name
r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|"
r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)")
hostname_regex = re.compile( # pylint: disable=invalid-name
r"^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*[a-z]+$", re.IGNORECASE)
class Plugin(object): class Plugin(object):
"""Generic plugin.""" """Generic plugin."""
+68 -12
View File
@@ -1,6 +1,12 @@
"""Manual plugin.""" """Manual plugin."""
import os import os
import logging
import shutil
import signal
import subprocess
import sys import sys
import tempfile
import time
import zope.component import zope.component
import zope.interface import zope.interface
@@ -8,10 +14,14 @@ import zope.interface
from acme import challenges from acme import challenges
from acme import jose from acme import jose
from letsencrypt import errors
from letsencrypt import interfaces from letsencrypt import interfaces
from letsencrypt.plugins import common from letsencrypt.plugins import common
logger = logging.getLogger(__name__)
class ManualAuthenticator(common.Plugin): class ManualAuthenticator(common.Plugin):
"""Manual Authenticator. """Manual Authenticator.
@@ -43,8 +53,8 @@ command on the target server (as root):
# anything recursively under the cwd # anything recursively under the cwd
HTTP_TEMPLATE = """\ HTTP_TEMPLATE = """\
mkdir -p /tmp/letsencrypt/public_html/{response.URI_ROOT_PATH} mkdir -p {root}/public_html/{response.URI_ROOT_PATH}
cd /tmp/letsencrypt/public_html cd {root}/public_html
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
# run only once per server: # run only once per server:
python -c "import BaseHTTPServer, SimpleHTTPServer; \\ python -c "import BaseHTTPServer, SimpleHTTPServer; \\
@@ -55,8 +65,8 @@ s.serve_forever()" """
# https://www.piware.de/2011/01/creating-an-https-server-in-python/ # https://www.piware.de/2011/01/creating-an-https-server-in-python/
HTTPS_TEMPLATE = """\ HTTPS_TEMPLATE = """\
mkdir -p /tmp/letsencrypt/public_html/{response.URI_ROOT_PATH} mkdir -p {root}/public_html/{response.URI_ROOT_PATH}
cd /tmp/letsencrypt/public_html cd {root}/public_html
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path}
# run only once per server: # run only once per server:
openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem
@@ -77,6 +87,15 @@ s.serve_forever()" """
super(ManualAuthenticator, self).__init__(*args, **kwargs) super(ManualAuthenticator, self).__init__(*args, **kwargs)
self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls self.template = (self.HTTP_TEMPLATE if self.config.no_simple_http_tls
else self.HTTPS_TEMPLATE) else self.HTTPS_TEMPLATE)
self._root = (tempfile.mkdtemp() if self.conf("test-mode")
else "/tmp/letsencrypt")
self._httpd = None
@classmethod
def add_parser_arguments(cls, add):
add("test-mode", action="store_true",
help="Test mode. Executes the manual command in subprocess. "
"Requires openssl to be installed unless --no-simple-http-tls.")
def prepare(self): # pylint: disable=missing-docstring,no-self-use def prepare(self): # pylint: disable=missing-docstring,no-self-use
pass # pragma: no cover pass # pragma: no cover
@@ -110,17 +129,44 @@ binary for temporary key/certificate generation.""".replace("\n", "")
tls=(not self.config.no_simple_http_tls)) tls=(not self.config.no_simple_http_tls))
assert response.good_path # is encoded os.urandom(18) good? assert response.good_path # is encoded os.urandom(18) good?
self._notify_and_wait(self.MESSAGE_TEMPLATE.format( command = self.template.format(
achall=achall, response=response, uri=response.uri(achall.domain), root=self._root, achall=achall, response=response,
ct=response.CONTENT_TYPE, command=self.template.format( ct=response.CONTENT_TYPE, port=(
achall=achall, response=response, ct=response.CONTENT_TYPE, response.port if self.config.simple_http_port is None
port=(response.port if self.config.simple_http_port is None else self.config.simple_http_port))
else self.config.simple_http_port)))) if self.conf("test-mode"):
logger.debug("Test mode. Executing the manual command: %s", command)
try:
self._httpd = subprocess.Popen(
command,
# don't care about setting stdout and stderr,
# we're in test mode anyway
shell=True,
# "preexec_fn" is UNIX specific, but so is "command"
preexec_fn=os.setsid)
except OSError as error: # ValueError should not happen!
logger.debug(
"Couldn't execute manual command: %s", error, exc_info=True)
return False
logger.debug("Manual command running as PID %s.", self._httpd.pid)
# give it some time to bootstrap, before we try to verify
# (cert generation in case of simpleHttpS might take time)
time.sleep(4) # XXX
if self._httpd.poll() is not None:
raise errors.Error("Couldn't execute manual command")
else:
self._notify_and_wait(self.MESSAGE_TEMPLATE.format(
achall=achall, response=response,
uri=response.uri(achall.domain), ct=response.CONTENT_TYPE,
command=command))
if response.simple_verify( if response.simple_verify(
achall.challb, achall.domain, self.config.simple_http_port): achall.challb, achall.domain, self.config.simple_http_port):
return response return response
else: else:
if self.conf("test-mode") and self._httpd.poll() is not None:
# simply verify cause command failure...
return False
return None return None
def _notify_and_wait(self, message): # pylint: disable=no-self-use def _notify_and_wait(self, message): # pylint: disable=no-self-use
@@ -130,5 +176,15 @@ binary for temporary key/certificate generation.""".replace("\n", "")
sys.stdout.write(message) sys.stdout.write(message)
raw_input("Press ENTER to continue") raw_input("Press ENTER to continue")
def cleanup(self, achalls): # pylint: disable=missing-docstring,no-self-use def cleanup(self, achalls):
pass # pragma: no cover # pylint: disable=missing-docstring,no-self-use,unused-argument
if self.conf("test-mode"):
assert self._httpd is not None, (
"cleanup() must be called after perform()")
if self._httpd.poll() is None:
logger.debug("Terminating manual command process")
os.killpg(self._httpd.pid, signal.SIGTERM)
else:
logger.debug("Manual command process already terminated "
"with %s code", self._httpd.returncode)
shutil.rmtree(self._root)
+50 -1
View File
@@ -1,4 +1,5 @@
"""Tests for letsencrypt.plugins.manual.""" """Tests for letsencrypt.plugins.manual."""
import signal
import unittest import unittest
import mock import mock
@@ -6,6 +7,8 @@ import mock
from acme import challenges from acme import challenges
from letsencrypt import achallenges from letsencrypt import achallenges
from letsencrypt import errors
from letsencrypt.tests import acme_util from letsencrypt.tests import acme_util
@@ -15,11 +18,18 @@ class ManualAuthenticatorTest(unittest.TestCase):
def setUp(self): def setUp(self):
from letsencrypt.plugins.manual import ManualAuthenticator from letsencrypt.plugins.manual import ManualAuthenticator
self.config = mock.MagicMock( self.config = mock.MagicMock(
no_simple_http_tls=True, simple_http_port=4430) no_simple_http_tls=True, simple_http_port=4430,
manual_test_mode=False)
self.auth = ManualAuthenticator(config=self.config, name="manual") self.auth = ManualAuthenticator(config=self.config, name="manual")
self.achalls = [achallenges.SimpleHTTP( self.achalls = [achallenges.SimpleHTTP(
challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)] challb=acme_util.SIMPLE_HTTP, domain="foo.com", key=None)]
config_test_mode = mock.MagicMock(
no_simple_http_tls=True, simple_http_port=4430,
manual_test_mode=True)
self.auth_test_mode = ManualAuthenticator(
config=config_test_mode, name="manual")
def test_more_info(self): def test_more_info(self):
self.assertTrue(isinstance(self.auth.more_info(), str)) self.assertTrue(isinstance(self.auth.more_info(), str))
@@ -51,6 +61,45 @@ class ManualAuthenticatorTest(unittest.TestCase):
mock_verify.return_value = False mock_verify.return_value = False
self.assertEqual([None], self.auth.perform(self.achalls)) self.assertEqual([None], self.auth.perform(self.achalls))
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_command_oserror(self, mock_popen):
mock_popen.side_effect = OSError
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
@mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True)
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_command_run_failure(
self, mock_popen, unused_mock_sleep):
mock_popen.poll.return_value = 10
mock_popen.return_value.pid = 1234
self.assertRaises(
errors.Error, self.auth_test_mode.perform, self.achalls)
@mock.patch("letsencrypt.plugins.manual.time.sleep", autospec=True)
@mock.patch("acme.challenges.SimpleHTTPResponse.simple_verify",
autospec=True)
@mock.patch("letsencrypt.plugins.manual.subprocess.Popen", autospec=True)
def test_perform_test_mode(self, mock_popen, mock_verify, mock_sleep):
mock_popen.return_value.poll.side_effect = [None, 10]
mock_popen.return_value.pid = 1234
mock_verify.return_value = False
self.assertEqual([False], self.auth_test_mode.perform(self.achalls))
self.assertEqual(1, mock_sleep.call_count)
def test_cleanup_test_mode_already_terminated(self):
# pylint: disable=protected-access
self.auth_test_mode._httpd = httpd = mock.Mock()
httpd.poll.return_value = 0
self.auth_test_mode.cleanup(self.achalls)
@mock.patch("letsencrypt.plugins.manual.os.killpg", autospec=True)
def test_cleanup_test_mode_kills_still_running(self, mock_killpg):
# pylint: disable=protected-access
self.auth_test_mode._httpd = httpd = mock.Mock(pid=1234)
httpd.poll.return_value = None
self.auth_test_mode.cleanup(self.achalls)
mock_killpg.assert_called_once_with(1234, signal.SIGTERM)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
@@ -196,6 +196,10 @@ class StandaloneAuthenticator(common.Plugin):
""" """
signal.signal(signal.SIGINT, self.subproc_signal_handler) signal.signal(signal.SIGINT, self.subproc_signal_handler)
self.sock = socket.socket() self.sock = socket.socket()
# SO_REUSEADDR flag tells the kernel to reuse a local socket
# in TIME_WAIT state, without waiting for its natural timeout
# to expire.
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try: try:
self.sock.bind(("0.0.0.0", port)) self.sock.bind(("0.0.0.0", port))
except socket.error, error: except socket.error, error:
+2 -1
View File
@@ -20,6 +20,7 @@ from letsencrypt import configuration
from letsencrypt import cli from letsencrypt import cli
from letsencrypt import client from letsencrypt import client
from letsencrypt import crypto_util from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import notify from letsencrypt import notify
from letsencrypt import storage from letsencrypt import storage
@@ -164,7 +165,7 @@ def main(config=None, args=sys.argv[1:]):
# dramatically improve performance for large deployments # dramatically improve performance for large deployments
# where autorenewal is widely turned off. # where autorenewal is widely turned off.
cert = storage.RenewableCert(rc_config, cli_config=cli_config) cert = storage.RenewableCert(rc_config, cli_config=cli_config)
except ValueError: except errors.CertStorageError:
# This indicates an invalid renewal configuration file, such # This indicates an invalid renewal configuration file, such
# as one missing a required parameter (in the future, perhaps # as one missing a required parameter (in the future, perhaps
# also one that is internally inconsistent or is missing a # also one that is internally inconsistent or is missing a
+76 -9
View File
@@ -1,4 +1,5 @@
"""Reverter class saves configuration checkpoints and allows for recovery.""" """Reverter class saves configuration checkpoints and allows for recovery."""
import csv
import logging import logging
import os import os
import shutil import shutil
@@ -20,6 +21,8 @@ logger = logging.getLogger(__name__)
class Reverter(object): class Reverter(object):
"""Reverter Class - save and revert configuration checkpoints. """Reverter Class - save and revert configuration checkpoints.
.. note:: Consider moving everything over to CSV format.
:param config: Configuration. :param config: Configuration.
:type config: :class:`letsencrypt.interfaces.IConfig` :type config: :class:`letsencrypt.interfaces.IConfig`
@@ -27,6 +30,9 @@ class Reverter(object):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
le_util.make_or_verify_dir(
config.backup_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
def revert_temporary_config(self): def revert_temporary_config(self):
"""Reload users original configuration files after a temporary save. """Reload users original configuration files after a temporary save.
@@ -91,6 +97,8 @@ class Reverter(object):
.. todo:: Decide on a policy for error handling, OSError IOError... .. todo:: Decide on a policy for error handling, OSError IOError...
:raises .errors.ReverterError: If invalid directory structure.
""" """
backups = os.listdir(self.config.backup_dir) backups = os.listdir(self.config.backup_dir)
backups.sort(reverse=True) backups.sort(reverse=True)
@@ -98,6 +106,7 @@ class Reverter(object):
if not backups: if not backups:
logger.info("The Let's Encrypt client has not saved any backups " logger.info("The Let's Encrypt client has not saved any backups "
"of your configuration") "of your configuration")
return return
# Make sure there isn't anything unexpected in the backup folder # Make sure there isn't anything unexpected in the backup folder
# There should only be timestamped (float) directories # There should only be timestamped (float) directories
@@ -201,7 +210,7 @@ class Reverter(object):
notes_fd.write(save_notes) notes_fd.write(save_notes)
def _read_and_append(self, filepath): # pylint: disable=no-self-use def _read_and_append(self, filepath): # pylint: disable=no-self-use
"""Reads the file lines and returns a fd. """Reads the file lines and returns a file obj.
Read the file returning the lines, and a pointer to the end of the file. Read the file returning the lines, and a pointer to the end of the file.
@@ -227,6 +236,10 @@ class Reverter(object):
:raises errors.ReverterError: If unable to recover checkpoint :raises errors.ReverterError: If unable to recover checkpoint
""" """
# Undo all commands
if os.path.isfile(os.path.join(cp_dir, "COMMANDS")):
self._run_undo_commands(os.path.join(cp_dir, "COMMANDS"))
# Revert all changed files
if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")): if os.path.isfile(os.path.join(cp_dir, "FILEPATHS")):
try: try:
with open(os.path.join(cp_dir, "FILEPATHS")) as paths_fd: with open(os.path.join(cp_dir, "FILEPATHS")) as paths_fd:
@@ -251,6 +264,17 @@ class Reverter(object):
raise errors.ReverterError( raise errors.ReverterError(
"Unable to remove directory: %s" % cp_dir) "Unable to remove directory: %s" % cp_dir)
def _run_undo_commands(self, filepath): # pylint: disable=no-self-use
"""Run all commands in a file."""
with open(filepath, 'rb') as csvfile:
csvreader = csv.reader(csvfile)
for command in reversed(list(csvreader)):
try:
le_util.run_script(command)
except errors.SubprocessError:
logger.error(
"Unable to run undo command: %s", " ".join(command))
def _check_tempfile_saves(self, save_files): def _check_tempfile_saves(self, save_files):
"""Verify save isn't overwriting any temporary files. """Verify save isn't overwriting any temporary files.
@@ -303,13 +327,7 @@ class Reverter(object):
raise errors.ReverterError( raise errors.ReverterError(
"Forgot to provide files to registration call") "Forgot to provide files to registration call")
if temporary: cp_dir = self._get_cp_dir(temporary)
cp_dir = self.config.temp_checkpoint_dir
else:
cp_dir = self.config.in_progress_dir
le_util.make_or_verify_dir(
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
# Append all new files (that aren't already registered) # Append all new files (that aren't already registered)
new_fd = None new_fd = None
@@ -328,6 +346,53 @@ class Reverter(object):
if new_fd is not None: if new_fd is not None:
new_fd.close() new_fd.close()
def register_undo_command(self, temporary, command):
"""Register a command to be run to undo actions taken.
.. warning:: This function does not enforce order of operations in terms
of file modification vs. command registration. All undo commands
are run first before all normal files are reverted to their previous
state. If you need to maintain strict order, you may create
checkpoints before and after the the command registration. This
function may be improved in the future based on demand.
:param bool temporary: Whether the command should be saved in the
IN_PROGRESS or TEMPORARY checkpoints.
:param command: Command to be run.
:type command: list of str
"""
commands_fp = os.path.join(self._get_cp_dir(temporary), "COMMANDS")
command_file = None
try:
if os.path.isfile(commands_fp):
command_file = open(commands_fp, "ab")
else:
command_file = open(commands_fp, "wb")
csvwriter = csv.writer(command_file)
csvwriter.writerow(command)
except (IOError, OSError):
logger.error("Unable to register undo command")
raise errors.ReverterError(
"Unable to register undo command.")
finally:
if command_file is not None:
command_file.close()
def _get_cp_dir(self, temporary):
"""Return the proper reverter directory."""
if temporary:
cp_dir = self.config.temp_checkpoint_dir
else:
cp_dir = self.config.in_progress_dir
le_util.make_or_verify_dir(
cp_dir, constants.CONFIG_DIRS_MODE, os.geteuid())
return cp_dir
def recovery_routine(self): def recovery_routine(self):
"""Revert configuration to most recent finalized checkpoint. """Revert configuration to most recent finalized checkpoint.
@@ -335,6 +400,8 @@ class Reverter(object):
finalized. This is useful to protect against crashes and other finalized. This is useful to protect against crashes and other
execution interruptions. execution interruptions.
:raises .errors.ReverterError: If unable to recover the configuration
""" """
# First, any changes found in IConfig.temp_checkpoint_dir are removed, # First, any changes found in IConfig.temp_checkpoint_dir are removed,
# then IN_PROGRESS changes are removed The order is important. # then IN_PROGRESS changes are removed The order is important.
@@ -380,7 +447,7 @@ class Reverter(object):
os.remove(path) os.remove(path)
else: else:
logger.warning( logger.warning(
"File: %s - Could not be found to be deleted%s" "File: %s - Could not be found to be deleted %s - "
"LE probably shut down unexpectedly", "LE probably shut down unexpectedly",
os.linesep, path) os.linesep, path)
except (IOError, OSError): except (IOError, OSError):
+20 -14
View File
@@ -11,6 +11,7 @@ import pytz
import pyrfc3339 import pyrfc3339
from letsencrypt import constants from letsencrypt import constants
from letsencrypt import errors
from letsencrypt import le_util from letsencrypt import le_util
ALL_FOUR = ("cert", "privkey", "chain", "fullchain") ALL_FOUR = ("cert", "privkey", "chain", "fullchain")
@@ -90,7 +91,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
renewal config file. renewal config file.
:param .RenewerConfiguration cli_config: :param .RenewerConfiguration cli_config:
:raises ValueError: if the configuration file's name didn't end :raises .CertStorageError: if the configuration file's name didn't end
in ".conf", or the file is missing or broken. in ".conf", or the file is missing or broken.
:raises TypeError: if the provided renewal configuration isn't a :raises TypeError: if the provided renewal configuration isn't a
ConfigObj object. ConfigObj object.
@@ -99,7 +100,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
self.cli_config = cli_config self.cli_config = cli_config
if isinstance(configfile, configobj.ConfigObj): if isinstance(configfile, configobj.ConfigObj):
if not os.path.basename(configfile.filename).endswith(".conf"): if not os.path.basename(configfile.filename).endswith(".conf"):
raise ValueError("renewal config file name must end in .conf") raise errors.CertStorageError(
"renewal config file name must end in .conf")
self.lineagename = os.path.basename( self.lineagename = os.path.basename(
configfile.filename)[:-len(".conf")] configfile.filename)[:-len(".conf")]
else: else:
@@ -117,8 +119,9 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
self.configuration.merge(self.configfile) self.configuration.merge(self.configfile)
if not all(x in self.configuration for x in ALL_FOUR): if not all(x in self.configuration for x in ALL_FOUR):
raise ValueError("renewal config file {0} is missing a required " raise errors.CertStorageError(
"file reference".format(configfile)) "renewal config file {0} is missing a required "
"file reference".format(configfile))
self.cert = self.configuration["cert"] self.cert = self.configuration["cert"]
self.privkey = self.configuration["privkey"] self.privkey = self.configuration["privkey"]
@@ -213,7 +216,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
""" """
if kind not in ALL_FOUR: if kind not in ALL_FOUR:
raise ValueError("unknown kind of item") raise errors.CertStorageError("unknown kind of item")
link = getattr(self, kind) link = getattr(self, kind)
if not os.path.exists(link): if not os.path.exists(link):
return None return None
@@ -236,7 +239,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
""" """
if kind not in ALL_FOUR: if kind not in ALL_FOUR:
raise ValueError("unknown kind of item") raise errors.CertStorageError("unknown kind of item")
pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind))
target = self.current_target(kind) target = self.current_target(kind)
if target is None or not os.path.exists(target): if target is None or not os.path.exists(target):
@@ -263,12 +266,12 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
""" """
if kind not in ALL_FOUR: if kind not in ALL_FOUR:
raise ValueError("unknown kind of item") raise errors.CertStorageError("unknown kind of item")
where = os.path.dirname(self.current_target(kind)) where = os.path.dirname(self.current_target(kind))
return os.path.join(where, "{0}{1}.pem".format(kind, version)) return os.path.join(where, "{0}{1}.pem".format(kind, version))
def available_versions(self, kind): def available_versions(self, kind):
"""Which lternative versions of the specified kind of item exist? """Which alternative versions of the specified kind of item exist?
The archive directory where the current version is stored is The archive directory where the current version is stored is
consulted to obtain the list of alternatives. consulted to obtain the list of alternatives.
@@ -281,7 +284,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
""" """
if kind not in ALL_FOUR: if kind not in ALL_FOUR:
raise ValueError("unknown kind of item") raise errors.CertStorageError("unknown kind of item")
where = os.path.dirname(self.current_target(kind)) where = os.path.dirname(self.current_target(kind))
files = os.listdir(where) files = os.listdir(where)
pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind))
@@ -308,7 +311,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
:rtype: int :rtype: int
""" """
# TODO: this can raise ValueError if there is no version overlap # TODO: this can raise CertStorageError if there is no version overlap
# (it should probably return None instead) # (it should probably return None instead)
# TODO: this can raise a spurious AttributeError if the current # TODO: this can raise a spurious AttributeError if the current
# link for any kind is missing (it should probably return None) # link for any kind is missing (it should probably return None)
@@ -355,7 +358,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
""" """
if kind not in ALL_FOUR: if kind not in ALL_FOUR:
raise ValueError("unknown kind of item") raise errors.CertStorageError("unknown kind of item")
link = getattr(self, kind) link = getattr(self, kind)
filename = "{0}{1}.pem".format(kind, version) filename = "{0}{1}.pem".format(kind, version)
# Relative rather than absolute target directory # Relative rather than absolute target directory
@@ -550,7 +553,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
config_file, config_filename = le_util.unique_lineage_name( config_file, config_filename = le_util.unique_lineage_name(
cli_config.renewal_configs_dir, lineagename) cli_config.renewal_configs_dir, lineagename)
if not config_filename.endswith(".conf"): if not config_filename.endswith(".conf"):
raise ValueError("renewal config file name must end in .conf") raise errors.CertStorageError(
"renewal config file name must end in .conf")
# Determine where on disk everything will go # Determine where on disk everything will go
# lineagename will now potentially be modified based on which # lineagename will now potentially be modified based on which
@@ -559,9 +563,11 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
archive = os.path.join(cli_config.archive_dir, lineagename) archive = os.path.join(cli_config.archive_dir, lineagename)
live_dir = os.path.join(cli_config.live_dir, lineagename) live_dir = os.path.join(cli_config.live_dir, lineagename)
if os.path.exists(archive): if os.path.exists(archive):
raise ValueError("archive directory exists for " + lineagename) raise errors.CertStorageError(
"archive directory exists for " + lineagename)
if os.path.exists(live_dir): if os.path.exists(live_dir):
raise ValueError("live directory exists for " + lineagename) raise errors.CertStorageError(
"live directory exists for " + lineagename)
os.mkdir(archive) os.mkdir(archive)
os.mkdir(live_dir) os.mkdir(live_dir)
relative_archive = os.path.join("..", "..", "archive", lineagename) relative_archive = os.path.join("..", "..", "archive", lineagename)
+10
View File
@@ -92,9 +92,19 @@ class PickPluginTest(unittest.TestCase):
def test_single(self): def test_single(self):
plugin_ep = mock.MagicMock() plugin_ep = mock.MagicMock()
plugin_ep.init.return_value = "foo" plugin_ep.init.return_value = "foo"
plugin_ep.misconfigured = False
self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep} self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep}
self.assertEqual("foo", self._call()) self.assertEqual("foo", self._call())
def test_single_misconfigured(self):
plugin_ep = mock.MagicMock()
plugin_ep.init.return_value = "foo"
plugin_ep.misconfigured = True
self.reg.ifaces().verify().available.return_value = {"bar": plugin_ep}
self.assertTrue(self._call() is None)
def test_multiple(self): def test_multiple(self):
plugin_ep = mock.MagicMock() plugin_ep = mock.MagicMock()
plugin_ep.init.return_value = "foo" plugin_ep.init.return_value = "foo"
+61
View File
@@ -11,6 +11,67 @@ import mock
from letsencrypt import errors from letsencrypt import errors
class RunScriptTest(unittest.TestCase):
"""Tests for letsencrypt.le_util.run_script."""
@classmethod
def _call(cls, params):
from letsencrypt.le_util import run_script
return run_script(params)
@mock.patch("letsencrypt.le_util.subprocess.Popen")
def test_default(self, mock_popen):
"""These will be changed soon enough with reload."""
mock_popen().returncode = 0
mock_popen().communicate.return_value = ("stdout", "stderr")
out, err = self._call(["test"])
self.assertEqual(out, "stdout")
self.assertEqual(err, "stderr")
@mock.patch("letsencrypt.le_util.subprocess.Popen")
def test_bad_process(self, mock_popen):
mock_popen.side_effect = OSError
self.assertRaises(errors.SubprocessError, self._call, ["test"])
@mock.patch("letsencrypt.le_util.subprocess.Popen")
def test_failure(self, mock_popen):
mock_popen().communicate.return_value = ("", "")
mock_popen().returncode = 1
self.assertRaises(errors.SubprocessError, self._call, ["test"])
class ExeExistsTest(unittest.TestCase):
"""Tests for letsencrypt.le_util.exe_exists."""
@classmethod
def _call(cls, exe):
from letsencrypt.le_util import exe_exists
return exe_exists(exe)
@mock.patch("letsencrypt.le_util.os.path.isfile")
@mock.patch("letsencrypt.le_util.os.access")
def test_full_path(self, mock_access, mock_isfile):
mock_access.return_value = True
mock_isfile.return_value = True
self.assertTrue(self._call("/path/to/exe"))
@mock.patch("letsencrypt.le_util.os.path.isfile")
@mock.patch("letsencrypt.le_util.os.access")
def test_on_path(self, mock_access, mock_isfile):
mock_access.return_value = True
mock_isfile.return_value = True
self.assertTrue(self._call("exe"))
@mock.patch("letsencrypt.le_util.os.path.isfile")
@mock.patch("letsencrypt.le_util.os.access")
def test_not_found(self, mock_access, mock_isfile):
mock_access.return_value = False
mock_isfile.return_value = True
self.assertFalse(self._call("exe"))
class MakeOrVerifyDirTest(unittest.TestCase): class MakeOrVerifyDirTest(unittest.TestCase):
"""Tests for letsencrypt.le_util.make_or_verify_dir. """Tests for letsencrypt.le_util.make_or_verify_dir.
+27 -15
View File
@@ -10,6 +10,7 @@ import mock
import pytz import pytz
from letsencrypt import configuration from letsencrypt import configuration
from letsencrypt import errors
from letsencrypt.storage import ALL_FOUR from letsencrypt.storage import ALL_FOUR
from letsencrypt.tests import test_util from letsencrypt.tests import test_util
@@ -78,7 +79,8 @@ class RenewableCertTests(unittest.TestCase):
for kind in ALL_FOUR: for kind in ALL_FOUR:
config["cert"] = "nonexistent_" + kind + ".pem" config["cert"] = "nonexistent_" + kind + ".pem"
config.filename = "nonexistent_sillyfile" config.filename = "nonexistent_sillyfile"
self.assertRaises(ValueError, storage.RenewableCert, config, defaults) self.assertRaises(
errors.CertStorageError, storage.RenewableCert, config, defaults)
self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults) self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults)
def test_renewal_incomplete_config(self): def test_renewal_incomplete_config(self):
@@ -92,7 +94,8 @@ class RenewableCertTests(unittest.TestCase):
config["chain"] = "imaginary_chain.pem" config["chain"] = "imaginary_chain.pem"
config["fullchain"] = "imaginary_fullchain.pem" config["fullchain"] = "imaginary_fullchain.pem"
config.filename = "imaginary_config.conf" config.filename = "imaginary_config.conf"
self.assertRaises(ValueError, storage.RenewableCert, config, defaults) self.assertRaises(
errors.CertStorageError, storage.RenewableCert, config, defaults)
def test_consistent(self): # pylint: disable=too-many-statements def test_consistent(self): # pylint: disable=too-many-statements
oldcert = self.test_rc.cert oldcert = self.test_rc.cert
@@ -481,11 +484,13 @@ class RenewableCertTests(unittest.TestCase):
# Now trigger the detection of already existing files # Now trigger the detection of already existing files
os.mkdir(os.path.join( os.mkdir(os.path.join(
self.cli_config.live_dir, "the-lineage.com-0002")) self.cli_config.live_dir, "the-lineage.com-0002"))
self.assertRaises(ValueError, storage.RenewableCert.new_lineage, self.assertRaises(errors.CertStorageError,
storage.RenewableCert.new_lineage,
"the-lineage.com", "cert3", "privkey3", "chain3", "the-lineage.com", "cert3", "privkey3", "chain3",
None, self.defaults, self.cli_config) None, self.defaults, self.cli_config)
os.mkdir(os.path.join(self.cli_config.archive_dir, "other-example.com")) os.mkdir(os.path.join(self.cli_config.archive_dir, "other-example.com"))
self.assertRaises(ValueError, storage.RenewableCert.new_lineage, self.assertRaises(errors.CertStorageError,
storage.RenewableCert.new_lineage,
"other-example.com", "cert4", "privkey4", "chain4", "other-example.com", "cert4", "privkey4", "chain4",
None, self.defaults, self.cli_config) None, self.defaults, self.cli_config)
# Make sure it can accept renewal parameters # Make sure it can accept renewal parameters
@@ -518,20 +523,27 @@ class RenewableCertTests(unittest.TestCase):
def test_invalid_config_filename(self, mock_uln): def test_invalid_config_filename(self, mock_uln):
from letsencrypt import storage from letsencrypt import storage
mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes"
self.assertRaises(ValueError, storage.RenewableCert.new_lineage, self.assertRaises(errors.CertStorageError,
storage.RenewableCert.new_lineage,
"example.com", "cert", "privkey", "chain", "example.com", "cert", "privkey", "chain",
None, self.defaults, self.cli_config) None, self.defaults, self.cli_config)
def test_bad_kind(self): def test_bad_kind(self):
self.assertRaises(ValueError, self.test_rc.current_target, "elephant") self.assertRaises(
self.assertRaises(ValueError, self.test_rc.current_version, "elephant") errors.CertStorageError, self.test_rc.current_target, "elephant")
self.assertRaises(ValueError, self.test_rc.version, "elephant", 17) self.assertRaises(
self.assertRaises(ValueError, self.test_rc.available_versions, errors.CertStorageError, self.test_rc.current_version, "elephant")
"elephant") self.assertRaises(
self.assertRaises(ValueError, self.test_rc.newest_available_version, errors.CertStorageError, self.test_rc.version, "elephant", 17)
"elephant") self.assertRaises(
self.assertRaises(ValueError, self.test_rc.update_link_to, errors.CertStorageError,
"elephant", 17) self.test_rc.available_versions, "elephant")
self.assertRaises(
errors.CertStorageError,
self.test_rc.newest_available_version, "elephant")
self.assertRaises(
errors.CertStorageError,
self.test_rc.update_link_to, "elephant", 17)
def test_ocsp_revoked(self): def test_ocsp_revoked(self):
# XXX: This is currently hardcoded to False due to a lack of an # XXX: This is currently hardcoded to False due to a lack of an
@@ -651,7 +663,7 @@ class RenewableCertTests(unittest.TestCase):
f.write("incomplete = configfile\n") f.write("incomplete = configfile\n")
renewer.main(self.defaults, args=[ renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir]) '--config-dir', self.cli_config.config_dir])
# The ValueError is caught inside and nothing happens. # The errors.CertStorageError is caught inside and nothing happens.
if __name__ == "__main__": if __name__ == "__main__":
+53 -10
View File
@@ -1,4 +1,6 @@
"""Test letsencrypt.reverter.""" """Test letsencrypt.reverter."""
import csv
import itertools
import logging import logging
import os import os
import shutil import shutil
@@ -11,7 +13,7 @@ from letsencrypt import errors
class ReverterCheckpointLocalTest(unittest.TestCase): class ReverterCheckpointLocalTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes, too-many-public-methods
"""Test the Reverter Class.""" """Test the Reverter Class."""
def setUp(self): def setUp(self):
from letsencrypt.reverter import Reverter from letsencrypt.reverter import Reverter
@@ -126,6 +128,42 @@ class ReverterCheckpointLocalTest(unittest.TestCase):
errors.ReverterError, self.reverter.register_file_creation, errors.ReverterError, self.reverter.register_file_creation,
"filepath") "filepath")
def test_register_undo_command(self):
coms = [
["a2dismod", "ssl"],
["a2dismod", "rewrite"],
["cleanslate"]
]
for com in coms:
self.reverter.register_undo_command(True, com)
act_coms = get_undo_commands(self.config.temp_checkpoint_dir)
for a_com, com in itertools.izip(act_coms, coms):
self.assertEqual(a_com, com)
def test_bad_register_undo_command(self):
m_open = mock.mock_open()
with mock.patch("letsencrypt.reverter.open", m_open, create=True):
m_open.side_effect = OSError("bad open")
self.assertRaises(
errors.ReverterError, self.reverter.register_undo_command,
True, ["command"])
@mock.patch("letsencrypt.le_util.run_script")
def test_run_undo_commands(self, mock_run):
mock_run.side_effect = ["", errors.SubprocessError]
coms = [
["invalid_command"],
["a2dismod", "ssl"],
]
for com in coms:
self.reverter.register_undo_command(True, com)
self.reverter.revert_temporary_config()
self.assertEqual(mock_run.call_count, 2)
def test_recovery_routine_in_progress_failure(self): def test_recovery_routine_in_progress_failure(self):
self.reverter.add_to_checkpoint(self.sets[0], "perm save") self.reverter.add_to_checkpoint(self.sets[0], "perm save")
@@ -377,7 +415,6 @@ def setup_work_direc():
""" """
work_dir = tempfile.mkdtemp("work") work_dir = tempfile.mkdtemp("work")
backup_dir = os.path.join(work_dir, "backup") backup_dir = os.path.join(work_dir, "backup")
os.makedirs(backup_dir)
return mock.MagicMock( return mock.MagicMock(
work_dir=work_dir, backup_dir=backup_dir, work_dir=work_dir, backup_dir=backup_dir,
@@ -391,9 +428,9 @@ def setup_test_files():
dir2 = tempfile.mkdtemp("dir2") dir2 = tempfile.mkdtemp("dir2")
config1 = os.path.join(dir1, "config.txt") config1 = os.path.join(dir1, "config.txt")
config2 = os.path.join(dir2, "config.txt") config2 = os.path.join(dir2, "config.txt")
with open(config1, 'w') as file_fd: with open(config1, "w") as file_fd:
file_fd.write("directive-dir1") file_fd.write("directive-dir1")
with open(config2, 'w') as file_fd: with open(config2, "w") as file_fd:
file_fd.write("directive-dir2") file_fd.write("directive-dir2")
sets = [set([config1]), sets = [set([config1]),
@@ -405,30 +442,36 @@ def setup_test_files():
def get_save_notes(dire): def get_save_notes(dire):
"""Read save notes""" """Read save notes"""
return read_in(os.path.join(dire, 'CHANGES_SINCE')) return read_in(os.path.join(dire, "CHANGES_SINCE"))
def get_filepaths(dire): def get_filepaths(dire):
"""Get Filepaths""" """Get Filepaths"""
return read_in(os.path.join(dire, 'FILEPATHS')) return read_in(os.path.join(dire, "FILEPATHS"))
def get_new_files(dire): def get_new_files(dire):
"""Get new files.""" """Get new files."""
return read_in(os.path.join(dire, 'NEW_FILES')).splitlines() return read_in(os.path.join(dire, "NEW_FILES")).splitlines()
def get_undo_commands(dire):
"""Get new files."""
with open(os.path.join(dire, "COMMANDS")) as csvfile:
return list(csv.reader(csvfile))
def read_in(path): def read_in(path):
"""Read in a file, return the str""" """Read in a file, return the str"""
with open(path, 'r') as file_fd: with open(path, "r") as file_fd:
return file_fd.read() return file_fd.read()
def update_file(filename, string): def update_file(filename, string):
"""Update a file with a new value.""" """Update a file with a new value."""
with open(filename, 'w') as file_fd: with open(filename, "w") as file_fd:
file_fd.write(string) file_fd.write(string)
if __name__ == '__main__': if __name__ == "__main__":
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
+2
View File
@@ -23,6 +23,8 @@ common() {
common --domains le1.wtf auth common --domains le1.wtf auth
common --domains le2.wtf run common --domains le2.wtf run
common -a manual -d le.wtf auth
common -a manual -d le.wtf --no-simple-http-tls auth
export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \ export CSR_PATH="${root}/csr.der" KEY_PATH="${root}/key.pem" \
OPENSSL_CNF=examples/openssl.cnf OPENSSL_CNF=examples/openssl.cnf
+2 -8
View File
@@ -10,11 +10,5 @@ export GOPATH="${GOPATH:-/tmp/go}"
go get -d github.com/letsencrypt/boulder/cmd/boulder go get -d github.com/letsencrypt/boulder/cmd/boulder
cd $GOPATH/src/github.com/letsencrypt/boulder cd $GOPATH/src/github.com/letsencrypt/boulder
make -j4 # Travis has 2 cores per build instance. ./start.py &
if [ "$1" = "amqp" ]; # Hopefully start.py bootstraps before integration test is started...
then
./start.py &
else
./start.sh &
fi
# Hopefully start.py/start.sh bootstraps before integration test is started...
+5 -2
View File
@@ -2,7 +2,9 @@
if [ "xxx$root" = "xxx" ]; if [ "xxx$root" = "xxx" ];
then then
root="$(mktemp -d)" # The -t is required on OS X. It provides a template file path for
# the kernel to use.
root="$(mktemp -d -t leitXXXX)"
echo "Root integration tests directory: $root" echo "Root integration tests directory: $root"
fi fi
store_flags="--config-dir $root/conf --work-dir $root/work" store_flags="--config-dir $root/conf --work-dir $root/work"
@@ -10,11 +12,12 @@ store_flags="$store_flags --logs-dir $root/logs"
export root store_flags export root store_flags
letsencrypt_test () { letsencrypt_test () {
# first three flags required, rest is handy defaults
letsencrypt \ letsencrypt \
--server "${SERVER:-http://localhost:4000/acme/new-reg}" \ --server "${SERVER:-http://localhost:4000/acme/new-reg}" \
--no-verify-ssl \ --no-verify-ssl \
--dvsni-port 5001 \ --dvsni-port 5001 \
--simple-http-port 5001 \
--manual-test-mode \
$store_flags \ $store_flags \
--text \ --text \
--agree-eula \ --agree-eula \
+1 -1
View File
@@ -22,5 +22,5 @@ rm -f .coverage # --cover-erase is off, make sure stats are correct
# after_success) # after_success)
cover letsencrypt 97 && \ cover letsencrypt 97 && \
cover acme 100 && \ cover acme 100 && \
cover letsencrypt_apache 78 && \ cover letsencrypt_apache 100 && \
cover letsencrypt_nginx 96 cover letsencrypt_nginx 96