diff --git a/Dockerfile b/Dockerfile index d7aca784b..02aa0f0d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,8 @@ WORKDIR /opt/letsencrypt # If doesn't exist, it is created along with all missing # directories in its path. -COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ + +COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ubuntu.sh RUN /opt/letsencrypt/src/ubuntu.sh && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* \ diff --git a/Dockerfile-dev b/Dockerfile-dev index 028366b2c..838b60e8b 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -22,7 +22,7 @@ WORKDIR /opt/letsencrypt # TODO: Install non-default Python versions for tox. # TODO: Install Apache/Nginx for plugin development. -COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ +COPY bootstrap/ubuntu.sh /opt/letsencrypt/src/ubuntu.sh RUN /opt/letsencrypt/src/ubuntu.sh && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* \ diff --git a/README.rst b/README.rst index d3e89c939..ce0d1b686 100644 --- a/README.rst +++ b/README.rst @@ -35,11 +35,11 @@ It's all automated: 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 certonly 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 certonly and if you have a compatible web server (Apache or Nginx), Let's Encrypt can not only get a new certificate, but also deploy it and configure your diff --git a/acme/acme/jose/jwa.py b/acme/acme/jose/jwa.py index 4ce5ca3f5..1853e0107 100644 --- a/acme/acme/jose/jwa.py +++ b/acme/acme/jose/jwa.py @@ -176,5 +176,5 @@ PS384 = JWASignature.register(_JWAPS('PS384', hashes.SHA384)) PS512 = JWASignature.register(_JWAPS('PS512', hashes.SHA512)) ES256 = JWASignature.register(_JWAES('ES256')) -ES256 = JWASignature.register(_JWAES('ES384')) -ES256 = JWASignature.register(_JWAES('ES512')) +ES384 = JWASignature.register(_JWAES('ES384')) +ES512 = JWASignature.register(_JWAES('ES512')) diff --git a/acme/docs/conf.py b/acme/docs/conf.py index 1448aaea3..55f5eee3f 100644 --- a/acme/docs/conf.py +++ b/acme/docs/conf.py @@ -227,25 +227,25 @@ htmlhelp_basename = 'acme-pythondoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'acme-python.tex', u'acme-python Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'acme-python.tex', u'acme-python Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -289,9 +289,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'acme-python', u'acme-python Documentation', - author, 'acme-python', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'acme-python', u'acme-python Documentation', + author, 'acme-python', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/bootstrap/_suse_common.sh b/bootstrap/_suse_common.sh new file mode 100755 index 000000000..4b41bac36 --- /dev/null +++ b/bootstrap/_suse_common.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +# SLE12 dont have python-virtualenv + +zypper -nq in -l git-core \ + python \ + python-devel \ + python-virtualenv \ + gcc \ + dialog \ + augeas-lenses \ + libopenssl-devel \ + libffi-devel \ + ca-certificates \ diff --git a/bootstrap/install-deps.sh b/bootstrap/install-deps.sh index 3cb0fc274..e907e7035 100755 --- a/bootstrap/install-deps.sh +++ b/bootstrap/install-deps.sh @@ -29,6 +29,9 @@ elif [ -f /etc/gentoo-release ] ; then elif uname | grep -iq FreeBSD ; then echo "Bootstrapping dependencies for FreeBSD..." $SUDO $BOOTSTRAP/freebsd.sh +elif `grep -qs openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE.." + $SUDO $BOOTSTRAP/suse.sh elif uname | grep -iq Darwin ; then echo "Bootstrapping dependencies for Mac OS X..." echo "WARNING: Mac support is very experimental at present..." diff --git a/bootstrap/suse.sh b/bootstrap/suse.sh new file mode 120000 index 000000000..fc4c1dee4 --- /dev/null +++ b/bootstrap/suse.sh @@ -0,0 +1 @@ +_suse_common.sh \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 62a7cea07..21bcc6817 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -230,25 +230,25 @@ htmlhelp_basename = 'LetsEncryptdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - ('index', 'LetsEncrypt.tex', u'Let\'s Encrypt Documentation', - u'Let\'s Encrypt Project', 'manual'), + ('index', 'LetsEncrypt.tex', u'Let\'s Encrypt Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -295,9 +295,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'LetsEncrypt', u'Let\'s Encrypt Documentation', - u'Let\'s Encrypt Project', 'LetsEncrypt', 'One line description of project.', - 'Miscellaneous'), + ('index', 'LetsEncrypt', u'Let\'s Encrypt Documentation', + u'Let\'s Encrypt Project', 'LetsEncrypt', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/letsencrypt-apache/docs/conf.py b/letsencrypt-apache/docs/conf.py index ddbf09262..aa58038cd 100644 --- a/letsencrypt-apache/docs/conf.py +++ b/letsencrypt-apache/docs/conf.py @@ -232,25 +232,25 @@ htmlhelp_basename = 'letsencrypt-apachedoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-apache.tex', u'letsencrypt-apache Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-apache.tex', u'letsencrypt-apache Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -293,9 +293,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-apache', u'letsencrypt-apache Documentation', - author, 'letsencrypt-apache', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-apache', u'letsencrypt-apache Documentation', + author, 'letsencrypt-apache', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py index b0b15d649..9e0948f12 100644 --- a/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/augeas_configurator.py @@ -73,7 +73,8 @@ class AugeasConfigurator(common.Plugin): This function first checks for save errors, if none are found, all configuration changes made will be saved. According to the - function parameters. + function parameters. If an exception is raised, a new checkpoint + was not created. :param str title: The title of the save. If a title is given, the configuration will be saved as a new checkpoint and put in a @@ -82,8 +83,9 @@ class AugeasConfigurator(common.Plugin): :param bool temporary: Indicates whether the changes made will 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 + :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") @@ -122,16 +124,16 @@ class AugeasConfigurator(common.Plugin): except errors.ReverterError as err: raise errors.PluginError(str(err)) + self.aug.set("/augeas/save", save_state) + self.save_notes = "" + self.aug.save() + if title and not temporary: try: self.reverter.finalize_checkpoint(title) except errors.ReverterError as err: raise errors.PluginError(str(err)) - self.aug.set("/augeas/save", save_state) - self.save_notes = "" - self.aug.save() - def _log_save_errors(self, ex_errs): """Log errors due to bad Augeas save. diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index eee7cdbc5..f10f0c241 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -306,6 +306,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): best_points = 0 for vhost in self.vhosts: + if vhost.modmacro is True: + continue if target_name in vhost.get_names(): points = 2 elif any(addr.get_addr() == target_name for addr in vhost.addrs): @@ -325,7 +327,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # No winners here... is there only one reasonable vhost? if best_candidate is None: # reasonable == Not all _default_ addrs - reasonable_vhosts = self._non_default_vhosts() + vhosts = self._non_default_vhosts() + # remove mod_macro hosts from reasonable vhosts + reasonable_vhosts = [vh for vh + in vhosts if vh.modmacro is False] if len(reasonable_vhosts) == 1: best_candidate = reasonable_vhosts[0] @@ -347,8 +352,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ all_names = set() + vhost_macro = [] + for vhost in self.vhosts: all_names.update(vhost.get_names()) + if vhost.modmacro: + vhost_macro.append(vhost.filep) for addr in vhost.addrs: if common.hostname_regex.match(addr.get_addr()): @@ -358,6 +367,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if name: all_names.add(name) + if len(vhost_macro) > 0: + zope.component.getUtility(interfaces.IDisplay).notification( + "Apache mod_macro seems to be in use in file(s):\n{0}" + "\n\nUnfortunately mod_macro is not yet supported".format( + "\n ".join(vhost_macro))) + return all_names def get_name_from_ip(self, addr): # pylint: disable=no-self-use @@ -394,11 +409,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "ServerAlias", None, start=host.path, exclude=False) for alias in serveralias_match: - host.aliases.add(self.parser.get_arg(alias)) + serveralias = self.parser.get_arg(alias) + if not host.modmacro: + host.aliases.add(serveralias) if servername_match: # Get last ServerName as each overwrites the previous - host.name = self.parser.get_arg(servername_match[-1]) + servername = self.parser.get_arg(servername_match[-1]) + if not host.modmacro: + host.name = servername def _create_vhost(self, path): """Used by get_virtual_hosts to create vhost objects @@ -421,7 +440,12 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): filename = get_file_path(path) is_enabled = self.is_site_enabled(filename) - vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) + macro = False + if "/macro/" in path.lower(): + macro = True + + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, + is_enabled, modmacro=macro) self._add_servernames(vhost) return vhost @@ -1234,7 +1258,7 @@ def get_file_path(vhost_path): avail_fp = vhost_path[6:] # This can be optimized... while True: - # Cast both to lowercase to be case insensitive + # Cast all to lowercase to be case insensitive find_if = avail_fp.lower().find("/ifmodule") if find_if != -1: avail_fp = avail_fp[:find_if] @@ -1243,6 +1267,10 @@ def get_file_path(vhost_path): if find_vh != -1: avail_fp = avail_fp[:find_vh] continue + find_macro = avail_fp.lower().find("/macro") + if find_macro != -1: + avail_fp = avail_fp[:find_macro] + continue break return avail_fp diff --git a/letsencrypt-apache/letsencrypt_apache/obj.py b/letsencrypt-apache/letsencrypt_apache/obj.py index 58a6c740e..175ce3f92 100644 --- a/letsencrypt-apache/letsencrypt_apache/obj.py +++ b/letsencrypt-apache/letsencrypt_apache/obj.py @@ -102,6 +102,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods :ivar bool ssl: SSLEngine on in vhost :ivar bool enabled: Virtual host is enabled + :ivar bool modmacro: VirtualHost is using mod_macro https://httpd.apache.org/docs/2.4/vhosts/details.html @@ -112,7 +113,9 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods # ?: is used for not returning enclosed characters strip_name = re.compile(r"^(?:.+://)?([^ :$]*)") - def __init__(self, filep, path, addrs, ssl, enabled, name=None, aliases=None): + def __init__(self, filep, path, addrs, ssl, enabled, name=None, + aliases=None, modmacro=False): + # pylint: disable=too-many-arguments """Initialize a VH.""" self.filep = filep @@ -122,6 +125,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods self.aliases = aliases if aliases is not None else set() self.ssl = ssl self.enabled = enabled + self.modmacro = modmacro def get_names(self): """Return a set of all names.""" @@ -141,21 +145,25 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods "Name: {name}\n" "Aliases: {aliases}\n" "TLS Enabled: {tls}\n" - "Site Enabled: {active}".format( + "Site Enabled: {active}\n" + "mod_macro Vhost: {modmacro}".format( filename=self.filep, vhpath=self.path, addrs=", ".join(str(addr) for addr in self.addrs), name=self.name if self.name is not None else "", aliases=", ".join(name for name in self.aliases), tls="Yes" if self.ssl else "No", - active="Yes" if self.enabled else "No")) + active="Yes" if self.enabled else "No", + modmacro="Yes" if self.modmacro else "No")) def __eq__(self, other): if isinstance(other, self.__class__): return (self.filep == other.filep and self.path == other.path and self.addrs == other.addrs and self.get_names() == other.get_names() and - self.ssl == other.ssl and self.enabled == other.enabled) + self.ssl == other.ssl and + self.enabled == other.enabled and + self.modmacro == other.modmacro) return False diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index 971218170..0350a32ec 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -59,14 +59,20 @@ class TwoVhost80Test(util.ApacheTest): # Weak test.. ApacheConfigurator.add_parser_arguments(mock.MagicMock()) - def test_get_all_names(self): + @mock.patch("zope.component.getUtility") + def test_get_all_names(self, mock_getutility): + mock_getutility.notification = mock.MagicMock(return_value=True) names = self.config.get_all_names() self.assertEqual(names, set( ["letsencrypt.demo", "encryption-example.demo", "ip-172-30-0-17"])) + @mock.patch("zope.component.getUtility") @mock.patch("letsencrypt_apache.configurator.socket.gethostbyaddr") - def test_get_all_names_addrs(self, mock_gethost): + def test_get_all_names_addrs(self, mock_gethost, mock_getutility): mock_gethost.side_effect = [("google.com", "", ""), socket.error] + notification = mock.Mock() + notification.notification = mock.Mock(return_value=True) + mock_getutility.return_value = notification vhost = obj.VirtualHost( "fp", "ap", set([obj.Addr(("8.8.8.8", "443")), @@ -97,7 +103,7 @@ class TwoVhost80Test(util.ApacheTest): """ vhs = self.config.get_virtual_hosts() - self.assertEqual(len(vhs), 4) + self.assertEqual(len(vhs), 5) found = 0 for vhost in vhs: @@ -108,7 +114,7 @@ class TwoVhost80Test(util.ApacheTest): else: raise Exception("Missed: %s" % vhost) # pragma: no cover - self.assertEqual(found, 4) + self.assertEqual(found, 5) @mock.patch("letsencrypt_apache.display_ops.select_vhost") def test_choose_vhost_none_avail(self, mock_select): @@ -174,7 +180,7 @@ class TwoVhost80Test(util.ApacheTest): def test_non_default_vhosts(self): # pylint: disable=protected-access - self.assertEqual(len(self.config._non_default_vhosts()), 3) + self.assertEqual(len(self.config._non_default_vhosts()), 4) def test_is_site_enabled(self): """Test if site is enabled. @@ -345,7 +351,7 @@ class TwoVhost80Test(util.ApacheTest): self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), self.config.is_name_vhost(ssl_vhost)) - self.assertEqual(len(self.config.vhosts), 5) + self.assertEqual(len(self.config.vhosts), 6) def test_make_vhost_ssl_extra_vhs(self): self.config.aug.match = mock.Mock(return_value=["p1", "p2"]) @@ -587,7 +593,7 @@ class TwoVhost80Test(util.ApacheTest): 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) + self.assertEqual(len(self.config.vhosts), 6) def get_achalls(self): """Return testing achallenges.""" diff --git a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py index d7cfb09b3..6db319d87 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/display_ops_test.py @@ -57,7 +57,7 @@ class SelectVhostTest(unittest.TestCase): @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) + mock_util().menu.return_value = (display_util.OK, 5) self.vhosts.append( obj.VirtualHost( @@ -65,7 +65,7 @@ class SelectVhostTest(unittest.TestCase): False, False, "wildcard.com", set(["*.wildcard.com"]))) - self.assertEqual(self.vhosts[4], self._call(self.vhosts)) + self.assertEqual(self.vhosts[5], self._call(self.vhosts)) if __name__ == "__main__": diff --git a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py index d2e4dec14..bc1f316f9 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/parser_test.py @@ -52,7 +52,7 @@ class BasicParserTest(util.ParserTest): test2 = self.parser.find_dir("documentroot") self.assertEqual(len(test), 1) - self.assertEqual(len(test2), 3) + self.assertEqual(len(test2), 4) def test_add_dir(self): aug_default = "/files" + self.parser.loc["default"] diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf new file mode 100644 index 000000000..6a6579007 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-available/mod_macro-example.conf @@ -0,0 +1,15 @@ + + + ServerName $domain + ServerAlias www.$domain + DocumentRoot /var/www/html + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + +Use VHost macro1 test.com +Use VHost macro2 hostname.org +Use VHost macro3 apache.org + +# vim: syntax=apache ts=4 sw=4 sts=4 sr noet diff --git a/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf new file mode 120000 index 000000000..44f254304 --- /dev/null +++ b/letsencrypt-apache/letsencrypt_apache/tests/testdata/debian_apache_2_4/two_vhost_80/apache2/sites-enabled/mod_macro-example.conf @@ -0,0 +1 @@ +../sites-available/mod_macro-example.conf \ No newline at end of file diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index 2594ba773..a8bfe0e4b 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -124,6 +124,11 @@ def get_vh_truth(temp_dir, config_name): os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), set([obj.Addr.fromstring("*:80")]), False, True, "letsencrypt.demo"), + obj.VirtualHost( + os.path.join(prefix, "mod_macro-example.conf"), + os.path.join(aug_pre, + "mod_macro-example.conf/Macro/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, modmacro=True) ] return vh_truth diff --git a/letsencrypt-auto b/letsencrypt-auto index ba95350e4..25c83cc08 100755 --- a/letsencrypt-auto +++ b/letsencrypt-auto @@ -8,17 +8,16 @@ # without requiring specific versions of its dependencies from the operating # system. +# Note: you can set XDG_DATA_HOME or VENV_PATH before running this script, +# if you want to change where the virtual environment will be installed XDG_DATA_HOME=${XDG_DATA_HOME:-~/.local/share} VENV_NAME="letsencrypt" VENV_PATH=${VENV_PATH:-"$XDG_DATA_HOME/$VENV_NAME"} VENV_BIN=${VENV_PATH}/bin -if test "`id -u`" -ne "0" ; then - SUDO=sudo -else - SUDO= -fi - +# This script takes the same arguments as the main letsencrypt program, but it +# additionally responds to --verbose (more output) and --debug (allow support +# for experimental platforms) for arg in "$@" ; do # This first clause is redundant with the third, but hedging on portability if [ "$arg" = "-v" ] || [ "$arg" = "--verbose" ] || echo "$arg" | grep -E -- "-v+$" ; then @@ -28,6 +27,42 @@ for arg in "$@" ; do fi done +# letsencrypt-auto needs root access to bootstrap OS dependencies, and +# letsencrypt itself needs root access for almost all modes of operation +# The "normal" case is that sudo is used for the steps that need root, but +# this script *can* be run as root (not recommended), or fall back to using +# `su` +if test "`id -u`" -ne "0" ; then + if command -v sudo 1>/dev/null 2>&1; then + SUDO=sudo + else + echo \"sudo\" is not available, will use \"su\" for installation steps... + # Because the parameters in `su -c` has to be a string, + # we need properly escape it + su_sudo() { + args="" + # This `while` loop iterates over all parameters given to this function. + # For each parameter, all `'` will be replace by `'"'"'`, and the escaped string + # will be wrap in a pair of `'`, then append to `$args` string + # For example, `echo "It's only 1\$\!"` will be escaped to: + # 'echo' 'It'"'"'s only 1$!' + # │ │└┼┘│ + # │ │ │ └── `'s only 1$!'` the literal string + # │ │ └── `\"'\"` is a single quote (as a string) + # │ └── `'It'`, to be concatenated with the strings followed it + # └── `echo` wrapped in a pair of `'`, it's totally fine for the shell command itself + while [ $# -ne 0 ]; do + args="$args'$(printf "%s" "$1" | sed -e "s/'/'\"'\"'/g")' " + shift + done + su root -c "$args" + } + SUDO=su_sudo + fi +else + SUDO= +fi + ExperimentalBootstrap() { # Arguments: Platform name, boostrap script name, SUDO command (iff needed) if [ "$DEBUG" = 1 ] ; then @@ -85,6 +120,9 @@ then elif [ -f /etc/redhat-release ] ; then echo "Bootstrapping dependencies for RedHat-based OSes..." $SUDO $BOOTSTRAP/_rpm_common.sh + elif `grep -q openSUSE /etc/os-release` ; then + echo "Bootstrapping dependencies for openSUSE-based OSes..." + $SUDO $BOOTSTRAP/_suse_common.sh elif [ -f /etc/arch-release ] ; then echo "Bootstrapping dependencies for Archlinux..." $SUDO $BOOTSTRAP/archlinux.sh @@ -133,7 +171,7 @@ else $VENV_BIN/pip install -U pip > /dev/null printf . # nginx is buggy / disabled for now... - $VENV_BIN/pip install -r py26reqs.txt + $VENV_BIN/pip install -r py26reqs.txt > /dev/null printf . $VENV_BIN/pip install -U letsencrypt > /dev/null printf . diff --git a/letsencrypt-compatibility-test/docs/conf.py b/letsencrypt-compatibility-test/docs/conf.py index 7e9f0d5a4..3ee161efb 100644 --- a/letsencrypt-compatibility-test/docs/conf.py +++ b/letsencrypt-compatibility-test/docs/conf.py @@ -226,25 +226,26 @@ htmlhelp_basename = 'letsencrypt-compatibility-testdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-compatibility-test.tex', u'letsencrypt-compatibility-test Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-compatibility-test.tex', + u'letsencrypt-compatibility-test Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -273,7 +274,8 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, 'letsencrypt-compatibility-test', u'letsencrypt-compatibility-test Documentation', + (master_doc, 'letsencrypt-compatibility-test', + u'letsencrypt-compatibility-test Documentation', [author], 1) ] @@ -287,9 +289,10 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-compatibility-test', u'letsencrypt-compatibility-test Documentation', - author, 'letsencrypt-compatibility-test', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-compatibility-test', + u'letsencrypt-compatibility-test Documentation', + author, 'letsencrypt-compatibility-test', + 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. @@ -309,6 +312,8 @@ intersphinx_mapping = { 'python': ('https://docs.python.org/', None), 'acme': ('https://acme-python.readthedocs.org/en/latest/', None), 'letsencrypt': ('https://letsencrypt.readthedocs.org/en/latest/', None), - 'letsencrypt-apache': ('https://letsencrypt-apache.readthedocs.org/en/latest/', None), - 'letsencrypt-nginx': ('https://letsencrypt-nginx.readthedocs.org/en/latest/', None), + 'letsencrypt-apache': ( + 'https://letsencrypt-apache.readthedocs.org/en/latest/', None), + 'letsencrypt-nginx': ( + 'https://letsencrypt-nginx.readthedocs.org/en/latest/', None), } diff --git a/letsencrypt-nginx/docs/conf.py b/letsencrypt-nginx/docs/conf.py index cdb3490a0..14713a4b2 100644 --- a/letsencrypt-nginx/docs/conf.py +++ b/letsencrypt-nginx/docs/conf.py @@ -225,25 +225,25 @@ htmlhelp_basename = 'letsencrypt-nginxdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letsencrypt-nginx.tex', u'letsencrypt-nginx Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letsencrypt-nginx.tex', u'letsencrypt-nginx Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -286,9 +286,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letsencrypt-nginx', u'letsencrypt-nginx Documentation', - author, 'letsencrypt-nginx', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letsencrypt-nginx', u'letsencrypt-nginx Documentation', + author, 'letsencrypt-nginx', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/letsencrypt-nginx/letsencrypt_nginx/configurator.py b/letsencrypt-nginx/letsencrypt_nginx/configurator.py index 0123ac321..d97cf7397 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/configurator.py +++ b/letsencrypt-nginx/letsencrypt_nginx/configurator.py @@ -107,6 +107,10 @@ class NginxConfigurator(common.Plugin): # This is called in determine_authenticator and determine_installer def prepare(self): """Prepare the authenticator/installer.""" + # Verify Nginx is installed + if not le_util.exe_exists(self.conf('ctl')): + raise errors.NoInstallationError + self.parser = parser.NginxParser( self.conf('server-root'), self.mod_ssl_conf) diff --git a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py b/letsencrypt-nginx/letsencrypt_nginx/dvsni.py index b388c0267..8fd705f08 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/dvsni.py +++ b/letsencrypt-nginx/letsencrypt_nginx/dvsni.py @@ -99,8 +99,8 @@ class NginxDvsni(common.TLSSNI01): for key, body in main: if key == ['http']: found_bucket = False - for key, _ in body: - if key == bucket_directive[0]: + for k, _ in body: + if k == bucket_directive[0]: found_bucket = True if not found_bucket: body.insert(0, bucket_directive) diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py index 7000f85dc..913c5de27 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/configurator_test.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-public-methods """Test for letsencrypt_nginx.configurator.""" import os import shutil @@ -29,6 +30,12 @@ class NginxConfiguratorTest(util.NginxTest): shutil.rmtree(self.config_dir) shutil.rmtree(self.work_dir) + @mock.patch("letsencrypt_nginx.configurator.le_util.exe_exists") + def test_prepare_no_install(self, mock_exe_exists): + mock_exe_exists.return_value = False + self.assertRaises( + errors.NoInstallationError, self.config.prepare) + def test_prepare(self): self.assertEquals((1, 6, 2), self.config.version) self.assertEquals(5, len(self.config.parser.parsed)) diff --git a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py index cb4e08ddf..e60feb3d3 100644 --- a/letsencrypt-nginx/letsencrypt_nginx/tests/util.py +++ b/letsencrypt-nginx/letsencrypt_nginx/tests/util.py @@ -49,21 +49,25 @@ def get_nginx_configurator( backups = os.path.join(work_dir, "backups") - config = configurator.NginxConfigurator( - config=mock.MagicMock( - nginx_server_root=config_path, - le_vhost_ext="-le-ssl.conf", - config_dir=config_dir, - work_dir=work_dir, - backup_dir=backups, - temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), - in_progress_dir=os.path.join(backups, "IN_PROGRESS"), - server="https://acme-server.org:443/new", - tls_sni_01_port=5001, - ), - name="nginx", - version=version) - config.prepare() + with mock.patch("letsencrypt_nginx.configurator.le_util." + "exe_exists") as mock_exe_exists: + mock_exe_exists.return_value = True + + config = configurator.NginxConfigurator( + config=mock.MagicMock( + nginx_server_root=config_path, + le_vhost_ext="-le-ssl.conf", + config_dir=config_dir, + work_dir=work_dir, + backup_dir=backups, + temp_checkpoint_dir=os.path.join(work_dir, "temp_checkpoints"), + in_progress_dir=os.path.join(backups, "IN_PROGRESS"), + server="https://acme-server.org:443/new", + tls_sni_01_port=5001, + ), + name="nginx", + version=version) + config.prepare() # Provide general config utility. nsconfig = configuration.NamespaceConfig(config.config) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 38f7ee45d..32519ad05 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -557,6 +557,7 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print logger.debug("Filtered plugins: %r", filtered) if not args.init and not args.prepare: + print str(filtered) return filtered.init(config) @@ -564,17 +565,19 @@ def plugins_cmd(args, config, plugins): # TODO: Use IDisplay rather than print logger.debug("Verified plugins: %r", verified) if not args.prepare: + print str(verified) return verified.prepare() available = verified.available() logger.debug("Prepared plugins: %s", available) + print str(available) def read_file(filename, mode="rb"): """Returns the given file's contents. - :param str filename: Filename + :param str filename: filename as an absolute path :param str mode: open mode (see `open`) :returns: A tuple of filename and its contents @@ -584,6 +587,7 @@ def read_file(filename, mode="rb"): """ try: + filename = os.path.abspath(filename) return filename, open(filename, mode).read() except IOError as exc: raise argparse.ArgumentTypeError(exc.strerror) @@ -676,8 +680,32 @@ class HelpfulArgumentParser(object): parsed_args = self.parser.parse_args(self.args) parsed_args.func = self.VERBS[self.verb] + parsed_args.domains = self._parse_domains(parsed_args.domains) return parsed_args + def _parse_domains(self, domains): + """Helper function for parse_args() that parses domains from a + (possibly) comma separated list and returns list of unique domains. + + :param domains: List of domain flags + :type domains: `list` of `string` + + :returns: List of unique domains + :rtype: `list` of `string` + + """ + + uniqd = None + + if domains: + dlist = [] + for domain in domains: + dlist.extend([d.strip() for d in domain.split(",")]) + # Make sure we don't have duplicates + uniqd = [d for i, d in enumerate(dlist) if d not in dlist[:i]] + + return uniqd + def determine_verb(self): """Determines the verb/subcommand provided by the user. @@ -819,7 +847,11 @@ def prepare_and_parse_args(plugins, args): # --domains is useful, because it can be stored in config #for subparser in parser_run, parser_auth, parser_install: # subparser.add_argument("domains", nargs="*", metavar="domain") - helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append") + helpful.add(None, "-d", "--domains", dest="domains", + metavar="DOMAIN", action="append", + help="Domain names to apply. For multiple domains you can use " + "multiple -d flags or enter a comma separated list of domains" + "as a parameter.") helpful.add( None, "--duplicate", dest="duplicate", action="store_true", help="Allow getting a certificate that duplicates an existing one") @@ -888,7 +920,6 @@ def prepare_and_parse_args(plugins, args): # parser (--help should display plugin-specific options last) _plugins_parsing(helpful, plugins) - return helpful.parse_args() @@ -939,26 +970,28 @@ def _paths_parser(helpful): if verb in ("install", "revoke", "certonly"): section = verb if verb == "certonly": - add(section, "--cert-path", default=flag_default("auth_cert_path"), help=cph) + add(section, "--cert-path", type=os.path.abspath, + default=flag_default("auth_cert_path"), help=cph) elif verb == "revoke": add(section, "--cert-path", type=read_file, required=True, help=cph) else: - add(section, "--cert-path", help=cph, required=(verb == "install")) + add(section, "--cert-path", type=os.path.abspath, + help=cph, required=(verb == "install")) section = "paths" if verb in ("install", "revoke"): section = verb # revoke --key-path reads a file, install --key-path takes a string - add(section, "--key-path", type=((verb == "revoke" and read_file) or str), - required=(verb == "install"), + add(section, "--key-path", required=(verb == "install"), + type=((verb == "revoke" and read_file) or os.path.abspath), help="Path to private key for cert creation or revocation (if account key is missing)") default_cp = None if verb == "certonly": default_cp = flag_default("auth_chain_path") - add("paths", "--fullchain-path", default=default_cp, + add("paths", "--fullchain-path", default=default_cp, type=os.path.abspath, help="Accompanying path to a full certificate chain (cert plus chain).") - add("paths", "--chain-path", default=default_cp, + add("paths", "--chain-path", default=default_cp, type=os.path.abspath, help="Accompanying path to a certificate chain.") add("paths", "--config-dir", default=flag_default("config_dir"), help=config_help("config_dir")) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 6201ff1d5..44ac76394 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -5,6 +5,7 @@ import os from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa import OpenSSL +import zope.component from acme import client as acme_client from acme import jose @@ -20,6 +21,7 @@ from letsencrypt import continuity_auth from letsencrypt import crypto_util from letsencrypt import errors from letsencrypt import error_handler +from letsencrypt import interfaces from letsencrypt import le_util from letsencrypt import reverter from letsencrypt import storage @@ -345,26 +347,13 @@ class Client(object): self.installer.save("Deployed Let's Encrypt Certificate") - # sites may have been enabled / final cleanup - with error_handler.ErrorHandler(self._rollback_and_restart): + msg = ("We were unable to install your certificate, " + "however, we successfully restored your " + "server to its prior configuration.") + with error_handler.ErrorHandler(self._rollback_and_restart, msg): + # sites may have been enabled / final cleanup self.installer.restart() - def _rollback_and_restart(self): - """Rollback the most recent checkpoint and restart the webserver""" - logger.critical("Rolling back to previous server configuration...") - try: - self.installer.rollback_checkpoints() - self.installer.restart() - except: - # TODO: suggest letshelp-letsencypt here - logger.critical("Failure to rollback config " - "changes and restart your server") - logger.critical("Please submit a bug report to " - "https://github.com/letsencrypt/letsencrypt") - raise - logger.critical("Rollback successful; your server has " - "been restarted with your old configuration") - def enhance_config(self, domains, redirect=None): """Enhance the configuration. @@ -401,7 +390,9 @@ class Client(object): :type vhost: :class:`letsencrypt.interfaces.IInstaller` """ - with error_handler.ErrorHandler(self.installer.recovery_routine): + msg = ("We were unable to set up a redirect for your server, " + "however, we successfully installed your certificate.") + with error_handler.ErrorHandler(self._recovery_routine_with_msg, msg): for dom in domains: try: self.installer.enhance(dom, "redirect") @@ -410,8 +401,41 @@ class Client(object): raise self.installer.save("Add Redirects") + + with error_handler.ErrorHandler(self._rollback_and_restart, msg): self.installer.restart() + def _recovery_routine_with_msg(self, success_msg): + """Calls the installer's recovery routine and prints success_msg + + :param str success_msg: message to show on successful recovery + + """ + self.installer.recovery_routine() + reporter = zope.component.getUtility(interfaces.IReporter) + reporter.add_message(success_msg, reporter.HIGH_PRIORITY) + + def _rollback_and_restart(self, success_msg): + """Rollback the most recent checkpoint and restart the webserver + + :param str success_msg: message to show on successful rollback + + """ + logger.critical("Rolling back to previous server configuration...") + reporter = zope.component.getUtility(interfaces.IReporter) + try: + self.installer.rollback_checkpoints() + self.installer.restart() + except: + # TODO: suggest letshelp-letsencypt here + reporter.add_message( + "An error occured and we failed to restore your config and " + "restart your server. Please submit a bug report to " + "https://github.com/letsencrypt/letsencrypt", + reporter.HIGH_PRIORITY) + raise + reporter.add_message(success_msg, reporter.HIGH_PRIORITY) + def validate_key_csr(privkey, csr=None): """Validate Key and CSR files. diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index e70171675..4955655f3 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -37,6 +37,11 @@ class NamespaceConfig(object): def __init__(self, namespace): self.namespace = namespace + + self.namespace.config_dir = os.path.abspath(self.namespace.config_dir) + self.namespace.work_dir = os.path.abspath(self.namespace.work_dir) + self.namespace.logs_dir = os.path.abspath(self.namespace.logs_dir) + # Check command line parameters sanity, and error out in case of problem. check_config_sanity(self) diff --git a/letsencrypt/error_handler.py b/letsencrypt/error_handler.py index 8b0eb7c8b..431e677a1 100644 --- a/letsencrypt/error_handler.py +++ b/letsencrypt/error_handler.py @@ -1,4 +1,5 @@ """Registers functions to be called if an exception or signal occurs.""" +import functools import logging import os import signal @@ -40,11 +41,11 @@ class ErrorHandler(object): to be called again by the next signal handler. """ - def __init__(self, func=None): + def __init__(self, func=None, *args, **kwargs): self.funcs = [] self.prev_handlers = {} if func is not None: - self.register(func) + self.register(func, *args, **kwargs) def __enter__(self): self.set_signal_handlers() @@ -57,9 +58,13 @@ class ErrorHandler(object): self.call_registered() self.reset_signal_handlers() - def register(self, func): - """Registers func to be called if an error occurs.""" - self.funcs.append(func) + def register(self, func, *args, **kwargs): + """Sets func to be called with *args and **kwargs during cleanup + + :param function func: function to be called in case of an error + + """ + self.funcs.append(functools.partial(func, *args, **kwargs)) def call_registered(self): """Calls all registered functions""" diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 987fdc25e..c8a725fde 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -298,7 +298,8 @@ class IInstaller(IPlugin): Both title and temporary are needed because a save may be intended to be permanent, but the save is not ready to be a full - checkpoint + checkpoint. If an exception is raised, it is assumed a new + checkpoint was not created. :param str title: The title of the save. If a title is given, the configuration will be saved as a new checkpoint and put in a diff --git a/letsencrypt/plugins/disco_test.py b/letsencrypt/plugins/disco_test.py index 41d8cd5fe..0df4f88f1 100644 --- a/letsencrypt/plugins/disco_test.py +++ b/letsencrypt/plugins/disco_test.py @@ -51,8 +51,8 @@ class PluginEntryPointTest(unittest.TestCase): def test_description(self): self.assertEqual( - "Automatically use a temporary webserver", - self.plugin_ep.description) + "Automatically use a temporary webserver", + self.plugin_ep.description) def test_description_with_name(self): self.plugin_ep.plugin_cls = mock.MagicMock(description="Desc") diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py index 13b0eba7a..fd5aba788 100644 --- a/letsencrypt/tests/cli_test.py +++ b/letsencrypt/tests/cli_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.cli.""" +import argparse import itertools import os import shutil @@ -24,7 +25,7 @@ from letsencrypt.tests import test_util CSR = test_util.vector_path('csr.der') -class CLITest(unittest.TestCase): +class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods """Tests for different commands.""" def setUp(self): @@ -113,7 +114,6 @@ class CLITest(unittest.TestCase): out = self._help_output(['-h']) self.assertTrue(cli.usage_strings(plugins)[0] in out) - @mock.patch('letsencrypt.cli.sys.stdout') @mock.patch('letsencrypt.cli.sys.stderr') @mock.patch('letsencrypt.cli.client.acme_client.Client') @@ -142,14 +142,35 @@ class CLITest(unittest.TestCase): cli.main(args) acme_net.assert_called_once_with(mock.ANY, verify_ssl=True, user_agent=ua) + self._call(['install', '--domain', 'foo.bar', '--cert-path', 'cert', + + def test_install_abspath(self): + cert = 'cert' + key = 'key' + chain = 'chain' + fullchain = 'fullchain' + + with MockedVerb('install') as mock_install: + self._call(['install', '--cert-path', cert, '--key-path', 'key', + '--chain-path', 'chain', + '--fullchain-path', 'fullchain']) + + args = mock_install.call_args[0][0] + self.assertEqual(args.cert_path, os.path.abspath(cert)) + self.assertEqual(args.key_path, os.path.abspath(key)) + self.assertEqual(args.chain_path, os.path.abspath(chain)) + self.assertEqual(args.fullchain_path, os.path.abspath(fullchain)) + @mock.patch('letsencrypt.cli.record_chosen_plugins') @mock.patch('letsencrypt.cli.display_ops') def test_installer_selection(self, mock_display_ops, _rec): - self._call(['install', '--domain', 'foo.bar', '--cert-path', 'cert', + self._call(['install', '--domains', 'foo.bar', '--cert-path', 'cert', '--key-path', 'key', '--chain-path', 'chain']) self.assertEqual(mock_display_ops.pick_installer.call_count, 1) - def test_configurator_selection(self): + @mock.patch('letsencrypt.le_util.exe_exists') + def test_configurator_selection(self, mock_exe_exists): + mock_exe_exists.return_value = True real_plugins = disco.PluginsRegistry.find_all() args = ['--agree-dev-preview', '--apache', '--authenticator', 'standalone'] @@ -196,6 +217,65 @@ class CLITest(unittest.TestCase): for r in xrange(len(flags)))): self._call(['plugins'] + list(args)) + @mock.patch('letsencrypt.cli.plugins_disco') + def test_plugins_no_args(self, mock_disco): + ifaces = [] + plugins = mock_disco.PluginsRegistry.find_all() + + _, stdout, _, _ = self._call(['plugins']) + plugins.visible.assert_called_once_with() + plugins.visible().ifaces.assert_called_once_with(ifaces) + filtered = plugins.visible().ifaces() + stdout.write.called_once_with(str(filtered)) + + @mock.patch('letsencrypt.cli.plugins_disco') + def test_plugins_init(self, mock_disco): + ifaces = [] + plugins = mock_disco.PluginsRegistry.find_all() + + _, stdout, _, _ = self._call(['plugins', '--init']) + plugins.visible.assert_called_once_with() + plugins.visible().ifaces.assert_called_once_with(ifaces) + filtered = plugins.visible().ifaces() + self.assertEqual(filtered.init.call_count, 1) + filtered.verify.assert_called_once_with(ifaces) + verified = filtered.verify() + stdout.write.called_once_with(str(verified)) + + @mock.patch('letsencrypt.cli.plugins_disco') + def test_plugins_prepare(self, mock_disco): + ifaces = [] + plugins = mock_disco.PluginsRegistry.find_all() + + _, stdout, _, _ = self._call(['plugins', '--init', '--prepare']) + plugins.visible.assert_called_once_with() + plugins.visible().ifaces.assert_called_once_with(ifaces) + filtered = plugins.visible().ifaces() + self.assertEqual(filtered.init.call_count, 1) + filtered.verify.assert_called_once_with(ifaces) + verified = filtered.verify() + verified.prepare.assert_called_once_with() + verified.available.assert_called_once_with() + available = verified.available() + stdout.write.called_once_with(str(available)) + + def test_certonly_abspath(self): + cert = 'cert' + key = 'key' + chain = 'chain' + fullchain = 'fullchain' + + with MockedVerb('certonly') as mock_obtaincert: + self._call(['certonly', '--cert-path', cert, '--key-path', 'key', + '--chain-path', 'chain', + '--fullchain-path', 'fullchain']) + + args = mock_obtaincert.call_args[0][0] + self.assertEqual(args.cert_path, os.path.abspath(cert)) + self.assertEqual(args.key_path, os.path.abspath(key)) + self.assertEqual(args.chain_path, os.path.abspath(chain)) + self.assertEqual(args.fullchain_path, os.path.abspath(fullchain)) + def test_certonly_bad_args(self): ret, _, _, _ = self._call(['-d', 'foo.bar', 'certonly', '--csr', CSR]) self.assertEqual(ret, '--domains and --csr are mutually exclusive') @@ -221,6 +301,27 @@ class CLITest(unittest.TestCase): self._call, ['-d', '*.wildcard.tld']) + def test_parse_domains(self): + from letsencrypt import cli + plugins = disco.PluginsRegistry.find_all() + + short_args = ['-d', 'example.com'] + namespace = cli.prepare_and_parse_args(plugins, short_args) + self.assertEqual(namespace.domains, ['example.com']) + + short_args = ['-d', 'example.com,another.net,third.org,example.com'] + namespace = cli.prepare_and_parse_args(plugins, short_args) + self.assertEqual(namespace.domains, ['example.com', 'another.net', + 'third.org']) + + long_args = ['--domains', 'example.com'] + namespace = cli.prepare_and_parse_args(plugins, long_args) + self.assertEqual(namespace.domains, ['example.com']) + + long_args = ['--domains', 'example.com,another.net,example.com'] + namespace = cli.prepare_and_parse_args(plugins, long_args) + self.assertEqual(namespace.domains, ['example.com', 'another.net']) + @mock.patch('letsencrypt.crypto_util.notAfter') @mock.patch('letsencrypt.cli.zope.component.getUtility') def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter): @@ -341,6 +442,20 @@ class CLITest(unittest.TestCase): mock_sys.exit.assert_called_with(''.join( traceback.format_exception_only(KeyboardInterrupt, interrupt))) + def test_read_file(self): + from letsencrypt import cli + rel_test_path = os.path.relpath(os.path.join(self.tmp_dir, 'foo')) + self.assertRaises( + argparse.ArgumentTypeError, cli.read_file, rel_test_path) + + test_contents = 'bar\n' + with open(rel_test_path, 'w') as f: + f.write(test_contents) + + path, contents = cli.read_file(rel_test_path) + self.assertEqual(path, os.path.abspath(path)) + self.assertEqual(contents, test_contents) + class DetermineAccountTest(unittest.TestCase): """Tests for letsencrypt.cli._determine_account.""" diff --git a/letsencrypt/tests/client_test.py b/letsencrypt/tests/client_test.py index 0007323fa..d396e25bc 100644 --- a/letsencrypt/tests/client_test.py +++ b/letsencrypt/tests/client_test.py @@ -148,7 +148,7 @@ class ClientTest(unittest.TestCase): shutil.rmtree(tmp_path) - def test_deploy_certificate(self): + def test_deploy_certificate_success(self): self.assertRaises(errors.Error, self.client.deploy_certificate, ["foo.bar"], "key", "cert", "chain", "fullchain") @@ -166,17 +166,38 @@ class ClientTest(unittest.TestCase): self.assertEqual(installer.save.call_count, 2) installer.restart.assert_called_once_with() - def test_deploy_certificate_restart_failure_with_recovery(self): + def test_deploy_certificate_failure(self): + installer = mock.MagicMock() + self.client.installer = installer + + installer.deploy_cert.side_effect = errors.PluginError + self.assertRaises(errors.PluginError, self.client.deploy_certificate, + ["foo.bar"], "key", "cert", "chain", "fullchain") + installer.recovery_routine.assert_called_once_with() + + def test_deploy_certificate_save_failure(self): + installer = mock.MagicMock() + self.client.installer = installer + + installer.save.side_effect = errors.PluginError + self.assertRaises(errors.PluginError, self.client.deploy_certificate, + ["foo.bar"], "key", "cert", "chain", "fullchain") + installer.recovery_routine.assert_called_once_with() + + @mock.patch("letsencrypt.client.zope.component.getUtility") + def test_deploy_certificate_restart_failure(self, mock_get_utility): installer = mock.MagicMock() installer.restart.side_effect = [errors.PluginError, None] self.client.installer = installer self.assertRaises(errors.PluginError, self.client.deploy_certificate, ["foo.bar"], "key", "cert", "chain", "fullchain") + self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 2) - def test_deploy_certificate_restart_failure_without_recovery(self): + @mock.patch("letsencrypt.client.zope.component.getUtility") + def test_deploy_certificate_restart_failure2(self, mock_get_utility): installer = mock.MagicMock() installer.restart.side_effect = errors.PluginError installer.rollback_checkpoints.side_effect = errors.ReverterError @@ -184,6 +205,7 @@ class ClientTest(unittest.TestCase): self.assertRaises(errors.PluginError, self.client.deploy_certificate, ["foo.bar"], "key", "cert", "chain", "fullchain") + self.assertEqual(mock_get_utility().add_message.call_count, 1) installer.rollback_checkpoints.assert_called_once_with() self.assertEqual(installer.restart.call_count, 1) @@ -201,10 +223,68 @@ class ClientTest(unittest.TestCase): self.assertEqual(installer.save.call_count, 1) installer.restart.assert_called_once_with() + def test_enhance_config_no_installer(self): + self.assertRaises(errors.Error, + self.client.enhance_config, ["foo.bar"]) + + @mock.patch("letsencrypt.client.zope.component.getUtility") + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_enhance_failure(self, mock_enhancements, + mock_get_utility): + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer installer.enhance.side_effect = errors.PluginError + self.assertRaises(errors.PluginError, self.client.enhance_config, ["foo.bar"], True) installer.recovery_routine.assert_called_once_with() + self.assertEqual(mock_get_utility().add_message.call_count, 1) + + @mock.patch("letsencrypt.client.zope.component.getUtility") + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_save_failure(self, mock_enhancements, + mock_get_utility): + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + installer.save.side_effect = errors.PluginError + + self.assertRaises(errors.PluginError, + self.client.enhance_config, ["foo.bar"], True) + installer.recovery_routine.assert_called_once_with() + self.assertEqual(mock_get_utility().add_message.call_count, 1) + + @mock.patch("letsencrypt.client.zope.component.getUtility") + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_restart_failure(self, mock_enhancements, + mock_get_utility): + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + installer.restart.side_effect = [errors.PluginError, None] + + self.assertRaises(errors.PluginError, + self.client.enhance_config, ["foo.bar"], True) + self.assertEqual(mock_get_utility().add_message.call_count, 1) + installer.rollback_checkpoints.assert_called_once_with() + self.assertEqual(installer.restart.call_count, 2) + + @mock.patch("letsencrypt.client.zope.component.getUtility") + @mock.patch("letsencrypt.client.enhancements") + def test_enhance_config_restart_failure2(self, mock_enhancements, + mock_get_utility): + mock_enhancements.ask.return_value = True + installer = mock.MagicMock() + self.client.installer = installer + installer.restart.side_effect = errors.PluginError + installer.rollback_checkpoints.side_effect = errors.ReverterError + + self.assertRaises(errors.PluginError, + self.client.enhance_config, ["foo.bar"], True) + self.assertEqual(mock_get_utility().add_message.call_count, 1) + installer.rollback_checkpoints.assert_called_once_with() + self.assertEqual(installer.restart.call_count, 1) class RollbackTest(unittest.TestCase): diff --git a/letsencrypt/tests/configuration_test.py b/letsencrypt/tests/configuration_test.py index 3a8bf40cf..c42b99081 100644 --- a/letsencrypt/tests/configuration_test.py +++ b/letsencrypt/tests/configuration_test.py @@ -59,6 +59,38 @@ class NamespaceConfigTest(unittest.TestCase): self.namespace.http01_port = None self.assertEqual(80, self.config.http01_port) + def test_absolute_paths(self): + from letsencrypt.configuration import NamespaceConfig + + config_base = "foo" + work_base = "bar" + logs_base = "baz" + + mock_namespace = mock.MagicMock(spec=['config_dir', 'work_dir', + 'logs_dir', 'http01_port', + 'tls_sni_01_port', + 'domains', 'server']) + mock_namespace.config_dir = config_base + mock_namespace.work_dir = work_base + mock_namespace.logs_dir = logs_base + config = NamespaceConfig(mock_namespace) + + self.assertTrue(os.path.isabs(config.config_dir)) + self.assertEqual(config.config_dir, + os.path.join(os.getcwd(), config_base)) + self.assertTrue(os.path.isabs(config.work_dir)) + self.assertEqual(config.work_dir, + os.path.join(os.getcwd(), work_base)) + self.assertTrue(os.path.isabs(config.logs_dir)) + self.assertEqual(config.logs_dir, + os.path.join(os.getcwd(), logs_base)) + self.assertTrue(os.path.isabs(config.accounts_dir)) + self.assertTrue(os.path.isabs(config.backup_dir)) + self.assertTrue(os.path.isabs(config.csr_dir)) + self.assertTrue(os.path.isabs(config.in_progress_dir)) + self.assertTrue(os.path.isabs(config.key_dir)) + self.assertTrue(os.path.isabs(config.temp_checkpoint_dir)) + class RenewerConfigurationTest(unittest.TestCase): """Test for letsencrypt.configuration.RenewerConfiguration.""" @@ -81,6 +113,28 @@ class RenewerConfigurationTest(unittest.TestCase): self.config.renewal_configs_dir, '/tmp/config/renewal_configs') self.assertEqual(self.config.renewer_config_file, '/tmp/config/r.conf') + def test_absolute_paths(self): + from letsencrypt.configuration import NamespaceConfig + from letsencrypt.configuration import RenewerConfiguration + + config_base = "foo" + work_base = "bar" + logs_base = "baz" + + mock_namespace = mock.MagicMock(spec=['config_dir', 'work_dir', + 'logs_dir', 'http01_port', + 'tls_sni_01_port', + 'domains', 'server']) + mock_namespace.config_dir = config_base + mock_namespace.work_dir = work_base + mock_namespace.logs_dir = logs_base + config = RenewerConfiguration(NamespaceConfig(mock_namespace)) + + self.assertTrue(os.path.isabs(config.archive_dir)) + self.assertTrue(os.path.isabs(config.live_dir)) + self.assertTrue(os.path.isabs(config.renewal_configs_dir)) + self.assertTrue(os.path.isabs(config.renewer_config_file)) + if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/error_handler_test.py b/letsencrypt/tests/error_handler_test.py index c92f12435..7fbdcffd8 100644 --- a/letsencrypt/tests/error_handler_test.py +++ b/letsencrypt/tests/error_handler_test.py @@ -13,7 +13,11 @@ class ErrorHandlerTest(unittest.TestCase): from letsencrypt import error_handler self.init_func = mock.MagicMock() - self.handler = error_handler.ErrorHandler(self.init_func) + self.init_args = set((42,)) + self.init_kwargs = {'foo': 'bar'} + self.handler = error_handler.ErrorHandler(self.init_func, + *self.init_args, + **self.init_kwargs) # pylint: disable=protected-access self.signals = error_handler._SIGNALS @@ -23,7 +27,8 @@ class ErrorHandlerTest(unittest.TestCase): raise ValueError except ValueError: pass - self.init_func.assert_called_once_with() + self.init_func.assert_called_once_with(*self.init_args, + **self.init_kwargs) @mock.patch('letsencrypt.error_handler.os') @mock.patch('letsencrypt.error_handler.signal') @@ -37,7 +42,8 @@ class ErrorHandlerTest(unittest.TestCase): signum = self.signals[0] signal_handler(signum, None) - self.init_func.assert_called_once_with() + self.init_func.assert_called_once_with(*self.init_args, + **self.init_kwargs) mock_os.kill.assert_called_once_with(mock_os.getpid(), signum) self.handler.reset_signal_handlers() @@ -48,7 +54,8 @@ class ErrorHandlerTest(unittest.TestCase): bad_func = mock.MagicMock(side_effect=[ValueError]) self.handler.register(bad_func) self.handler.call_registered() - self.init_func.assert_called_once_with() + self.init_func.assert_called_once_with(*self.init_args, + **self.init_kwargs) bad_func.assert_called_once_with() def test_sysexit_ignored(self): diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index e76b6eb88..daec9678f 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -692,6 +692,9 @@ class RenewableCertTests(BaseRenewableCertTest): self.test_rc.configfile["renewalparams"]["http01_port"] = "1234" self.test_rc.configfile["renewalparams"]["account"] = "abcde" self.test_rc.configfile["renewalparams"]["domains"] = ["example.com"] + self.test_rc.configfile["renewalparams"]["config_dir"] = "config" + self.test_rc.configfile["renewalparams"]["work_dir"] = "work" + self.test_rc.configfile["renewalparams"]["logs_dir"] = "logs" mock_auth = mock.MagicMock() mock_pd.PluginsRegistry.find_all.return_value = {"apache": mock_auth} # Fails because "fake" != "apache" diff --git a/letshelp-letsencrypt/docs/conf.py b/letshelp-letsencrypt/docs/conf.py index 206b0b9e2..a84c4c982 100644 --- a/letshelp-letsencrypt/docs/conf.py +++ b/letshelp-letsencrypt/docs/conf.py @@ -225,25 +225,25 @@ htmlhelp_basename = 'letshelp-letsencryptdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # Additional stuff for the LaTeX preamble. + #'preamble': '', -# Latex figure (float) alignment -#'figure_align': 'htbp', + # Latex figure (float) alignment + #'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'letshelp-letsencrypt.tex', u'letshelp-letsencrypt Documentation', - u'Let\'s Encrypt Project', 'manual'), + (master_doc, 'letshelp-letsencrypt.tex', u'letshelp-letsencrypt Documentation', + u'Let\'s Encrypt Project', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -286,9 +286,9 @@ man_pages = [ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'letshelp-letsencrypt', u'letshelp-letsencrypt Documentation', - author, 'letshelp-letsencrypt', 'One line description of project.', - 'Miscellaneous'), + (master_doc, 'letshelp-letsencrypt', u'letshelp-letsencrypt Documentation', + author, 'letshelp-letsencrypt', 'One line description of project.', + 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh index d35ecbcff..53996cd20 100755 --- a/tests/boulder-integration.sh +++ b/tests/boulder-integration.sh @@ -40,7 +40,7 @@ common auth --csr "$CSR_PATH" \ openssl x509 -in "${root}/csr/0000_cert.pem" -text openssl x509 -in "${root}/csr/0000_chain.pem" -text -common --domain le3.wtf install \ +common --domains le3.wtf install \ --cert-path "${root}/csr/cert.pem" \ --key-path "${root}/csr/key.pem"